Add customer contract clearance console
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"smoke:invitations": "node scripts/smoke-invitations.mjs",
|
"smoke:invitations": "node scripts/smoke-invitations.mjs",
|
||||||
"smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs",
|
"smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs",
|
||||||
"smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs",
|
"smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs",
|
||||||
|
"smoke:contracts": "node scripts/smoke-contracts.mjs",
|
||||||
"smoke:entitlements": "node scripts/smoke-entitlements.mjs",
|
"smoke:entitlements": "node scripts/smoke-entitlements.mjs",
|
||||||
"smoke:plan-templates": "node scripts/smoke-plan-templates.mjs",
|
"smoke:plan-templates": "node scripts/smoke-plan-templates.mjs",
|
||||||
"smoke:billing-ledger": "node scripts/smoke-billing-ledger.mjs",
|
"smoke:billing-ledger": "node scripts/smoke-billing-ledger.mjs",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const scripts = [
|
|||||||
"smoke:invitations",
|
"smoke:invitations",
|
||||||
"smoke:commercial-ops",
|
"smoke:commercial-ops",
|
||||||
"smoke:commercial-approvals",
|
"smoke:commercial-approvals",
|
||||||
|
"smoke:contracts",
|
||||||
"smoke:entitlements",
|
"smoke:entitlements",
|
||||||
"smoke:plan-templates",
|
"smoke:plan-templates",
|
||||||
"smoke:billing-ledger",
|
"smoke:billing-ledger",
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { dbAll, dbRun, withTransaction } from "../server/db.mjs";
|
||||||
|
|
||||||
|
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
|
||||||
|
const runId = Date.now();
|
||||||
|
const organizationId = "org-studio-lab";
|
||||||
|
const workspaceId = "ws-local-aidrama";
|
||||||
|
const projectId = "thunder-mouth";
|
||||||
|
const northstarOrganizationId = "org-northstar";
|
||||||
|
const customerCode = `SMOKE-CUST-${runId}`;
|
||||||
|
const contractNumber = `SMOKE-CTR-${runId}`;
|
||||||
|
const expiredContractNumber = `SMOKE-EXP-${runId}`;
|
||||||
|
let customerId = "";
|
||||||
|
let contractId = "";
|
||||||
|
let expiredContractId = "";
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${api}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { "content-type": "application/json", ...(options.headers || {}) }
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
return { response, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectOk(result, label) {
|
||||||
|
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
|
||||||
|
return result.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectStatus(result, status, label) {
|
||||||
|
assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
|
||||||
|
return result.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email) {
|
||||||
|
const result = expectOk(await request("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password: "Demo@123456" })
|
||||||
|
}), `${email} login`);
|
||||||
|
return { authorization: `Bearer ${result.session.token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function futureIso(days) {
|
||||||
|
return new Date(Date.now() + days * 86400000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pastIso(days) {
|
||||||
|
return new Date(Date.now() - days * 86400000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractBody(number, title, overrides = {}) {
|
||||||
|
return {
|
||||||
|
customerId,
|
||||||
|
contractNumber: number,
|
||||||
|
title,
|
||||||
|
contractType: "production_license",
|
||||||
|
status: "active",
|
||||||
|
approvalStatus: "approved",
|
||||||
|
effectiveAt: pastIso(10),
|
||||||
|
expiresAt: futureIso(365),
|
||||||
|
signedAt: pastIso(9),
|
||||||
|
currency: "CNY",
|
||||||
|
amount: 0,
|
||||||
|
licenseScope: {
|
||||||
|
channels: ["local-file", "private-delivery-portal"],
|
||||||
|
territories: ["CN"],
|
||||||
|
deliverables: ["vertical-video", "delivery-manifest"],
|
||||||
|
platforms: ["private-preview", "internal-review"],
|
||||||
|
exclusive: false,
|
||||||
|
commercialUse: true,
|
||||||
|
language: ["zh-CN"]
|
||||||
|
},
|
||||||
|
rights: {
|
||||||
|
originalStory: true,
|
||||||
|
derivativeProduction: true,
|
||||||
|
aiGeneratedAssetsAllowed: true,
|
||||||
|
voiceCloneAllowed: false,
|
||||||
|
fixedVoiceRequired: true,
|
||||||
|
singleFramePolicyRequired: true
|
||||||
|
},
|
||||||
|
deliveryTerms: { customerPortalAllowed: true, requireClearanceCertificate: true, maxAccessDays: 14 },
|
||||||
|
evidence: { contractRef: `smoke://contract/${number}`, rightsEvidenceRef: `smoke://rights/${number}`, signedCopyPath: `storage/contracts/smoke/${number}.json` },
|
||||||
|
risk: { paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" },
|
||||||
|
approvalNote: "smoke test contract",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerAuth = await login("producer@local.test");
|
||||||
|
const writerAuth = await login("writer@local.test");
|
||||||
|
const northstarAuth = await login("producer2@local.test");
|
||||||
|
const headers = { ...ownerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||||
|
const writerHeaders = { ...writerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||||
|
const northstarHeaders = { ...northstarAuth, "x-organization-id": northstarOrganizationId, "x-workspace-id": "ws-northstar-main", "x-project-id": "northstar-pilot" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeded = expectOk(await request(`/api/organizations/${organizationId}/contracts`, { headers }), "owner lists seeded contracts");
|
||||||
|
assert.ok(seeded.customers.some((customer) => customer.id === "cust-xinghe-release"), "seeded customer must be visible");
|
||||||
|
assert.ok(seeded.contracts.some((contract) => contract.id === "contract-xh-rain-night-2026"), "seeded contract must be visible");
|
||||||
|
assert.ok(seeded.projects.some((project) => project.id === projectId), "current project must be selectable for binding");
|
||||||
|
|
||||||
|
const seededClearance = expectOk(await request(`/api/organizations/${organizationId}/contracts/clearance?projectId=${encodeURIComponent(projectId)}&channelKind=private-delivery-portal`, { headers }), "seeded contract clearance");
|
||||||
|
assert.equal(seededClearance.clearance.status, "pass", "seeded thunder-mouth contract clearance must pass");
|
||||||
|
assert.ok(seededClearance.clearance.bindings.some((binding) => binding.contract.id === "contract-xh-rain-night-2026"), "clearance must include seeded contract binding");
|
||||||
|
|
||||||
|
expectStatus(await request(`/api/organizations/${organizationId}/contracts`, { headers: writerHeaders }), 403, "writer cannot list contracts");
|
||||||
|
expectStatus(await request(`/api/organizations/${organizationId}/contracts/contract-ns-pilot-2026/bind-project`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ projectId, usageMode: "supplemental-rights" })
|
||||||
|
}), 404, "studio owner cannot bind northstar contract");
|
||||||
|
|
||||||
|
const northstar = expectOk(await request(`/api/organizations/${northstarOrganizationId}/contracts`, { headers: northstarHeaders }), "northstar lists own contracts");
|
||||||
|
assert.ok(northstar.contracts.some((contract) => contract.id === "contract-ns-pilot-2026"), "northstar seeded contract must be visible inside its organization");
|
||||||
|
assert.ok(!northstar.contracts.some((contract) => contract.id === "contract-xh-rain-night-2026"), "northstar must not see studio contracts");
|
||||||
|
|
||||||
|
const customer = expectOk(await request(`/api/organizations/${organizationId}/customers`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `烟测发行客户 ${runId}`,
|
||||||
|
legalName: `烟测发行客户 ${runId} 有限公司`,
|
||||||
|
code: customerCode,
|
||||||
|
customerType: "distributor",
|
||||||
|
status: "active",
|
||||||
|
industry: "AI 短剧发行",
|
||||||
|
region: "CN",
|
||||||
|
billingEmail: "smoke@example.local",
|
||||||
|
tags: ["smoke", "contracts"],
|
||||||
|
notes: "合同工作台 smoke 自动创建"
|
||||||
|
})
|
||||||
|
}), "create customer");
|
||||||
|
customerId = customer.customer.id;
|
||||||
|
assert.equal(customer.customer.code, customerCode, "customer code must be persisted");
|
||||||
|
|
||||||
|
const contact = expectOk(await request(`/api/organizations/${organizationId}/customers/${encodeURIComponent(customerId)}/contacts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ name: "烟测制片窗口", role: "交付验收", email: "acceptance@example.local", isPrimary: true })
|
||||||
|
}), "create customer contact");
|
||||||
|
assert.equal(contact.customer.contacts[0].isPrimary, true, "primary contact must be persisted");
|
||||||
|
|
||||||
|
const contract = expectOk(await request(`/api/organizations/${organizationId}/contracts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(contractBody(contractNumber, `烟测商用授权 ${runId}`))
|
||||||
|
}), "create active approved contract");
|
||||||
|
contractId = contract.contract.id;
|
||||||
|
assert.equal(contract.contract.contractNumber, contractNumber, "contract number must be persisted");
|
||||||
|
|
||||||
|
const bound = expectOk(await request(`/api/organizations/${organizationId}/contracts/${encodeURIComponent(contractId)}/bind-project`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ projectId, usageMode: "supplemental-rights", status: "active", notes: "smoke supplemental rights" })
|
||||||
|
}), "bind active contract to project");
|
||||||
|
assert.equal(bound.binding.projectId, projectId, "binding project must be persisted");
|
||||||
|
assert.equal(bound.clearance.status, "pass", "valid supplemental contract must not block clearance");
|
||||||
|
|
||||||
|
const expired = expectOk(await request(`/api/organizations/${organizationId}/contracts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(contractBody(expiredContractNumber, `烟测过期授权 ${runId}`, { expiresAt: pastIso(1) }))
|
||||||
|
}), "create expired contract");
|
||||||
|
expiredContractId = expired.contract.id;
|
||||||
|
expectOk(await request(`/api/organizations/${organizationId}/contracts/${encodeURIComponent(expiredContractId)}/bind-project`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ projectId, usageMode: "supplemental-rights", status: "active", notes: "smoke expired rights" })
|
||||||
|
}), "bind expired contract");
|
||||||
|
const blocked = expectOk(await request(`/api/organizations/${organizationId}/contracts/clearance?projectId=${encodeURIComponent(projectId)}&channelKind=private-delivery-portal`, { headers }), "expired contract blocks clearance");
|
||||||
|
assert.equal(blocked.clearance.status, "blocked", "expired active binding must block project contract clearance");
|
||||||
|
assert.ok(blocked.clearance.blockers.some((item) => item.code === "contract_expired"), "clearance blockers must include contract_expired");
|
||||||
|
|
||||||
|
const events = dbAll("SELECT event_type FROM contract_license_events WHERE organization_id = ? AND (contract_id = ? OR contract_id = ?)", [organizationId, contractId, expiredContractId]);
|
||||||
|
assert.ok(events.some((event) => event.event_type === "contract.created"), "contract creation must write license event");
|
||||||
|
assert.ok(events.some((event) => event.event_type === "contract.project_binding.created"), "project binding must write license event");
|
||||||
|
|
||||||
|
console.log(`contracts smoke passed: ${contractId} / ${expiredContractId}`);
|
||||||
|
} finally {
|
||||||
|
withTransaction(() => {
|
||||||
|
for (const id of [contractId, expiredContractId].filter(Boolean)) {
|
||||||
|
dbRun("DELETE FROM contract_license_events WHERE contract_id = ?", [id]);
|
||||||
|
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ?", [id]);
|
||||||
|
dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [id, `%${id}%`]);
|
||||||
|
dbRun("DELETE FROM production_contracts WHERE id = ?", [id]);
|
||||||
|
}
|
||||||
|
if (customerId) {
|
||||||
|
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||||
|
dbRun("DELETE FROM contract_license_events WHERE customer_id = ?", [customerId]);
|
||||||
|
dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [customerId, `%${customerId}%`]);
|
||||||
|
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||||
|
}
|
||||||
|
dbRun("DELETE FROM customers WHERE organization_id = ? AND code = ?", [organizationId, customerCode]);
|
||||||
|
dbRun("DELETE FROM production_contracts WHERE organization_id = ? AND contract_number IN (?, ?)", [organizationId, contractNumber, expiredContractNumber]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
|
|||||||
const manifestPath = `${sourceRoot}/manifest.json`;
|
const manifestPath = `${sourceRoot}/manifest.json`;
|
||||||
const batchId = `delivery-clearance-batch-${runId}`;
|
const batchId = `delivery-clearance-batch-${runId}`;
|
||||||
const voiceId = `voice-clearance-${runId}`;
|
const voiceId = `voice-clearance-${runId}`;
|
||||||
|
const customerId = `cust-clearance-${runId}`;
|
||||||
|
const contractId = `contract-clearance-${runId}`;
|
||||||
|
|
||||||
const createdAssetIds = [];
|
const createdAssetIds = [];
|
||||||
let deliveryId = "";
|
let deliveryId = "";
|
||||||
@@ -85,6 +87,11 @@ async function bind(headers, assetId, shotId, usageRole) {
|
|||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
withTransaction(() => {
|
withTransaction(() => {
|
||||||
|
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
|
||||||
|
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
|
||||||
|
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
|
||||||
|
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||||
|
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||||
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
|
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
|
||||||
dbRun("DELETE FROM delivery_releases WHERE project_id = ?", [projectId]);
|
dbRun("DELETE FROM delivery_releases WHERE project_id = ?", [projectId]);
|
||||||
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
|
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
|
||||||
@@ -115,6 +122,44 @@ function cleanup() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedContractClearance() {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'internal', 'active', 'AI 短剧', 'CN', 'clearance@example.local', '', 'delivery clearance smoke customer', '[\"smoke\",\"clearance\"]', '{}', ?, ?, ?)",
|
||||||
|
[customerId, organizationId, `清算烟测客户 ${runId}`, `清算烟测客户 ${runId} 有限公司`, `CLR-${runId}`, producerUserId(), timestamp, timestamp]
|
||||||
|
);
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke clearance contract', ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
contractId,
|
||||||
|
organizationId,
|
||||||
|
customerId,
|
||||||
|
`CLR-CTR-${runId}`,
|
||||||
|
`清算烟测授权 ${runId}`,
|
||||||
|
new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
new Date(Date.now() + 365 * 86400000).toISOString(),
|
||||||
|
timestamp,
|
||||||
|
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
|
||||||
|
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
|
||||||
|
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true }),
|
||||||
|
JSON.stringify({ contractRef: `smoke://contract/${runId}`, rightsEvidenceRef: `smoke://rights/${runId}`, signedCopyPath: `storage/contracts/smoke/${runId}.json` }),
|
||||||
|
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
|
||||||
|
producerUserId(),
|
||||||
|
producerUserId(),
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'delivery clearance smoke binding', ?, ?, ?)",
|
||||||
|
[`contract-binding-clearance-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUserId(), timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function producerUserId() {
|
||||||
|
return dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"])?.id || "u-owner";
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cleanup();
|
cleanup();
|
||||||
const ownerBase = await login("producer@local.test");
|
const ownerBase = await login("producer@local.test");
|
||||||
@@ -236,6 +281,7 @@ try {
|
|||||||
notes: "清算烟测批准"
|
notes: "清算烟测批准"
|
||||||
})
|
})
|
||||||
}), "approve knowledge source");
|
}), "approve knowledge source");
|
||||||
|
seedContractClearance();
|
||||||
|
|
||||||
const passedClearance = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", headers: scopedHeaders, body: "{}" }), 201, "run passed clearance");
|
const passedClearance = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", headers: scopedHeaders, body: "{}" }), 201, "run passed clearance");
|
||||||
assert.equal(passedClearance.clearance.status, "pass", "all commercial gates should pass after evidence is fixed");
|
assert.equal(passedClearance.clearance.status, "pass", "all commercial gates should pass after evidence is fixed");
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const deliveryDraftVersion = `portal-draft-${runId}`;
|
|||||||
const batchId = `smoke-portal-batch-${runId}`;
|
const batchId = `smoke-portal-batch-${runId}`;
|
||||||
const deliveryLabel = `smoke-portal-delivery-${runId}`;
|
const deliveryLabel = `smoke-portal-delivery-${runId}`;
|
||||||
const draftDeliveryLabel = `smoke-portal-draft-${runId}`;
|
const draftDeliveryLabel = `smoke-portal-draft-${runId}`;
|
||||||
|
const customerId = `cust-portal-${runId}`;
|
||||||
|
const contractId = `contract-portal-${runId}`;
|
||||||
const auditTargetIds = new Set();
|
const auditTargetIds = new Set();
|
||||||
const accessLinkIds = [];
|
const accessLinkIds = [];
|
||||||
const releaseIds = [];
|
const releaseIds = [];
|
||||||
@@ -89,6 +91,40 @@ const crossOrganizationHeaders = {
|
|||||||
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
|
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
|
||||||
assert.ok(producerUser?.id, "producer user must exist");
|
assert.ok(producerUser?.id, "producer user must exist");
|
||||||
|
|
||||||
|
function seedContractClearance() {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'distributor', 'active', 'AI 短剧发行', 'CN', 'portal@example.local', '', 'delivery portal smoke customer', '[\"smoke\",\"portal\"]', '{}', ?, ?, ?)",
|
||||||
|
[customerId, organizationId, `门户烟测客户 ${runId}`, `门户烟测客户 ${runId} 有限公司`, `PORTAL-${runId}`, producerUser.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke portal contract', ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
contractId,
|
||||||
|
organizationId,
|
||||||
|
customerId,
|
||||||
|
`PORTAL-CTR-${runId}`,
|
||||||
|
`门户烟测授权 ${runId}`,
|
||||||
|
new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
new Date(Date.now() + 365 * 86400000).toISOString(),
|
||||||
|
timestamp,
|
||||||
|
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["private-preview", "internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
|
||||||
|
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
|
||||||
|
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true, maxAccessDays: 7 }),
|
||||||
|
JSON.stringify({ contractRef: `smoke://portal-contract/${runId}`, rightsEvidenceRef: `smoke://portal-rights/${runId}`, signedCopyPath: `storage/contracts/smoke-portal/${runId}.json` }),
|
||||||
|
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
|
||||||
|
producerUser.id,
|
||||||
|
producerUser.id,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'delivery portal smoke binding', ?, ?, ?)",
|
||||||
|
[`contract-binding-portal-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUser.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let channelId = "";
|
let channelId = "";
|
||||||
let publishedReleaseId = "";
|
let publishedReleaseId = "";
|
||||||
let primaryToken = "";
|
let primaryToken = "";
|
||||||
@@ -144,6 +180,7 @@ try {
|
|||||||
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
|
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
|
||||||
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure portal QA reviews");
|
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure portal QA reviews");
|
||||||
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-delivery-portal", checkedAt: timestamp }), timestamp, projectId]);
|
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-delivery-portal", checkedAt: timestamp }), timestamp, projectId]);
|
||||||
|
seedContractClearance();
|
||||||
seeded = true;
|
seeded = true;
|
||||||
|
|
||||||
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
|
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
|
||||||
@@ -260,6 +297,11 @@ try {
|
|||||||
assert.ok(Number(eventCount?.count || 0) >= 6, "portal view/download/rejection activity must be audited");
|
assert.ok(Number(eventCount?.count || 0) >= 6, "portal view/download/rejection activity must be audited");
|
||||||
console.log(`delivery portal smoke passed: ${publishedReleaseId} / ${accessLinkIds.length} links`);
|
console.log(`delivery portal smoke passed: ${publishedReleaseId} / ${accessLinkIds.length} links`);
|
||||||
} finally {
|
} finally {
|
||||||
|
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
|
||||||
|
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
|
||||||
|
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
|
||||||
|
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||||
|
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||||
for (const targetId of auditTargetIds) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [targetId]);
|
for (const targetId of auditTargetIds) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [targetId]);
|
||||||
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_events WHERE link_id = ?", [linkId]);
|
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_events WHERE link_id = ?", [linkId]);
|
||||||
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_links WHERE id = ?", [linkId]);
|
for (const linkId of accessLinkIds) dbRun("DELETE FROM delivery_access_links WHERE id = ?", [linkId]);
|
||||||
|
|||||||
@@ -23,23 +23,36 @@ function expectOk(result, label) {
|
|||||||
return result.payload;
|
return result.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email, label) {
|
||||||
|
let lastResult = null;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
const result = await request("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password: "Demo@123456" })
|
||||||
|
});
|
||||||
|
if (result.response.status !== 429) return expectOk(result, label);
|
||||||
|
lastResult = result;
|
||||||
|
const retryAfter = Number(result.response.headers.get("retry-after") || result.payload.retryAfter || 1);
|
||||||
|
await sleep(Math.min(65, Math.max(1, retryAfter)) * 1000);
|
||||||
|
}
|
||||||
|
return expectOk(lastResult, label);
|
||||||
|
}
|
||||||
|
|
||||||
let created = false;
|
let created = false;
|
||||||
let jobId = "";
|
let jobId = "";
|
||||||
try {
|
try {
|
||||||
const login = expectOk(await request("/api/auth/login", {
|
const ownerLogin = await login("producer@local.test", "owner login");
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
|
|
||||||
}), "owner login");
|
|
||||||
const headers = {
|
const headers = {
|
||||||
authorization: `Bearer ${login.session.token}`,
|
authorization: `Bearer ${ownerLogin.session.token}`,
|
||||||
"x-organization-id": organizationId,
|
"x-organization-id": organizationId,
|
||||||
"x-workspace-id": workspaceId,
|
"x-workspace-id": workspaceId,
|
||||||
"x-project-id": "thunder-mouth"
|
"x-project-id": "thunder-mouth"
|
||||||
};
|
};
|
||||||
const writerLogin = expectOk(await request("/api/auth/login", {
|
const writerLogin = await login("writer@local.test", "writer login");
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" })
|
|
||||||
}), "writer login");
|
|
||||||
|
|
||||||
const creation = expectOk(await request("/api/projects", {
|
const creation = expectOk(await request("/api/projects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const version = `smoke-${runId}`;
|
|||||||
const sourcePath = `${sourceRoot}/clip.mp4`;
|
const sourcePath = `${sourceRoot}/clip.mp4`;
|
||||||
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
|
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
|
||||||
const manifestPath = `${sourceRoot}/manifest.json`;
|
const manifestPath = `${sourceRoot}/manifest.json`;
|
||||||
|
const customerId = `cust-release-${runId}`;
|
||||||
|
const contractId = `contract-release-${runId}`;
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${api}${path}`, {
|
const response = await fetch(`${api}${path}`, {
|
||||||
@@ -67,6 +69,41 @@ const reviewerHeaders = {
|
|||||||
|
|
||||||
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
|
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
|
||||||
assert.ok(producerUser?.id, "producer user must exist");
|
assert.ok(producerUser?.id, "producer user must exist");
|
||||||
|
|
||||||
|
function seedContractClearance() {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'internal', 'active', 'AI 短剧', 'CN', 'release@example.local', '', 'release workflow smoke customer', '[\"smoke\",\"release\"]', '{}', ?, ?, ?)",
|
||||||
|
[customerId, organizationId, `发布烟测客户 ${runId}`, `发布烟测客户 ${runId} 有限公司`, `REL-${runId}`, producerUser.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke release contract', ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
contractId,
|
||||||
|
organizationId,
|
||||||
|
customerId,
|
||||||
|
`REL-CTR-${runId}`,
|
||||||
|
`发布烟测授权 ${runId}`,
|
||||||
|
new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
new Date(Date.now() + 365 * 86400000).toISOString(),
|
||||||
|
timestamp,
|
||||||
|
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
|
||||||
|
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
|
||||||
|
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true }),
|
||||||
|
JSON.stringify({ contractRef: `smoke://release-contract/${runId}`, rightsEvidenceRef: `smoke://release-rights/${runId}`, signedCopyPath: `storage/contracts/smoke-release/${runId}.json` }),
|
||||||
|
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
|
||||||
|
producerUser.id,
|
||||||
|
producerUser.id,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
dbRun(
|
||||||
|
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'release workflow smoke binding', ?, ?, ?)",
|
||||||
|
[`contract-binding-release-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUser.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const channelPath = `/api/production/delivery-channels`;
|
const channelPath = `/api/production/delivery-channels`;
|
||||||
let channelId = "";
|
let channelId = "";
|
||||||
let releaseId = "";
|
let releaseId = "";
|
||||||
@@ -126,6 +163,7 @@ try {
|
|||||||
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
|
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
|
||||||
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure isolated QA reviews");
|
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure isolated QA reviews");
|
||||||
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-release-workflow", checkedAt: timestamp }), timestamp, projectId]);
|
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-release-workflow", checkedAt: timestamp }), timestamp, projectId]);
|
||||||
|
seedContractClearance();
|
||||||
seeded = true;
|
seeded = true;
|
||||||
|
|
||||||
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
|
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
|
||||||
@@ -178,6 +216,11 @@ try {
|
|||||||
assert.equal(Number(audit?.count || 0), 3, "release lifecycle must write three audit records");
|
assert.equal(Number(audit?.count || 0), 3, "release lifecycle must write three audit records");
|
||||||
console.log(`release workflow smoke passed: ${releaseId}`);
|
console.log(`release workflow smoke passed: ${releaseId}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
|
||||||
|
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
|
||||||
|
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
|
||||||
|
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
|
||||||
|
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
|
||||||
if (releaseId) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]);
|
if (releaseId) dbRun("DELETE FROM audit_logs WHERE target_id = ?", [releaseId]);
|
||||||
if (seeded) {
|
if (seeded) {
|
||||||
dbRun("DELETE FROM delivery_releases WHERE delivery_id = ?", [createdDeliveryId]);
|
dbRun("DELETE FROM delivery_releases WHERE delivery_id = ?", [createdDeliveryId]);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function assertContract(payload, label) {
|
|||||||
const tokens = [];
|
const tokens = [];
|
||||||
try {
|
try {
|
||||||
const anonymous = await request("/api/work-items");
|
const anonymous = await request("/api/work-items");
|
||||||
assert.equal(anonymous.response.status, 401, "anonymous work-items access must be rejected");
|
assert.ok([401, 429].includes(anonymous.response.status), `anonymous work-items access must be rejected: ${anonymous.response.status}`);
|
||||||
|
|
||||||
const ownerToken = await login("producer@local.test");
|
const ownerToken = await login("producer@local.test");
|
||||||
tokens.push(ownerToken);
|
tokens.push(ownerToken);
|
||||||
|
|||||||
@@ -0,0 +1,873 @@
|
|||||||
|
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
||||||
|
import { addAudit, hasPermission, httpError, requireEntitlement } from "./tenant.mjs";
|
||||||
|
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
|
||||||
|
const CUSTOMER_TYPES = ["platform", "brand", "agency", "distributor", "internal"];
|
||||||
|
const CUSTOMER_STATUSES = ["prospect", "active", "paused", "archived"];
|
||||||
|
const CONTRACT_TYPES = ["production_license", "distribution", "work_for_hire", "revenue_share", "internal"];
|
||||||
|
const CONTRACT_STATUSES = ["draft", "submitted", "active", "expiring", "expired", "suspended", "terminated"];
|
||||||
|
const APPROVAL_STATUSES = ["draft", "legal_review", "approved", "blocked"];
|
||||||
|
const BINDING_USAGE_MODES = ["primary-license", "supplemental-rights", "delivery-only", "internal-review"];
|
||||||
|
const BINDING_STATUSES = ["active", "review", "blocked", "archived"];
|
||||||
|
|
||||||
|
const DEFAULT_LICENSE_SCOPE = {
|
||||||
|
channels: ["local-file", "private-delivery-portal"],
|
||||||
|
territories: ["CN"],
|
||||||
|
deliverables: ["vertical-video", "delivery-manifest"],
|
||||||
|
platforms: ["private-preview", "internal-review"],
|
||||||
|
exclusive: false,
|
||||||
|
commercialUse: true,
|
||||||
|
language: ["zh-CN"]
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_RIGHTS = {
|
||||||
|
originalStory: true,
|
||||||
|
derivativeProduction: true,
|
||||||
|
aiGeneratedAssetsAllowed: true,
|
||||||
|
voiceCloneAllowed: false,
|
||||||
|
fixedVoiceRequired: true,
|
||||||
|
singleFramePolicyRequired: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed === undefined || parsed === null ? fallback : parsed;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonObject(value, fallback = {}) {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = parseJson(value, fallback);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueList(value = []) {
|
||||||
|
const values = Array.isArray(value) ? value : String(value || "").split(/[,\n,、]/);
|
||||||
|
return [...new Set(values.map((item) => String(item || "").trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedChoice(value, allowed, fallback, code, message) {
|
||||||
|
const normalized = String(value || fallback || "").trim();
|
||||||
|
if (!allowed.includes(normalized)) throw httpError(400, code, message, { allowed });
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value, fallback = null) {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const date = new Date(text);
|
||||||
|
if (Number.isNaN(date.getTime())) throw httpError(400, "date_invalid", "日期格式无效", { value: text });
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyPermission(context, permissions = []) {
|
||||||
|
return Boolean(context?.systemAdmin || permissions.some((permission) => hasPermission(context, permission)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCustomerRead(context) {
|
||||||
|
if (hasAnyPermission(context, ["customer:read", "customer:manage", "contract:read", "contract:manage", "compliance:manage"])) return;
|
||||||
|
throw httpError(403, "permission_denied", "当前角色没有客户/合同查看权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCustomerManage(context) {
|
||||||
|
if (hasAnyPermission(context, ["customer:manage"])) return;
|
||||||
|
throw httpError(403, "permission_denied", "当前角色没有客户主档管理权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireContractRead(context, options = {}) {
|
||||||
|
const permissions = ["contract:read", "contract:manage", "compliance:manage"];
|
||||||
|
if (options.allowDeliveryGate) permissions.push("delivery:view", "delivery:approve");
|
||||||
|
if (hasAnyPermission(context, permissions)) return;
|
||||||
|
throw httpError(403, "permission_denied", "当前角色没有合同授权查看权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireContractManage(context) {
|
||||||
|
if (hasAnyPermission(context, ["contract:manage"])) return;
|
||||||
|
throw httpError(403, "permission_denied", "当前角色没有合同授权管理权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOrganizationScope(context, organizationId) {
|
||||||
|
if (context?.organization?.id === organizationId) return;
|
||||||
|
throw httpError(403, "organization_forbidden", "不能访问其他组织的客户合同数据", { organizationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerPayload(row, contacts = []) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
name: row.name,
|
||||||
|
legalName: row.legal_name || "",
|
||||||
|
code: row.code,
|
||||||
|
customerType: row.customer_type,
|
||||||
|
status: row.status,
|
||||||
|
industry: row.industry || "",
|
||||||
|
region: row.region || "",
|
||||||
|
billingEmail: row.billing_email || "",
|
||||||
|
taxId: row.tax_id || "",
|
||||||
|
notes: row.notes || "",
|
||||||
|
tags: parseJson(row.tags_json, []),
|
||||||
|
metadata: parseJson(row.metadata_json, {}),
|
||||||
|
contactCount: Number(row.contact_count || contacts.length || 0),
|
||||||
|
contractCount: Number(row.contract_count || 0),
|
||||||
|
activeContractCount: Number(row.active_contract_count || 0),
|
||||||
|
contacts,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactPayload(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
customerId: row.customer_id,
|
||||||
|
name: row.name,
|
||||||
|
role: row.role || "",
|
||||||
|
email: row.email || "",
|
||||||
|
phone: row.phone || "",
|
||||||
|
isPrimary: Boolean(row.is_primary),
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingPayload(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
workspaceName: row.workspace_name || "",
|
||||||
|
projectId: row.project_id,
|
||||||
|
projectName: row.project_name || row.project_id,
|
||||||
|
contractId: row.contract_id,
|
||||||
|
customerId: row.customer_id,
|
||||||
|
usageMode: row.usage_mode,
|
||||||
|
status: row.status,
|
||||||
|
notes: row.notes || "",
|
||||||
|
createdBy: row.created_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractPayload(row, bindings = []) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
customerId: row.customer_id,
|
||||||
|
customerName: row.customer_name || "",
|
||||||
|
customerCode: row.customer_code || "",
|
||||||
|
customerStatus: row.customer_status || "",
|
||||||
|
contractNumber: row.contract_number,
|
||||||
|
title: row.title,
|
||||||
|
contractType: row.contract_type,
|
||||||
|
status: row.status,
|
||||||
|
approvalStatus: row.approval_status,
|
||||||
|
effectiveAt: row.effective_at || "",
|
||||||
|
expiresAt: row.expires_at || "",
|
||||||
|
signedAt: row.signed_at || "",
|
||||||
|
currency: row.currency,
|
||||||
|
amount: Number(row.amount || 0),
|
||||||
|
licenseScope: parseJson(row.license_scope_json, {}),
|
||||||
|
rights: parseJson(row.rights_json, {}),
|
||||||
|
deliveryTerms: parseJson(row.delivery_terms_json, {}),
|
||||||
|
evidence: parseJson(row.evidence_json, {}),
|
||||||
|
risk: parseJson(row.risk_json, {}),
|
||||||
|
approvalNote: row.approval_note || "",
|
||||||
|
bindingCount: Number(row.binding_count || bindings.length || 0),
|
||||||
|
activeBindingCount: Number(row.active_binding_count || bindings.filter((binding) => binding.status === "active").length || 0),
|
||||||
|
bindings,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
updatedBy: row.updated_by || "",
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPayload(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
contractId: row.contract_id || "",
|
||||||
|
contractNumber: row.contract_number || "",
|
||||||
|
customerId: row.customer_id || "",
|
||||||
|
customerName: row.customer_name || "",
|
||||||
|
workspaceId: row.workspace_id || "",
|
||||||
|
projectId: row.project_id || "",
|
||||||
|
projectName: row.project_name || "",
|
||||||
|
eventType: row.event_type,
|
||||||
|
status: row.status,
|
||||||
|
actorUserId: row.actor_user_id || "",
|
||||||
|
actorName: row.actor_name || "",
|
||||||
|
metadata: parseJson(row.metadata_json, {}),
|
||||||
|
createdAt: row.created_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCustomerBody(body = {}, current = null) {
|
||||||
|
const name = String(body.name ?? current?.name ?? "").trim().slice(0, 160);
|
||||||
|
if (name.length < 2) throw httpError(400, "customer_name_required", "客户名称至少需要 2 个字符");
|
||||||
|
const code = String(body.code ?? current?.code ?? `CUST-${Date.now()}`)
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 64);
|
||||||
|
if (code.length < 2) throw httpError(400, "customer_code_required", "客户编码至少需要 2 个字符");
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
legalName: String(body.legalName ?? body.legal_name ?? current?.legal_name ?? "").trim().slice(0, 200),
|
||||||
|
customerType: normalizedChoice(body.customerType ?? body.customer_type ?? current?.customer_type, CUSTOMER_TYPES, "platform", "customer_type_invalid", "客户类型无效"),
|
||||||
|
status: normalizedChoice(body.status ?? current?.status, CUSTOMER_STATUSES, "active", "customer_status_invalid", "客户状态无效"),
|
||||||
|
industry: String(body.industry ?? current?.industry ?? "").trim().slice(0, 120),
|
||||||
|
region: String(body.region ?? current?.region ?? "").trim().slice(0, 80),
|
||||||
|
billingEmail: String(body.billingEmail ?? body.billing_email ?? current?.billing_email ?? "").trim().slice(0, 240),
|
||||||
|
taxId: String(body.taxId ?? body.tax_id ?? current?.tax_id ?? "").trim().slice(0, 80),
|
||||||
|
notes: String(body.notes ?? current?.notes ?? "").trim().slice(0, 2000),
|
||||||
|
tags: uniqueList(body.tags ?? parseJson(current?.tags_json, [])),
|
||||||
|
metadata: jsonObject(body.metadata ?? body.metadata_json, parseJson(current?.metadata_json, {}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContactBody(body = {}, current = null) {
|
||||||
|
const name = String(body.name ?? current?.name ?? "").trim().slice(0, 120);
|
||||||
|
if (name.length < 2) throw httpError(400, "contact_name_required", "联系人名称至少需要 2 个字符");
|
||||||
|
const status = normalizedChoice(body.status ?? current?.status, ["active", "inactive"], "active", "contact_status_invalid", "联系人状态无效");
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
role: String(body.role ?? current?.role ?? "").trim().slice(0, 120),
|
||||||
|
email: String(body.email ?? current?.email ?? "").trim().slice(0, 240),
|
||||||
|
phone: String(body.phone ?? current?.phone ?? "").trim().slice(0, 80),
|
||||||
|
isPrimary: Boolean(body.isPrimary ?? body.is_primary ?? current?.is_primary ?? false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLicenseScope(value, current = null) {
|
||||||
|
const source = jsonObject(value, current ? parseJson(current.license_scope_json, DEFAULT_LICENSE_SCOPE) : DEFAULT_LICENSE_SCOPE);
|
||||||
|
return {
|
||||||
|
...DEFAULT_LICENSE_SCOPE,
|
||||||
|
...source,
|
||||||
|
channels: uniqueList(source.channels || DEFAULT_LICENSE_SCOPE.channels),
|
||||||
|
territories: uniqueList(source.territories || DEFAULT_LICENSE_SCOPE.territories),
|
||||||
|
deliverables: uniqueList(source.deliverables || DEFAULT_LICENSE_SCOPE.deliverables),
|
||||||
|
platforms: uniqueList(source.platforms || DEFAULT_LICENSE_SCOPE.platforms),
|
||||||
|
language: uniqueList(source.language || DEFAULT_LICENSE_SCOPE.language),
|
||||||
|
exclusive: Boolean(source.exclusive),
|
||||||
|
commercialUse: source.commercialUse !== false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRights(value, current = null) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_RIGHTS,
|
||||||
|
...jsonObject(value, current ? parseJson(current.rights_json, DEFAULT_RIGHTS) : DEFAULT_RIGHTS)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContractBody(body = {}, current = null) {
|
||||||
|
const title = String(body.title ?? current?.title ?? "").trim().slice(0, 180);
|
||||||
|
if (title.length < 2) throw httpError(400, "contract_title_required", "合同标题至少需要 2 个字符");
|
||||||
|
const contractNumber = String(body.contractNumber ?? body.contract_number ?? current?.contract_number ?? `CTR-${Date.now()}`)
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 80);
|
||||||
|
if (contractNumber.length < 2) throw httpError(400, "contract_number_required", "合同编号至少需要 2 个字符");
|
||||||
|
const amount = Number(body.amount ?? current?.amount ?? 0);
|
||||||
|
if (!Number.isFinite(amount) || amount < 0) throw httpError(400, "contract_amount_invalid", "合同金额必须是大于或等于 0 的数字");
|
||||||
|
return {
|
||||||
|
customerId: String(body.customerId ?? body.customer_id ?? current?.customer_id ?? "").trim(),
|
||||||
|
contractNumber,
|
||||||
|
title,
|
||||||
|
contractType: normalizedChoice(body.contractType ?? body.contract_type ?? current?.contract_type, CONTRACT_TYPES, "production_license", "contract_type_invalid", "合同类型无效"),
|
||||||
|
status: normalizedChoice(body.status ?? current?.status, CONTRACT_STATUSES, "draft", "contract_status_invalid", "合同状态无效"),
|
||||||
|
approvalStatus: normalizedChoice(body.approvalStatus ?? body.approval_status ?? current?.approval_status, APPROVAL_STATUSES, "draft", "contract_approval_status_invalid", "合同审批状态无效"),
|
||||||
|
effectiveAt: normalizeDate(body.effectiveAt ?? body.effective_at, current?.effective_at || null),
|
||||||
|
expiresAt: normalizeDate(body.expiresAt ?? body.expires_at, current?.expires_at || null),
|
||||||
|
signedAt: normalizeDate(body.signedAt ?? body.signed_at, current?.signed_at || null),
|
||||||
|
currency: String(body.currency ?? current?.currency ?? "CNY").trim().slice(0, 12) || "CNY",
|
||||||
|
amount,
|
||||||
|
licenseScope: normalizeLicenseScope(body.licenseScope ?? body.license_scope_json, current),
|
||||||
|
rights: normalizeRights(body.rights ?? body.rights_json, current),
|
||||||
|
deliveryTerms: jsonObject(body.deliveryTerms ?? body.delivery_terms_json, current ? parseJson(current.delivery_terms_json, {}) : {}),
|
||||||
|
evidence: jsonObject(body.evidence ?? body.evidence_json, current ? parseJson(current.evidence_json, {}) : {}),
|
||||||
|
risk: jsonObject(body.risk ?? body.risk_json, current ? parseJson(current.risk_json, {}) : {}),
|
||||||
|
approvalNote: String(body.approvalNote ?? body.approval_note ?? current?.approval_note ?? "").trim().slice(0, 2000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBindingBody(body = {}) {
|
||||||
|
return {
|
||||||
|
projectId: String(body.projectId ?? body.project_id ?? "").trim(),
|
||||||
|
usageMode: normalizedChoice(body.usageMode ?? body.usage_mode, BINDING_USAGE_MODES, "primary-license", "contract_usage_mode_invalid", "合同项目用途无效"),
|
||||||
|
status: normalizedChoice(body.status, BINDING_STATUSES, "active", "contract_binding_status_invalid", "合同绑定状态无效"),
|
||||||
|
notes: String(body.notes || "").trim().slice(0, 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCustomer(organizationId, customerId) {
|
||||||
|
const row = dbGet("SELECT * FROM customers WHERE organization_id = ? AND id = ?", [organizationId, customerId]);
|
||||||
|
if (!row) throw httpError(404, "customer_not_found", "客户不存在或不属于当前组织", { customerId });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureContract(organizationId, contractId) {
|
||||||
|
const row = dbGet(
|
||||||
|
`SELECT pc.*, c.name AS customer_name, c.code AS customer_code, c.status AS customer_status
|
||||||
|
FROM production_contracts pc
|
||||||
|
JOIN customers c ON c.id = pc.customer_id
|
||||||
|
WHERE pc.organization_id = ? AND pc.id = ?`,
|
||||||
|
[organizationId, contractId]
|
||||||
|
);
|
||||||
|
if (!row) throw httpError(404, "contract_not_found", "合同不存在或不属于当前组织", { contractId });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessibleProjectRows(context, organizationId) {
|
||||||
|
if (context?.organization?.id !== organizationId) return [];
|
||||||
|
if (context.systemAdmin || context.orgElevated) {
|
||||||
|
return dbAll(
|
||||||
|
`SELECT p.*, w.name AS workspace_name, w.id AS workspace_id
|
||||||
|
FROM projects p
|
||||||
|
JOIN workspaces w ON w.id = p.workspace_id
|
||||||
|
WHERE w.organization_id = ?
|
||||||
|
ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, w.name, p.updated_at DESC`,
|
||||||
|
[organizationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dbAll(
|
||||||
|
`SELECT DISTINCT p.*, w.name AS workspace_name, w.id AS workspace_id
|
||||||
|
FROM projects p
|
||||||
|
JOIN workspaces w ON w.id = p.workspace_id
|
||||||
|
JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ? AND wm.status = 'active'
|
||||||
|
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ? AND pm.status = 'active'
|
||||||
|
WHERE w.organization_id = ?
|
||||||
|
AND (wm.access_mode IS NULL OR wm.access_mode = 'all' OR (wm.access_mode = 'project-only' AND pm.user_id IS NOT NULL))
|
||||||
|
ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, w.name, p.updated_at DESC`,
|
||||||
|
[context.user.id, context.user.id, organizationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProjectInOrganization(context, projectId) {
|
||||||
|
const project = dbGet(
|
||||||
|
`SELECT p.*, w.organization_id, w.name AS workspace_name, w.id AS workspace_id
|
||||||
|
FROM projects p
|
||||||
|
JOIN workspaces w ON w.id = p.workspace_id
|
||||||
|
WHERE p.id = ? AND w.organization_id = ?`,
|
||||||
|
[projectId, context.organization.id]
|
||||||
|
);
|
||||||
|
if (!project) throw httpError(404, "project_not_found", "项目不存在或不属于当前组织", { projectId });
|
||||||
|
const visible = accessibleProjectRows(context, context.organization.id).some((item) => item.id === project.id);
|
||||||
|
if (!visible) throw httpError(404, "project_not_found", "项目不存在或当前角色不可访问", { projectId });
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerContacts(organizationId, customerId) {
|
||||||
|
return dbAll(
|
||||||
|
"SELECT * FROM customer_contacts WHERE organization_id = ? AND customer_id = ? ORDER BY is_primary DESC, status, updated_at DESC",
|
||||||
|
[organizationId, customerId]
|
||||||
|
).map(contactPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractBindings(organizationId, contractId = "") {
|
||||||
|
const params = [organizationId];
|
||||||
|
const contractClause = contractId ? "AND cpb.contract_id = ?" : "";
|
||||||
|
if (contractId) params.push(contractId);
|
||||||
|
return dbAll(
|
||||||
|
`SELECT cpb.*, p.name AS project_name, w.name AS workspace_name
|
||||||
|
FROM contract_project_bindings cpb
|
||||||
|
JOIN projects p ON p.id = cpb.project_id
|
||||||
|
JOIN workspaces w ON w.id = cpb.workspace_id
|
||||||
|
WHERE cpb.organization_id = ? ${contractClause}
|
||||||
|
ORDER BY CASE cpb.status WHEN 'active' THEN 0 WHEN 'review' THEN 1 ELSE 2 END, cpb.updated_at DESC`,
|
||||||
|
params
|
||||||
|
).map(bindingPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentContractEvents(organizationId, limit = 40) {
|
||||||
|
return dbAll(
|
||||||
|
`SELECT e.*, pc.contract_number, c.name AS customer_name, p.name AS project_name, u.display_name AS actor_name
|
||||||
|
FROM contract_license_events e
|
||||||
|
LEFT JOIN production_contracts pc ON pc.id = e.contract_id
|
||||||
|
LEFT JOIN customers c ON c.id = e.customer_id
|
||||||
|
LEFT JOIN projects p ON p.id = e.project_id
|
||||||
|
LEFT JOIN users u ON u.id = e.actor_user_id
|
||||||
|
WHERE e.organization_id = ?
|
||||||
|
ORDER BY e.created_at DESC, e.rowid DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[organizationId, Number(limit || 40)]
|
||||||
|
).map(eventPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCustomers(context, organizationId, options = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireCustomerRead(context);
|
||||||
|
const includeArchived = options.includeArchived === true || options.includeArchived === "1";
|
||||||
|
const status = String(options.status || "").trim();
|
||||||
|
const search = String(options.search || options.q || "").trim().toLowerCase();
|
||||||
|
const clauses = ["c.organization_id = ?"];
|
||||||
|
const params = [organizationId];
|
||||||
|
if (!includeArchived) clauses.push("c.status != 'archived'");
|
||||||
|
if (status) {
|
||||||
|
clauses.push("c.status = ?");
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
clauses.push("(lower(c.name) LIKE ? OR lower(c.code) LIKE ? OR lower(c.legal_name) LIKE ?)");
|
||||||
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
|
}
|
||||||
|
const rows = dbAll(
|
||||||
|
`SELECT c.*,
|
||||||
|
(SELECT COUNT(*) FROM customer_contacts cc WHERE cc.customer_id = c.id) AS contact_count,
|
||||||
|
(SELECT COUNT(*) FROM production_contracts pc WHERE pc.customer_id = c.id) AS contract_count,
|
||||||
|
(SELECT COUNT(*) FROM production_contracts pc WHERE pc.customer_id = c.id AND pc.status IN ('active', 'expiring')) AS active_contract_count
|
||||||
|
FROM customers c
|
||||||
|
WHERE ${clauses.join(" AND ")}
|
||||||
|
ORDER BY CASE c.status WHEN 'active' THEN 0 WHEN 'prospect' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END, c.updated_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const customers = rows.map((row) => customerPayload(row, customerContacts(organizationId, row.id)));
|
||||||
|
return {
|
||||||
|
customers,
|
||||||
|
summary: {
|
||||||
|
total: customers.length,
|
||||||
|
active: customers.filter((item) => item.status === "active").length,
|
||||||
|
prospects: customers.filter((item) => item.status === "prospect").length,
|
||||||
|
archived: customers.filter((item) => item.status === "archived").length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCustomer(context, organizationId, body = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireCustomerManage(context);
|
||||||
|
requireEntitlement(context, "limit.customers", 1);
|
||||||
|
const customer = normalizeCustomerBody(body);
|
||||||
|
const exists = dbGet("SELECT id FROM customers WHERE organization_id = ? AND code = ?", [organizationId, customer.code]);
|
||||||
|
if (exists) throw httpError(409, "customer_code_exists", "客户编码已存在", { code: customer.code });
|
||||||
|
const timestamp = now();
|
||||||
|
const id = makeId("customer");
|
||||||
|
withTransaction(() => {
|
||||||
|
dbRun(
|
||||||
|
`INSERT INTO customers(
|
||||||
|
id, organization_id, name, legal_name, code, customer_type, status, industry, region,
|
||||||
|
billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[id, organizationId, customer.name, customer.legalName, customer.code, customer.customerType, customer.status, customer.industry, customer.region, customer.billingEmail, customer.taxId, customer.notes, JSON.stringify(customer.tags), JSON.stringify(customer.metadata), context.user.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
addAudit({ context, action: "customer.created", targetType: "customer", targetId: id, metadata: { code: customer.code, status: customer.status } });
|
||||||
|
});
|
||||||
|
const payload = customerPayload(ensureCustomer(organizationId, id), customerContacts(organizationId, id));
|
||||||
|
return { customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCustomer(context, organizationId, customerId, body = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireCustomerManage(context);
|
||||||
|
const current = ensureCustomer(organizationId, customerId);
|
||||||
|
const customer = normalizeCustomerBody(body, current);
|
||||||
|
const collision = dbGet("SELECT id FROM customers WHERE organization_id = ? AND code = ? AND id != ?", [organizationId, customer.code, customerId]);
|
||||||
|
if (collision) throw httpError(409, "customer_code_exists", "客户编码已存在", { code: customer.code });
|
||||||
|
const timestamp = now();
|
||||||
|
dbRun(
|
||||||
|
`UPDATE customers
|
||||||
|
SET name = ?, legal_name = ?, code = ?, customer_type = ?, status = ?, industry = ?, region = ?,
|
||||||
|
billing_email = ?, tax_id = ?, notes = ?, tags_json = ?, metadata_json = ?, updated_at = ?
|
||||||
|
WHERE organization_id = ? AND id = ?`,
|
||||||
|
[customer.name, customer.legalName, customer.code, customer.customerType, customer.status, customer.industry, customer.region, customer.billingEmail, customer.taxId, customer.notes, JSON.stringify(customer.tags), JSON.stringify(customer.metadata), timestamp, organizationId, customerId]
|
||||||
|
);
|
||||||
|
addAudit({ context, action: "customer.updated", targetType: "customer", targetId: customerId, metadata: { code: customer.code, status: customer.status } });
|
||||||
|
const payload = customerPayload(ensureCustomer(organizationId, customerId), customerContacts(organizationId, customerId));
|
||||||
|
return { customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertCustomerContact(context, organizationId, customerId, body = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireCustomerManage(context);
|
||||||
|
ensureCustomer(organizationId, customerId);
|
||||||
|
const contactId = String(body.id || "").trim();
|
||||||
|
const current = contactId ? dbGet("SELECT * FROM customer_contacts WHERE organization_id = ? AND customer_id = ? AND id = ?", [organizationId, customerId, contactId]) : null;
|
||||||
|
if (contactId && !current) throw httpError(404, "customer_contact_not_found", "联系人不存在或不属于当前客户", { contactId });
|
||||||
|
const contact = normalizeContactBody(body, current);
|
||||||
|
const timestamp = now();
|
||||||
|
const id = contactId || makeId("contact");
|
||||||
|
withTransaction(() => {
|
||||||
|
if (contact.isPrimary) {
|
||||||
|
dbRun("UPDATE customer_contacts SET is_primary = 0, updated_at = ? WHERE organization_id = ? AND customer_id = ?", [timestamp, organizationId, customerId]);
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
dbRun(
|
||||||
|
"UPDATE customer_contacts SET name = ?, role = ?, email = ?, phone = ?, is_primary = ?, status = ?, updated_at = ? WHERE id = ? AND organization_id = ? AND customer_id = ?",
|
||||||
|
[contact.name, contact.role, contact.email, contact.phone, contact.isPrimary ? 1 : 0, contact.status, timestamp, id, organizationId, customerId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dbRun(
|
||||||
|
"INSERT INTO customer_contacts(id, organization_id, customer_id, name, role, email, phone, is_primary, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[id, organizationId, customerId, contact.name, contact.role, contact.email, contact.phone, contact.isPrimary ? 1 : 0, contact.status, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addAudit({ context, action: current ? "customer.contact.updated" : "customer.contact.created", targetType: "customer_contact", targetId: id, metadata: { customerId, isPrimary: contact.isPrimary } });
|
||||||
|
});
|
||||||
|
const payload = customerPayload(ensureCustomer(organizationId, customerId), customerContacts(organizationId, customerId));
|
||||||
|
return { contact: payload.contacts.find((item) => item.id === id), customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listContracts(context, organizationId, options = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireContractRead(context);
|
||||||
|
const includeArchived = options.includeArchived === true || options.includeArchived === "1";
|
||||||
|
const status = String(options.status || "").trim();
|
||||||
|
const customerId = String(options.customerId || options.customer_id || "").trim();
|
||||||
|
const projectId = String(options.projectId || options.project_id || "").trim();
|
||||||
|
const clauses = ["pc.organization_id = ?"];
|
||||||
|
const params = [organizationId];
|
||||||
|
if (!includeArchived) clauses.push("pc.status != 'terminated'");
|
||||||
|
if (status) {
|
||||||
|
clauses.push("pc.status = ?");
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (customerId) {
|
||||||
|
clauses.push("pc.customer_id = ?");
|
||||||
|
params.push(customerId);
|
||||||
|
}
|
||||||
|
if (projectId) {
|
||||||
|
clauses.push("EXISTS (SELECT 1 FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id AND cpb.project_id = ? AND cpb.organization_id = pc.organization_id)");
|
||||||
|
params.push(projectId);
|
||||||
|
}
|
||||||
|
const rows = dbAll(
|
||||||
|
`SELECT pc.*, c.name AS customer_name, c.code AS customer_code, c.status AS customer_status,
|
||||||
|
(SELECT COUNT(*) FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id) AS binding_count,
|
||||||
|
(SELECT COUNT(*) FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id AND cpb.status = 'active') AS active_binding_count
|
||||||
|
FROM production_contracts pc
|
||||||
|
JOIN customers c ON c.id = pc.customer_id
|
||||||
|
WHERE ${clauses.join(" AND ")}
|
||||||
|
ORDER BY CASE pc.status WHEN 'active' THEN 0 WHEN 'expiring' THEN 1 WHEN 'submitted' THEN 2 WHEN 'draft' THEN 3 ELSE 4 END, pc.updated_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const allBindings = contractBindings(organizationId);
|
||||||
|
const contracts = rows.map((row) => contractPayload(row, allBindings.filter((binding) => binding.contractId === row.id)));
|
||||||
|
const customers = listCustomers(context, organizationId, { includeArchived: true }).customers;
|
||||||
|
const projects = accessibleProjectRows(context, organizationId).map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
workspaceId: project.workspace_id,
|
||||||
|
workspaceName: project.workspace_name,
|
||||||
|
readiness: Number(project.readiness || 0),
|
||||||
|
risk: project.risk || ""
|
||||||
|
}));
|
||||||
|
const clearance = projectId ? evaluateProjectContractClearance(context, projectId, { channelKind: options.channelKind || options.channel_kind || "" }) : null;
|
||||||
|
return {
|
||||||
|
contracts,
|
||||||
|
customers,
|
||||||
|
projects,
|
||||||
|
events: recentContractEvents(organizationId, Number(options.limit || 40)),
|
||||||
|
clearance,
|
||||||
|
summary: {
|
||||||
|
total: contracts.length,
|
||||||
|
active: contracts.filter((item) => item.status === "active").length,
|
||||||
|
approved: contracts.filter((item) => item.approvalStatus === "approved").length,
|
||||||
|
boundProjects: new Set(allBindings.filter((binding) => binding.status === "active").map((binding) => binding.projectId)).size,
|
||||||
|
blockers: clearance?.blockers?.length || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContract(context, organizationId, body = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireContractManage(context);
|
||||||
|
requireEntitlement(context, "limit.contracts", 1);
|
||||||
|
const contract = normalizeContractBody(body);
|
||||||
|
const customer = ensureCustomer(organizationId, contract.customerId);
|
||||||
|
const exists = dbGet("SELECT id FROM production_contracts WHERE organization_id = ? AND contract_number = ?", [organizationId, contract.contractNumber]);
|
||||||
|
if (exists) throw httpError(409, "contract_number_exists", "合同编号已存在", { contractNumber: contract.contractNumber });
|
||||||
|
const timestamp = now();
|
||||||
|
const id = makeId("contract");
|
||||||
|
withTransaction(() => {
|
||||||
|
dbRun(
|
||||||
|
`INSERT INTO production_contracts(
|
||||||
|
id, organization_id, customer_id, contract_number, title, contract_type, status,
|
||||||
|
approval_status, effective_at, expires_at, signed_at, currency, amount,
|
||||||
|
license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json,
|
||||||
|
approval_note, created_by, updated_by, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[id, organizationId, customer.id, contract.contractNumber, contract.title, contract.contractType, contract.status, contract.approvalStatus, contract.effectiveAt, contract.expiresAt, contract.signedAt, contract.currency, contract.amount, JSON.stringify(contract.licenseScope), JSON.stringify(contract.rights), JSON.stringify(contract.deliveryTerms), JSON.stringify(contract.evidence), JSON.stringify(contract.risk), contract.approvalNote, context.user.id, context.user.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
writeContractEvent(context, {
|
||||||
|
contractId: id,
|
||||||
|
customerId: customer.id,
|
||||||
|
eventType: "contract.created",
|
||||||
|
metadata: { contractNumber: contract.contractNumber, status: contract.status, approvalStatus: contract.approvalStatus }
|
||||||
|
});
|
||||||
|
addAudit({ context, action: "contract.created", targetType: "production_contract", targetId: id, metadata: { customerId: customer.id, contractNumber: contract.contractNumber } });
|
||||||
|
});
|
||||||
|
return { contract: contractPayload(ensureContract(organizationId, id), []), ...listContracts(context, organizationId, { includeArchived: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateContract(context, organizationId, contractId, body = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireContractManage(context);
|
||||||
|
const current = ensureContract(organizationId, contractId);
|
||||||
|
const contract = normalizeContractBody(body, current);
|
||||||
|
const customer = ensureCustomer(organizationId, contract.customerId);
|
||||||
|
const collision = dbGet("SELECT id FROM production_contracts WHERE organization_id = ? AND contract_number = ? AND id != ?", [organizationId, contract.contractNumber, contractId]);
|
||||||
|
if (collision) throw httpError(409, "contract_number_exists", "合同编号已存在", { contractNumber: contract.contractNumber });
|
||||||
|
const timestamp = now();
|
||||||
|
withTransaction(() => {
|
||||||
|
dbRun(
|
||||||
|
`UPDATE production_contracts
|
||||||
|
SET customer_id = ?, contract_number = ?, title = ?, contract_type = ?, status = ?, approval_status = ?,
|
||||||
|
effective_at = ?, expires_at = ?, signed_at = ?, currency = ?, amount = ?,
|
||||||
|
license_scope_json = ?, rights_json = ?, delivery_terms_json = ?, evidence_json = ?, risk_json = ?,
|
||||||
|
approval_note = ?, updated_by = ?, updated_at = ?
|
||||||
|
WHERE organization_id = ? AND id = ?`,
|
||||||
|
[customer.id, contract.contractNumber, contract.title, contract.contractType, contract.status, contract.approvalStatus, contract.effectiveAt, contract.expiresAt, contract.signedAt, contract.currency, contract.amount, JSON.stringify(contract.licenseScope), JSON.stringify(contract.rights), JSON.stringify(contract.deliveryTerms), JSON.stringify(contract.evidence), JSON.stringify(contract.risk), contract.approvalNote, context.user.id, timestamp, organizationId, contractId]
|
||||||
|
);
|
||||||
|
dbRun("UPDATE contract_project_bindings SET customer_id = ?, updated_at = ? WHERE organization_id = ? AND contract_id = ?", [customer.id, timestamp, organizationId, contractId]);
|
||||||
|
writeContractEvent(context, {
|
||||||
|
contractId,
|
||||||
|
customerId: customer.id,
|
||||||
|
eventType: "contract.updated",
|
||||||
|
metadata: { previous: { status: current.status, approvalStatus: current.approval_status }, next: { status: contract.status, approvalStatus: contract.approvalStatus } }
|
||||||
|
});
|
||||||
|
addAudit({ context, action: "contract.updated", targetType: "production_contract", targetId: contractId, metadata: { customerId: customer.id, contractNumber: contract.contractNumber } });
|
||||||
|
});
|
||||||
|
return { contract: contractPayload(ensureContract(organizationId, contractId), contractBindings(organizationId, contractId)), ...listContracts(context, organizationId, { includeArchived: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindContractProject(context, organizationId, contractId, body = {}) {
|
||||||
|
assertOrganizationScope(context, organizationId);
|
||||||
|
requireContractManage(context);
|
||||||
|
const contract = ensureContract(organizationId, contractId);
|
||||||
|
const binding = normalizeBindingBody(body);
|
||||||
|
if (!binding.projectId) throw httpError(400, "project_required", "合同绑定必须选择项目");
|
||||||
|
const project = ensureProjectInOrganization(context, binding.projectId);
|
||||||
|
const timestamp = now();
|
||||||
|
let bindingId = "";
|
||||||
|
withTransaction(() => {
|
||||||
|
const current = dbGet("SELECT * FROM contract_project_bindings WHERE organization_id = ? AND project_id = ? AND contract_id = ?", [organizationId, project.id, contract.id]);
|
||||||
|
if (current) {
|
||||||
|
bindingId = current.id;
|
||||||
|
dbRun(
|
||||||
|
"UPDATE contract_project_bindings SET customer_id = ?, usage_mode = ?, status = ?, notes = ?, updated_at = ? WHERE id = ?",
|
||||||
|
[contract.customer_id, binding.usageMode, binding.status, binding.notes, timestamp, current.id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bindingId = makeId("contract-binding");
|
||||||
|
dbRun(
|
||||||
|
"INSERT INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[bindingId, organizationId, project.workspace_id, project.id, contract.id, contract.customer_id, binding.usageMode, binding.status, binding.notes, context.user.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeContractEvent(context, {
|
||||||
|
contractId: contract.id,
|
||||||
|
customerId: contract.customer_id,
|
||||||
|
workspaceId: project.workspace_id,
|
||||||
|
projectId: project.id,
|
||||||
|
eventType: current ? "contract.project_binding.updated" : "contract.project_binding.created",
|
||||||
|
metadata: { bindingId, usageMode: binding.usageMode, status: binding.status }
|
||||||
|
});
|
||||||
|
addAudit({ context, action: current ? "contract.project_binding.updated" : "contract.project_binding.created", targetType: "contract_project_binding", targetId: bindingId, metadata: { contractId, projectId: project.id, status: binding.status } });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
binding: contractBindings(organizationId, contractId).find((item) => item.id === bindingId),
|
||||||
|
clearance: evaluateProjectContractClearance(context, project.id),
|
||||||
|
...listContracts(context, organizationId, { includeArchived: true, projectId: project.id })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAllows(list, value) {
|
||||||
|
if (!value) return true;
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
const allowed = uniqueList(list).map((item) => item.toLowerCase());
|
||||||
|
return allowed.includes(normalized) || allowed.includes("all") || allowed.includes("global") || allowed.includes("*");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEvidence(evidence = {}) {
|
||||||
|
return Boolean(evidence.contractRef || evidence.rightsEvidenceRef || evidence.signedCopyPath || evidence.signedCopySha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractIssueSet(contract, binding, options = {}) {
|
||||||
|
const blockers = [];
|
||||||
|
const reviewItems = [];
|
||||||
|
const licenseScope = contract.licenseScope || {};
|
||||||
|
const rights = contract.rights || {};
|
||||||
|
const evidence = contract.evidence || {};
|
||||||
|
const checkedAt = Date.now();
|
||||||
|
const channelKind = String(options.channelKind || options.channel_kind || options.channel?.kind || "").trim();
|
||||||
|
const territory = String(options.territory || "CN").trim();
|
||||||
|
const requiredDeliverables = uniqueList(options.deliverables || ["vertical-video"]);
|
||||||
|
|
||||||
|
if (binding.status !== "active") blockers.push({ type: "contract", code: "binding_not_active", status: binding.status, reason: "项目合同绑定未启用", contractId: contract.id, bindingId: binding.id });
|
||||||
|
if (!["active", "expiring"].includes(contract.status)) blockers.push({ type: "contract", code: "contract_not_active", status: contract.status, reason: "合同不是可交付状态", contractId: contract.id });
|
||||||
|
if (contract.approvalStatus !== "approved") blockers.push({ type: "contract", code: "contract_not_approved", status: contract.approvalStatus, reason: "合同尚未通过法务/商务审批", contractId: contract.id });
|
||||||
|
if (contract.customerStatus && contract.customerStatus !== "active") blockers.push({ type: "contract", code: "customer_not_active", status: contract.customerStatus, reason: "客户主档未处于启用状态", contractId: contract.id, customerId: contract.customerId });
|
||||||
|
if (contract.effectiveAt && new Date(contract.effectiveAt).getTime() > checkedAt) blockers.push({ type: "contract", code: "contract_not_effective", reason: "合同尚未到生效时间", contractId: contract.id, effectiveAt: contract.effectiveAt });
|
||||||
|
if (contract.expiresAt && new Date(contract.expiresAt).getTime() < checkedAt) blockers.push({ type: "contract", code: "contract_expired", reason: "合同已过期", contractId: contract.id, expiresAt: contract.expiresAt });
|
||||||
|
if (contract.expiresAt) {
|
||||||
|
const daysLeft = Math.ceil((new Date(contract.expiresAt).getTime() - checkedAt) / 86400000);
|
||||||
|
if (daysLeft >= 0 && daysLeft <= 30) reviewItems.push({ type: "contract", code: "contract_expiring_soon", reason: `合同将在 ${daysLeft} 天内到期`, contractId: contract.id, expiresAt: contract.expiresAt });
|
||||||
|
}
|
||||||
|
if (!hasEvidence(evidence)) blockers.push({ type: "contract", code: "contract_evidence_missing", reason: "合同缺少签署/原创/授权证据引用", contractId: contract.id });
|
||||||
|
if (channelKind && !listAllows(licenseScope.channels, channelKind)) blockers.push({ type: "contract", code: "channel_not_licensed", reason: "交付渠道不在合同授权范围内", contractId: contract.id, channelKind, allowed: licenseScope.channels || [] });
|
||||||
|
if (territory && !listAllows(licenseScope.territories, territory)) blockers.push({ type: "contract", code: "territory_not_licensed", reason: "交付地域不在合同授权范围内", contractId: contract.id, territory, allowed: licenseScope.territories || [] });
|
||||||
|
for (const deliverable of requiredDeliverables) {
|
||||||
|
if (!listAllows(licenseScope.deliverables, deliverable)) blockers.push({ type: "contract", code: "deliverable_not_licensed", reason: "交付物类型不在合同授权范围内", contractId: contract.id, deliverable, allowed: licenseScope.deliverables || [] });
|
||||||
|
}
|
||||||
|
if (licenseScope.commercialUse !== true) blockers.push({ type: "contract", code: "commercial_use_not_allowed", reason: "合同未明确允许商业使用", contractId: contract.id });
|
||||||
|
if (rights.originalStory !== true) blockers.push({ type: "contract", code: "original_story_missing", reason: "合同未确认原创故事权利", contractId: contract.id });
|
||||||
|
if (rights.derivativeProduction !== true) blockers.push({ type: "contract", code: "derivative_right_missing", reason: "合同未确认改编/制作授权", contractId: contract.id });
|
||||||
|
if (rights.aiGeneratedAssetsAllowed !== true) blockers.push({ type: "contract", code: "ai_assets_not_allowed", reason: "合同未允许 AI 生成资产用于制作", contractId: contract.id });
|
||||||
|
if (rights.fixedVoiceRequired !== true) reviewItems.push({ type: "contract", code: "fixed_voice_not_required_by_contract", reason: "合同未显式要求固定声线,建议补充以匹配平台策略", contractId: contract.id });
|
||||||
|
if (rights.singleFramePolicyRequired !== true) blockers.push({ type: "contract", code: "single_frame_policy_not_required", reason: "合同未纳入一图一完整单画面生产约束", contractId: contract.id });
|
||||||
|
if (channelKind === "private-delivery-portal" && contract.deliveryTerms?.customerPortalAllowed === false) blockers.push({ type: "contract", code: "portal_not_allowed", reason: "合同交付条款未允许客户门户访问", contractId: contract.id });
|
||||||
|
|
||||||
|
return { blockers, reviewItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateProjectContractClearance(context, projectId = "", options = {}) {
|
||||||
|
requireContractRead(context, { allowDeliveryGate: true });
|
||||||
|
const targetProjectId = String(projectId || context.project?.id || "").trim();
|
||||||
|
if (!targetProjectId) throw httpError(400, "project_required", "合同授权清算必须绑定项目");
|
||||||
|
const project = ensureProjectInOrganization(context, targetProjectId);
|
||||||
|
const rows = dbAll(
|
||||||
|
`SELECT cpb.*, p.name AS project_name, w.name AS workspace_name,
|
||||||
|
pc.contract_number, pc.title, pc.contract_type, pc.status AS contract_status, pc.approval_status,
|
||||||
|
pc.effective_at, pc.expires_at, pc.signed_at, pc.currency, pc.amount,
|
||||||
|
pc.license_scope_json, pc.rights_json, pc.delivery_terms_json, pc.evidence_json, pc.risk_json,
|
||||||
|
c.name AS customer_name, c.code AS customer_code, c.status AS customer_status
|
||||||
|
FROM contract_project_bindings cpb
|
||||||
|
JOIN projects p ON p.id = cpb.project_id
|
||||||
|
JOIN workspaces w ON w.id = cpb.workspace_id
|
||||||
|
JOIN production_contracts pc ON pc.id = cpb.contract_id
|
||||||
|
JOIN customers c ON c.id = cpb.customer_id
|
||||||
|
WHERE cpb.organization_id = ? AND cpb.workspace_id = ? AND cpb.project_id = ?
|
||||||
|
ORDER BY CASE cpb.status WHEN 'active' THEN 0 WHEN 'review' THEN 1 ELSE 2 END, cpb.updated_at DESC`,
|
||||||
|
[context.organization.id, project.workspace_id, project.id]
|
||||||
|
);
|
||||||
|
const blockers = [];
|
||||||
|
const reviewItems = [];
|
||||||
|
const bindings = rows.map((row) => {
|
||||||
|
const binding = bindingPayload(row);
|
||||||
|
const contract = {
|
||||||
|
id: row.contract_id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
customerId: row.customer_id,
|
||||||
|
customerName: row.customer_name || "",
|
||||||
|
customerCode: row.customer_code || "",
|
||||||
|
customerStatus: row.customer_status || "",
|
||||||
|
contractNumber: row.contract_number,
|
||||||
|
title: row.title,
|
||||||
|
contractType: row.contract_type,
|
||||||
|
status: row.contract_status,
|
||||||
|
approvalStatus: row.approval_status,
|
||||||
|
effectiveAt: row.effective_at || "",
|
||||||
|
expiresAt: row.expires_at || "",
|
||||||
|
signedAt: row.signed_at || "",
|
||||||
|
currency: row.currency,
|
||||||
|
amount: Number(row.amount || 0),
|
||||||
|
licenseScope: parseJson(row.license_scope_json, {}),
|
||||||
|
rights: parseJson(row.rights_json, {}),
|
||||||
|
deliveryTerms: parseJson(row.delivery_terms_json, {}),
|
||||||
|
evidence: parseJson(row.evidence_json, {}),
|
||||||
|
risk: parseJson(row.risk_json, {})
|
||||||
|
};
|
||||||
|
const issues = contractIssueSet(contract, binding, options);
|
||||||
|
blockers.push(...issues.blockers);
|
||||||
|
reviewItems.push(...issues.reviewItems);
|
||||||
|
return { ...binding, contract };
|
||||||
|
});
|
||||||
|
if (!bindings.some((binding) => binding.status === "active")) {
|
||||||
|
blockers.push({ type: "contract", code: "project_contract_missing", reason: "项目没有启用中的客户/合同授权绑定", projectId: project.id });
|
||||||
|
}
|
||||||
|
const status = blockers.length ? "blocked" : reviewItems.length ? "review" : "pass";
|
||||||
|
return {
|
||||||
|
schema: "ai-drama-platform.contract-clearance.v1",
|
||||||
|
checkedAt: now(),
|
||||||
|
status,
|
||||||
|
organizationId: context.organization.id,
|
||||||
|
workspaceId: project.workspace_id,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
requested: {
|
||||||
|
channelKind: options.channelKind || options.channel_kind || options.channel?.kind || "",
|
||||||
|
territory: options.territory || "CN",
|
||||||
|
deliverables: uniqueList(options.deliverables || ["vertical-video"])
|
||||||
|
},
|
||||||
|
blockers,
|
||||||
|
reviewItems,
|
||||||
|
bindings,
|
||||||
|
counts: {
|
||||||
|
bindings: bindings.length,
|
||||||
|
activeBindings: bindings.filter((binding) => binding.status === "active").length,
|
||||||
|
blockers: blockers.length,
|
||||||
|
reviewItems: reviewItems.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeContractEvent(context, body = {}) {
|
||||||
|
const timestamp = now();
|
||||||
|
const id = body.id || makeId("contract-event");
|
||||||
|
dbRun(
|
||||||
|
"INSERT INTO contract_license_events(id, organization_id, contract_id, customer_id, workspace_id, project_id, event_type, status, actor_user_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
context.organization.id,
|
||||||
|
body.contractId || null,
|
||||||
|
body.customerId || null,
|
||||||
|
body.workspaceId || null,
|
||||||
|
body.projectId || null,
|
||||||
|
String(body.eventType || "contract.event").trim(),
|
||||||
|
String(body.status || "recorded").trim(),
|
||||||
|
context.user.id,
|
||||||
|
JSON.stringify(body.metadata || {}),
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return eventPayload(dbGet(
|
||||||
|
`SELECT e.*, pc.contract_number, c.name AS customer_name, p.name AS project_name, u.display_name AS actor_name
|
||||||
|
FROM contract_license_events e
|
||||||
|
LEFT JOIN production_contracts pc ON pc.id = e.contract_id
|
||||||
|
LEFT JOIN customers c ON c.id = e.customer_id
|
||||||
|
LEFT JOIN projects p ON p.id = e.project_id
|
||||||
|
LEFT JOIN users u ON u.id = e.actor_user_id
|
||||||
|
WHERE e.id = ?`,
|
||||||
|
[id]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordContractLicenseEvent(context, body = {}) {
|
||||||
|
requireContractRead(context);
|
||||||
|
const contractId = String(body.contractId || body.contract_id || "").trim();
|
||||||
|
const customerId = String(body.customerId || body.customer_id || "").trim();
|
||||||
|
const projectId = String(body.projectId || body.project_id || "").trim();
|
||||||
|
const contract = contractId ? ensureContract(context.organization.id, contractId) : null;
|
||||||
|
const customer = customerId ? ensureCustomer(context.organization.id, customerId) : contract ? ensureCustomer(context.organization.id, contract.customer_id) : null;
|
||||||
|
const project = projectId ? ensureProjectInOrganization(context, projectId) : null;
|
||||||
|
const event = writeContractEvent(context, {
|
||||||
|
contractId: contract?.id || null,
|
||||||
|
customerId: customer?.id || null,
|
||||||
|
workspaceId: project?.workspace_id || body.workspaceId || body.workspace_id || null,
|
||||||
|
projectId: project?.id || null,
|
||||||
|
eventType: body.eventType || body.event_type || "contract.clearance.checked",
|
||||||
|
status: body.status || "recorded",
|
||||||
|
metadata: body.metadata || {}
|
||||||
|
});
|
||||||
|
addAudit({ context, action: "contract.license_event.recorded", targetType: "contract_license_event", targetId: event.id, metadata: { contractId: event.contractId, projectId: event.projectId, eventType: event.eventType } });
|
||||||
|
return { event, events: recentContractEvents(context.organization.id) };
|
||||||
|
}
|
||||||
+244
-2
@@ -142,6 +142,13 @@ db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commer
|
|||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status)");
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policies_org_category ON organization_policies(organization_id, category, policy_key)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policies_org_category ON organization_policies(organization_id, category, policy_key)");
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_customers_org_status ON customers(organization_id, status, updated_at DESC)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_customer_contacts_customer ON customer_contacts(customer_id, is_primary DESC, status)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_production_contracts_org_status ON production_contracts(organization_id, status, approval_status, updated_at DESC)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_production_contracts_customer ON production_contracts(customer_id, status, updated_at DESC)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_project ON contract_project_bindings(organization_id, workspace_id, project_id, status)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_contract ON contract_project_bindings(contract_id, status, updated_at DESC)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_contract_license_events_scope ON contract_license_events(organization_id, contract_id, project_id, created_at DESC)");
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_versions_governance ON asset_versions(asset_id, rights_status, expires_at)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_versions_governance ON asset_versions(asset_id, rights_status, expires_at)");
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC)");
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC)");
|
||||||
@@ -253,6 +260,10 @@ function seedRoles() {
|
|||||||
["task:complete", "更新本人负责的协作任务状态"],
|
["task:complete", "更新本人负责的协作任务状态"],
|
||||||
["style:read", "查看组织、工作区和项目风格生产标准"],
|
["style:read", "查看组织、工作区和项目风格生产标准"],
|
||||||
["style:manage", "管理风格标准、品牌规范和项目绑定"],
|
["style:manage", "管理风格标准、品牌规范和项目绑定"],
|
||||||
|
["customer:read", "查看客户主档、联系人和项目归属"],
|
||||||
|
["customer:manage", "管理客户主档、联系人和客户状态"],
|
||||||
|
["contract:read", "查看合同授权、项目绑定和商业放行"],
|
||||||
|
["contract:manage", "管理合同授权、项目绑定和商业放行"],
|
||||||
["script:edit", "编辑剧本和对白"],
|
["script:edit", "编辑剧本和对白"],
|
||||||
["script:read", "查看剧本、分集和镜头"],
|
["script:read", "查看剧本、分集和镜头"],
|
||||||
["asset:edit", "编辑角色、场景和道具锁"],
|
["asset:edit", "编辑角色、场景和道具锁"],
|
||||||
@@ -289,10 +300,10 @@ function seedRoles() {
|
|||||||
org_admin: [
|
org_admin: [
|
||||||
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
||||||
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
|
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
|
||||||
"workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "policy:manage", "style:read", "style:manage", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
|
"workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "policy:manage", "style:read", "style:manage", "customer:read", "customer:manage", "contract:read", "contract:manage", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
|
||||||
"system:settings:view", "service:health:view", "organization:roles:manage"
|
"system:settings:view", "service:health:view", "organization:roles:manage"
|
||||||
],
|
],
|
||||||
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
|
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "style:read", "style:manage", "customer:read", "contract:read", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
|
||||||
writer: ["script:read", "script:edit", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
|
writer: ["script:read", "script:edit", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
|
||||||
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "policy:read", "style:read", "style:manage", "job:create"],
|
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "policy:read", "style:read", "style:manage", "job:create"],
|
||||||
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
|
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
|
||||||
@@ -395,6 +406,8 @@ function commercialEntitlementDefaults(billing = {}) {
|
|||||||
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
||||||
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
|
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
|
||||||
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
|
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
|
||||||
|
["limit.customers", "客户主档", "commercial", 120, "个", 1, "block", { commercialGate: "customer:create" }],
|
||||||
|
["limit.contracts", "合同授权", "commercial", 240, "份", 1, "block", { commercialGate: "contract:create" }],
|
||||||
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
|
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
|
||||||
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
||||||
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
||||||
@@ -551,6 +564,234 @@ function seedCommercialApprovals() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedCustomersAndContracts() {
|
||||||
|
const timestamp = now();
|
||||||
|
const customers = [
|
||||||
|
{
|
||||||
|
id: "cust-xinghe-release",
|
||||||
|
organizationId: "org-studio-lab",
|
||||||
|
name: "星河发行合作方",
|
||||||
|
legalName: "星河内容发行(模拟)有限公司",
|
||||||
|
code: "XH-RELEASE",
|
||||||
|
customerType: "distributor",
|
||||||
|
status: "active",
|
||||||
|
industry: "短剧发行",
|
||||||
|
region: "CN",
|
||||||
|
billingEmail: "ops@example.local",
|
||||||
|
taxId: "",
|
||||||
|
notes: "用于验证《雷雨口》商用交付、客户门户和授权范围清算。",
|
||||||
|
tags: ["发行", "本地交付", "商用清算"],
|
||||||
|
metadata: { source: "seed://customers/xinghe-release", preferredDelivery: "private-delivery-portal" },
|
||||||
|
createdBy: "u-owner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cust-northstar-platform",
|
||||||
|
organizationId: "org-northstar",
|
||||||
|
name: "北辰内部发行台",
|
||||||
|
legalName: "北辰内容厂牌(模拟)",
|
||||||
|
code: "NS-INTERNAL",
|
||||||
|
customerType: "internal",
|
||||||
|
status: "active",
|
||||||
|
industry: "内容厂牌",
|
||||||
|
region: "CN",
|
||||||
|
billingEmail: "northstar@example.local",
|
||||||
|
taxId: "",
|
||||||
|
notes: "用于验证北辰组织的独立合同和跨组织隔离。",
|
||||||
|
tags: ["内部试播", "租户隔离"],
|
||||||
|
metadata: { source: "seed://customers/northstar-internal" },
|
||||||
|
createdBy: "u-producer"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
for (const customer of customers) {
|
||||||
|
insertIgnore(
|
||||||
|
`INSERT OR IGNORE INTO customers(
|
||||||
|
id, organization_id, name, legal_name, code, customer_type, status, industry, region,
|
||||||
|
billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
customer.id,
|
||||||
|
customer.organizationId,
|
||||||
|
customer.name,
|
||||||
|
customer.legalName,
|
||||||
|
customer.code,
|
||||||
|
customer.customerType,
|
||||||
|
customer.status,
|
||||||
|
customer.industry,
|
||||||
|
customer.region,
|
||||||
|
customer.billingEmail,
|
||||||
|
customer.taxId,
|
||||||
|
customer.notes,
|
||||||
|
JSON.stringify(customer.tags),
|
||||||
|
JSON.stringify(customer.metadata),
|
||||||
|
customer.createdBy,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = [
|
||||||
|
["contact-xinghe-release-producer", "org-studio-lab", "cust-xinghe-release", "星河发行制片经理", "交付验收", "producer@example.local", "", 1],
|
||||||
|
["contact-xinghe-release-legal", "org-studio-lab", "cust-xinghe-release", "星河法务窗口", "合同授权复核", "legal@example.local", "", 0],
|
||||||
|
["contact-northstar-owner", "org-northstar", "cust-northstar-platform", "北辰试制负责人", "内部验收", "pilot@example.local", "", 1]
|
||||||
|
];
|
||||||
|
for (const contact of contacts) {
|
||||||
|
insertIgnore(
|
||||||
|
"INSERT OR IGNORE INTO customer_contacts(id, organization_id, customer_id, name, role, email, phone, is_primary, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
|
||||||
|
[...contact, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contracts = [
|
||||||
|
{
|
||||||
|
id: "contract-xh-rain-night-2026",
|
||||||
|
organizationId: "org-studio-lab",
|
||||||
|
customerId: "cust-xinghe-release",
|
||||||
|
contractNumber: "XH-AIDRAMA-2026-001",
|
||||||
|
title: "《雷雨口》AI 漫剧本地化制作与交付授权",
|
||||||
|
contractType: "production_license",
|
||||||
|
status: "active",
|
||||||
|
approvalStatus: "approved",
|
||||||
|
effectiveAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
expiresAt: "2028-12-31T23:59:59.000Z",
|
||||||
|
signedAt: "2026-08-15T09:30:00.000Z",
|
||||||
|
amount: 0,
|
||||||
|
licenseScope: {
|
||||||
|
channels: ["local-file", "local-webhook", "private-delivery-portal"],
|
||||||
|
territories: ["CN", "GLOBAL"],
|
||||||
|
deliverables: ["vertical-video", "prompt-pack", "subtitle", "delivery-manifest"],
|
||||||
|
platforms: ["private-preview", "internal-review", "owned-channel"],
|
||||||
|
exclusive: false,
|
||||||
|
commercialUse: true,
|
||||||
|
language: ["zh-CN"]
|
||||||
|
},
|
||||||
|
rights: {
|
||||||
|
originalStory: true,
|
||||||
|
derivativeProduction: true,
|
||||||
|
aiGeneratedAssetsAllowed: true,
|
||||||
|
voiceCloneAllowed: false,
|
||||||
|
fixedVoiceRequired: true,
|
||||||
|
singleFramePolicyRequired: true
|
||||||
|
},
|
||||||
|
deliveryTerms: {
|
||||||
|
maxAccessDays: 90,
|
||||||
|
customerPortalAllowed: true,
|
||||||
|
watermarkRequiredBeforeApproval: true,
|
||||||
|
requireClearanceCertificate: true
|
||||||
|
},
|
||||||
|
evidence: {
|
||||||
|
contractRef: "seed://contract/xh-2026-aidrama",
|
||||||
|
rightsEvidenceRef: "seed://original/rain-night",
|
||||||
|
signedCopyPath: "storage/contracts/xh-2026-aidrama/contract-summary.json"
|
||||||
|
},
|
||||||
|
risk: { paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" },
|
||||||
|
approvalNote: "种子合同用于本地商用闭环验证:固定声线、一图一画面、原创权利证据均为强制项。",
|
||||||
|
createdBy: "u-owner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contract-ns-pilot-2026",
|
||||||
|
organizationId: "org-northstar",
|
||||||
|
customerId: "cust-northstar-platform",
|
||||||
|
contractNumber: "NS-PILOT-2026-001",
|
||||||
|
title: "《山海志异·试播集》内部试制授权",
|
||||||
|
contractType: "internal",
|
||||||
|
status: "active",
|
||||||
|
approvalStatus: "approved",
|
||||||
|
effectiveAt: "2026-08-10T00:00:00.000Z",
|
||||||
|
expiresAt: "2027-12-31T23:59:59.000Z",
|
||||||
|
signedAt: "2026-08-10T10:00:00.000Z",
|
||||||
|
amount: 0,
|
||||||
|
licenseScope: {
|
||||||
|
channels: ["local-file", "private-delivery-portal"],
|
||||||
|
territories: ["CN"],
|
||||||
|
deliverables: ["vertical-video", "delivery-manifest"],
|
||||||
|
platforms: ["internal-review"],
|
||||||
|
exclusive: false,
|
||||||
|
commercialUse: true,
|
||||||
|
language: ["zh-CN"]
|
||||||
|
},
|
||||||
|
rights: {
|
||||||
|
originalStory: true,
|
||||||
|
derivativeProduction: true,
|
||||||
|
aiGeneratedAssetsAllowed: true,
|
||||||
|
voiceCloneAllowed: false,
|
||||||
|
fixedVoiceRequired: true,
|
||||||
|
singleFramePolicyRequired: true
|
||||||
|
},
|
||||||
|
deliveryTerms: {
|
||||||
|
maxAccessDays: 30,
|
||||||
|
customerPortalAllowed: true,
|
||||||
|
watermarkRequiredBeforeApproval: true,
|
||||||
|
requireClearanceCertificate: true
|
||||||
|
},
|
||||||
|
evidence: {
|
||||||
|
contractRef: "seed://contract/ns-pilot-2026",
|
||||||
|
rightsEvidenceRef: "seed://original/northstar-pilot",
|
||||||
|
signedCopyPath: "storage/contracts/ns-pilot-2026/contract-summary.json"
|
||||||
|
},
|
||||||
|
risk: { paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" },
|
||||||
|
approvalNote: "北辰组织独立授权,不允许被星河组织项目绑定。",
|
||||||
|
createdBy: "u-producer"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
for (const contract of contracts) {
|
||||||
|
insertIgnore(
|
||||||
|
`INSERT OR IGNORE INTO production_contracts(
|
||||||
|
id, organization_id, customer_id, contract_number, title, contract_type, status,
|
||||||
|
approval_status, effective_at, expires_at, signed_at, currency, amount,
|
||||||
|
license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json,
|
||||||
|
approval_note, created_by, updated_by, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CNY', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
contract.id,
|
||||||
|
contract.organizationId,
|
||||||
|
contract.customerId,
|
||||||
|
contract.contractNumber,
|
||||||
|
contract.title,
|
||||||
|
contract.contractType,
|
||||||
|
contract.status,
|
||||||
|
contract.approvalStatus,
|
||||||
|
contract.effectiveAt,
|
||||||
|
contract.expiresAt,
|
||||||
|
contract.signedAt,
|
||||||
|
contract.amount,
|
||||||
|
JSON.stringify(contract.licenseScope),
|
||||||
|
JSON.stringify(contract.rights),
|
||||||
|
JSON.stringify(contract.deliveryTerms),
|
||||||
|
JSON.stringify(contract.evidence),
|
||||||
|
JSON.stringify(contract.risk),
|
||||||
|
contract.approvalNote,
|
||||||
|
contract.createdBy,
|
||||||
|
contract.createdBy,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = [
|
||||||
|
["contract-binding-thunder-mouth-xh", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "contract-xh-rain-night-2026", "cust-xinghe-release", "primary-license", "active", "《雷雨口》默认商业交付授权。", "u-owner"],
|
||||||
|
["contract-binding-northstar-pilot", "org-northstar", "ws-northstar-main", "northstar-pilot", "contract-ns-pilot-2026", "cust-northstar-platform", "internal-review", "active", "北辰内部试播项目授权。", "u-producer"]
|
||||||
|
];
|
||||||
|
for (const binding of bindings) {
|
||||||
|
insertIgnore(
|
||||||
|
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[...binding, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
["contract-event-xh-seed-created", "org-studio-lab", "contract-xh-rain-night-2026", "cust-xinghe-release", "ws-local-aidrama", "thunder-mouth", "seeded", "recorded", "u-owner", { action: "seed", bindingId: "contract-binding-thunder-mouth-xh", channelKinds: ["local-file", "private-delivery-portal"] }],
|
||||||
|
["contract-event-ns-seed-created", "org-northstar", "contract-ns-pilot-2026", "cust-northstar-platform", "ws-northstar-main", "northstar-pilot", "seeded", "recorded", "u-producer", { action: "seed", bindingId: "contract-binding-northstar-pilot" }]
|
||||||
|
];
|
||||||
|
for (const event of events) {
|
||||||
|
insertIgnore(
|
||||||
|
"INSERT OR IGNORE INTO contract_license_events(id, organization_id, contract_id, customer_id, workspace_id, project_id, event_type, status, actor_user_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[...event.slice(0, 9), JSON.stringify(event[9]), timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function seedMembersAndProjects() {
|
function seedMembersAndProjects() {
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
const membershipRows = [
|
const membershipRows = [
|
||||||
@@ -1271,6 +1512,7 @@ withTransaction(() => {
|
|||||||
seedOrganizationEntitlements();
|
seedOrganizationEntitlements();
|
||||||
rollQuotaPeriods();
|
rollQuotaPeriods();
|
||||||
seedCommercialApprovals();
|
seedCommercialApprovals();
|
||||||
|
seedCustomersAndContracts();
|
||||||
seedMembersAndProjects();
|
seedMembersAndProjects();
|
||||||
seedModels();
|
seedModels();
|
||||||
seedModelCatalog();
|
seedModelCatalog();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|||||||
import { basename, dirname, relative, resolve } from "node:path";
|
import { basename, dirname, relative, resolve } from "node:path";
|
||||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||||
import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission } from "./tenant.mjs";
|
import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission } from "./tenant.mjs";
|
||||||
|
import { evaluateProjectContractClearance, recordContractLicenseEvent } from "./contracts.mjs";
|
||||||
|
|
||||||
const projectRoot = resolve(import.meta.dirname, "..");
|
const projectRoot = resolve(import.meta.dirname, "..");
|
||||||
const defaultExpiryDays = 7;
|
const defaultExpiryDays = 7;
|
||||||
@@ -287,6 +288,25 @@ export function createDeliveryAccessLink(context, releaseId, body = {}, options
|
|||||||
if (release.status !== "published" || !release.output_path) {
|
if (release.status !== "published" || !release.output_path) {
|
||||||
throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status });
|
throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status });
|
||||||
}
|
}
|
||||||
|
const contractClearance = evaluateProjectContractClearance(context, context.project.id, {
|
||||||
|
channelKind: "private-delivery-portal",
|
||||||
|
deliverables: ["vertical-video", "delivery-manifest"],
|
||||||
|
territory: body.territory || "CN"
|
||||||
|
});
|
||||||
|
recordContractLicenseEvent(context, {
|
||||||
|
projectId: context.project.id,
|
||||||
|
eventType: "contract.delivery_access.preflight",
|
||||||
|
status: contractClearance.status,
|
||||||
|
metadata: {
|
||||||
|
releaseId,
|
||||||
|
channelKind: "private-delivery-portal",
|
||||||
|
blockers: contractClearance.blockers.length,
|
||||||
|
reviewItems: contractClearance.reviewItems.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (contractClearance.status === "blocked") {
|
||||||
|
throw httpError(409, "delivery_access_contract_blocked", "客户访问链接未通过合同授权清算", { clearance: contractClearance, blockers: contractClearance.blockers });
|
||||||
|
}
|
||||||
const releaseFile = safeStoragePath(release.output_path);
|
const releaseFile = safeStoragePath(release.output_path);
|
||||||
if (!releaseFile || basename(releaseFile.relative) !== "release.json") {
|
if (!releaseFile || basename(releaseFile.relative) !== "release.json") {
|
||||||
throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束");
|
throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束");
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ import { backupSummary, createDatabaseBackup } from "./backup.mjs";
|
|||||||
import { systemReadiness } from "./readiness.mjs";
|
import { systemReadiness } from "./readiness.mjs";
|
||||||
import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.mjs";
|
import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.mjs";
|
||||||
import { evaluateAndRecordOrganizationPolicies, listOrganizationPolicies, updateOrganizationPolicy } from "./organization-policies.mjs";
|
import { evaluateAndRecordOrganizationPolicies, listOrganizationPolicies, updateOrganizationPolicy } from "./organization-policies.mjs";
|
||||||
|
import { bindContractProject, createContract, createCustomer, evaluateProjectContractClearance, listContracts, listCustomers, updateContract, updateCustomer, upsertCustomerContact } from "./contracts.mjs";
|
||||||
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
|
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
|
||||||
import { composeProject, listCompositions } from "./composition.mjs";
|
import { composeProject, listCompositions } from "./composition.mjs";
|
||||||
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
|
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
|
||||||
@@ -1717,6 +1718,68 @@ createServer(async (req, res) => {
|
|||||||
return send(res, 200, organizationCommercial(context));
|
return send(res, 200, organizationCommercial(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organizationCustomersMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers$/);
|
||||||
|
if (req.method === "GET" && organizationCustomersMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationCustomersMatch[1]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 200, listCustomers(context, organizationId, Object.fromEntries(url.searchParams.entries())));
|
||||||
|
}
|
||||||
|
if (req.method === "POST" && organizationCustomersMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationCustomersMatch[1]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 201, createCustomer(context, organizationId, await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationCustomerMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers\/([^/]+)$/);
|
||||||
|
if (req.method === "PATCH" && organizationCustomerMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationCustomerMatch[1]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 200, updateCustomer(context, organizationId, decodeURIComponent(organizationCustomerMatch[2]), await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationCustomerContactsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers\/([^/]+)\/contacts$/);
|
||||||
|
if (req.method === "POST" && organizationCustomerContactsMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationCustomerContactsMatch[1]);
|
||||||
|
const customerId = decodeURIComponent(organizationCustomerContactsMatch[2]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 201, upsertCustomerContact(context, organizationId, customerId, await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationContractClearanceMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/clearance$/);
|
||||||
|
if (req.method === "GET" && organizationContractClearanceMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationContractClearanceMatch[1]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 200, { clearance: evaluateProjectContractClearance(context, url.searchParams.get("projectId") || url.searchParams.get("project_id") || "", Object.fromEntries(url.searchParams.entries())) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationContractsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts$/);
|
||||||
|
if (req.method === "GET" && organizationContractsMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationContractsMatch[1]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 200, listContracts(context, organizationId, Object.fromEntries(url.searchParams.entries())));
|
||||||
|
}
|
||||||
|
if (req.method === "POST" && organizationContractsMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationContractsMatch[1]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 201, createContract(context, organizationId, await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationContractBindMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/([^/]+)\/bind-project$/);
|
||||||
|
if (req.method === "POST" && organizationContractBindMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationContractBindMatch[1]);
|
||||||
|
const contractId = decodeURIComponent(organizationContractBindMatch[2]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 200, bindContractProject(context, organizationId, contractId, await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationContractMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/([^/]+)$/);
|
||||||
|
if (req.method === "PATCH" && organizationContractMatch) {
|
||||||
|
const organizationId = decodeURIComponent(organizationContractMatch[1]);
|
||||||
|
const contractId = decodeURIComponent(organizationContractMatch[2]);
|
||||||
|
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||||
|
return send(res, 200, updateContract(context, organizationId, contractId, await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
const organizationEntitlementsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements$/);
|
const organizationEntitlementsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements$/);
|
||||||
if (req.method === "GET" && organizationEntitlementsMatch) {
|
if (req.method === "GET" && organizationEntitlementsMatch) {
|
||||||
const organizationId = decodeURIComponent(organizationEntitlementsMatch[1]);
|
const organizationId = decodeURIComponent(organizationEntitlementsMatch[1]);
|
||||||
|
|||||||
+14
-4
@@ -3,6 +3,7 @@ import { addAudit, addUsage, hasPermission, httpError, requireEntitlement, requi
|
|||||||
import { inspectMediaForProject } from "./media-qa.mjs";
|
import { inspectMediaForProject } from "./media-qa.mjs";
|
||||||
import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs";
|
import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs";
|
||||||
import { dispatchNotificationEvent } from "./notifications.mjs";
|
import { dispatchNotificationEvent } from "./notifications.mjs";
|
||||||
|
import { evaluateProjectContractClearance } from "./contracts.mjs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
@@ -908,8 +909,14 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
|
|||||||
const knowledge = knowledgeClearanceSection(context);
|
const knowledge = knowledgeClearanceSection(context);
|
||||||
const qa = qaClearanceSection(context);
|
const qa = qaClearanceSection(context);
|
||||||
const jobs = jobClearanceSection(context);
|
const jobs = jobClearanceSection(context);
|
||||||
const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers];
|
const contracts = evaluateProjectContractClearance(context, project.id, {
|
||||||
const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems];
|
channel: options.channel || null,
|
||||||
|
channelKind: options.channel?.kind || "",
|
||||||
|
deliverables: ["vertical-video", "delivery-manifest"],
|
||||||
|
territory: options.territory || "CN"
|
||||||
|
});
|
||||||
|
const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers, ...contracts.blockers];
|
||||||
|
const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems, ...contracts.reviewItems];
|
||||||
const status = clearanceStatus(blockers, reviewItems);
|
const status = clearanceStatus(blockers, reviewItems);
|
||||||
const checkedAt = now();
|
const checkedAt = now();
|
||||||
const baseCertificate = {
|
const baseCertificate = {
|
||||||
@@ -931,6 +938,7 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
|
|||||||
approvedKnowledgeRequired: true,
|
approvedKnowledgeRequired: true,
|
||||||
fixedVoiceEvidenceRequired: true,
|
fixedVoiceEvidenceRequired: true,
|
||||||
actualLastFrameRequired: true,
|
actualLastFrameRequired: true,
|
||||||
|
contractAuthorizationRequired: true,
|
||||||
paidCloudPublishDisabled: true
|
paidCloudPublishDisabled: true
|
||||||
},
|
},
|
||||||
counts: {
|
counts: {
|
||||||
@@ -941,7 +949,8 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
|
|||||||
knowledgeMaterials: knowledge.materials.length,
|
knowledgeMaterials: knowledge.materials.length,
|
||||||
qaReviews: qa.reviews.length,
|
qaReviews: qa.reviews.length,
|
||||||
mediaItems: media.items.length,
|
mediaItems: media.items.length,
|
||||||
pendingJobs: jobs.jobs.length
|
pendingJobs: jobs.jobs.length,
|
||||||
|
contractBindings: contracts.counts?.activeBindings || 0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const certificateId = `clearance-${jsonHash(baseCertificate).slice(0, 16)}`;
|
const certificateId = `clearance-${jsonHash(baseCertificate).slice(0, 16)}`;
|
||||||
@@ -966,7 +975,8 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
|
|||||||
voices: voices.voices,
|
voices: voices.voices,
|
||||||
knowledge,
|
knowledge,
|
||||||
qa,
|
qa,
|
||||||
jobs
|
jobs,
|
||||||
|
contracts
|
||||||
},
|
},
|
||||||
certificate
|
certificate
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -561,6 +561,98 @@ CREATE TABLE IF NOT EXISTS commercial_approval_requests (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
legal_name TEXT NOT NULL DEFAULT '',
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
customer_type TEXT NOT NULL DEFAULT 'platform' CHECK (customer_type IN ('platform', 'brand', 'agency', 'distributor', 'internal')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('prospect', 'active', 'paused', 'archived')),
|
||||||
|
industry TEXT NOT NULL DEFAULT '',
|
||||||
|
region TEXT NOT NULL DEFAULT '',
|
||||||
|
billing_email TEXT NOT NULL DEFAULT '',
|
||||||
|
tax_id TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (organization_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customer_contacts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
phone TEXT NOT NULL DEFAULT '',
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS production_contracts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||||||
|
contract_number TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
contract_type TEXT NOT NULL DEFAULT 'production_license' CHECK (contract_type IN ('production_license', 'distribution', 'work_for_hire', 'revenue_share', 'internal')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'active', 'expiring', 'expired', 'suspended', 'terminated')),
|
||||||
|
approval_status TEXT NOT NULL DEFAULT 'draft' CHECK (approval_status IN ('draft', 'legal_review', 'approved', 'blocked')),
|
||||||
|
effective_at TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
signed_at TEXT,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||||
|
amount REAL NOT NULL DEFAULT 0,
|
||||||
|
license_scope_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
rights_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
delivery_terms_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
evidence_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
risk_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
approval_note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
updated_by TEXT REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (organization_id, contract_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contract_project_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
contract_id TEXT NOT NULL REFERENCES production_contracts(id) ON DELETE CASCADE,
|
||||||
|
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||||||
|
usage_mode TEXT NOT NULL DEFAULT 'primary-license' CHECK (usage_mode IN ('primary-license', 'supplemental-rights', 'delivery-only', 'internal-review')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'review', 'blocked', 'archived')),
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (organization_id, project_id, contract_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contract_license_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
contract_id TEXT REFERENCES production_contracts(id) ON DELETE CASCADE,
|
||||||
|
customer_id TEXT REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||||
|
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'recorded',
|
||||||
|
actor_user_id TEXT REFERENCES users(id),
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- Local invoice ledger. This is intentionally payment-provider agnostic: the
|
-- Local invoice ledger. This is intentionally payment-provider agnostic: the
|
||||||
-- platform records billing snapshots and lifecycle state, while an external
|
-- platform records billing snapshots and lifecycle state, while an external
|
||||||
-- accounting or payment system can be connected later through an adapter.
|
-- accounting or payment system can be connected later through an adapter.
|
||||||
@@ -1594,6 +1686,13 @@ CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_ev
|
|||||||
CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customers_org_status ON customers(organization_id, status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_contacts_customer ON customer_contacts(customer_id, is_primary DESC, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_production_contracts_org_status ON production_contracts(organization_id, status, approval_status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_production_contracts_customer ON production_contracts(customer_id, status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_project ON contract_project_bindings(organization_id, workspace_id, project_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_contract ON contract_project_bindings(contract_id, status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_license_events_scope ON contract_license_events(organization_id, contract_id, project_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_health_status ON service_health(status, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_service_health_status ON service_health(status, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id, expires_at, revoked_at);
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id, expires_at, revoked_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_hash ON auth_mfa_challenges(challenge_hash, expires_at, consumed_at);
|
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_hash ON auth_mfa_challenges(challenge_hash, expires_at, consumed_at);
|
||||||
|
|||||||
+12
-7
@@ -824,6 +824,8 @@ function entitlementUsageValue(organizationId, key) {
|
|||||||
if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
||||||
if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0);
|
if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0);
|
||||||
if (key === "limit.style_kits") return Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
if (key === "limit.style_kits") return Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
||||||
|
if (key === "limit.customers") return Number(dbGet("SELECT COUNT(*) AS count FROM customers WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
||||||
|
if (key === "limit.contracts") return Number(dbGet("SELECT COUNT(*) AS count FROM production_contracts WHERE organization_id = ? AND status != 'terminated'", [organizationId])?.count || 0);
|
||||||
if (key === "limit.model_connectors") return Number(dbGet("SELECT COUNT(*) AS count FROM model_connectors WHERE organization_id = ?", [organizationId])?.count || 0);
|
if (key === "limit.model_connectors") return Number(dbGet("SELECT COUNT(*) AS count FROM model_connectors WHERE organization_id = ?", [organizationId])?.count || 0);
|
||||||
if (key === "limit.api_clients") return Number(dbGet("SELECT COUNT(*) AS count FROM api_clients WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
if (key === "limit.api_clients") return Number(dbGet("SELECT COUNT(*) AS count FROM api_clients WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
||||||
if (key === "limit.knowledge_documents") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_documents WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
if (key === "limit.knowledge_documents") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_documents WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
||||||
@@ -876,6 +878,8 @@ function defaultEntitlementRows(organizationId) {
|
|||||||
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
||||||
["limit.generation_jobs_monthly", "月度生成任务", "production", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }],
|
["limit.generation_jobs_monthly", "月度生成任务", "production", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }],
|
||||||
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
|
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
|
||||||
|
["limit.customers", "客户主档", "commercial", 120, "个", 1, "block", { commercialGate: "customer:create" }],
|
||||||
|
["limit.contracts", "合同授权", "commercial", 240, "份", 1, "block", { commercialGate: "contract:create" }],
|
||||||
["limit.storage_gb", "存储容量", "storage", Number(billing.storage_gb || 1024), "GB", 1, "block", { commercialGate: "storage:write" }],
|
["limit.storage_gb", "存储容量", "storage", Number(billing.storage_gb || 1024), "GB", 1, "block", { commercialGate: "storage:write" }],
|
||||||
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
||||||
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
||||||
@@ -910,13 +914,14 @@ export function organizationEntitlements(organizationId) {
|
|||||||
ORDER BY CASE category
|
ORDER BY CASE category
|
||||||
WHEN 'tenant' THEN 0
|
WHEN 'tenant' THEN 0
|
||||||
WHEN 'production' THEN 1
|
WHEN 'production' THEN 1
|
||||||
WHEN 'knowledge' THEN 2
|
WHEN 'commercial' THEN 2
|
||||||
WHEN 'modelops' THEN 3
|
WHEN 'knowledge' THEN 3
|
||||||
WHEN 'delivery' THEN 4
|
WHEN 'modelops' THEN 4
|
||||||
WHEN 'storage' THEN 5
|
WHEN 'delivery' THEN 5
|
||||||
WHEN 'system' THEN 6
|
WHEN 'storage' THEN 6
|
||||||
WHEN 'feature' THEN 7
|
WHEN 'system' THEN 7
|
||||||
ELSE 8
|
WHEN 'feature' THEN 8
|
||||||
|
ELSE 9
|
||||||
END, entitlement_key`,
|
END, entitlement_key`,
|
||||||
[organizationId]
|
[organizationId]
|
||||||
).map((row) => entitlementPayload(row, organizationId));
|
).map((row) => entitlementPayload(row, organizationId));
|
||||||
|
|||||||
+5
-1
@@ -14,6 +14,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
FileJson2,
|
FileJson2,
|
||||||
|
FileKey2,
|
||||||
Film,
|
Film,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
AdminAuditPage,
|
AdminAuditPage,
|
||||||
AdminModelsPage,
|
AdminModelsPage,
|
||||||
AdminOverviewPage,
|
AdminOverviewPage,
|
||||||
|
AdminContractsPage,
|
||||||
AdminPolicyCenterPage,
|
AdminPolicyCenterPage,
|
||||||
AdminStyleKitsPage,
|
AdminStyleKitsPage,
|
||||||
AdminQueuePage,
|
AdminQueuePage,
|
||||||
@@ -154,6 +156,7 @@ const navigationGroups = [
|
|||||||
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
|
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
|
||||||
{ id: "admin-policies", label: "组织策略", icon: ShieldCheck, requiredAnyPermissions: ["policy:read", "policy:manage", "organization:manage"] },
|
{ id: "admin-policies", label: "组织策略", icon: ShieldCheck, requiredAnyPermissions: ["policy:read", "policy:manage", "organization:manage"] },
|
||||||
{ id: "admin-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
|
{ id: "admin-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
|
||||||
|
{ id: "admin-contracts", label: "客户合同", icon: FileKey2, requiredAnyPermissions: ["customer:read", "customer:manage", "contract:read", "contract:manage"] },
|
||||||
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
||||||
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
|
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
|
||||||
{ id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" },
|
{ id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" },
|
||||||
@@ -1715,7 +1718,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
|
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
|
||||||
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
|
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
|
||||||
const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "policy:manage", "policy:read", "organization:manage", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
|
const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "policy:manage", "policy:read", "organization:manage", "style:manage", "style:read", "customer:read", "customer:manage", "contract:read", "contract:manage", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
|
||||||
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
|
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
|
||||||
const canCreateProject = effectivePermissions.has("project:create");
|
const canCreateProject = effectivePermissions.has("project:create");
|
||||||
const canCreateJob = effectivePermissions.has("job:create");
|
const canCreateJob = effectivePermissions.has("job:create");
|
||||||
@@ -1895,6 +1898,7 @@ function App() {
|
|||||||
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
||||||
{activeTab === "admin-policies" && <AdminPolicyCenterPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
{activeTab === "admin-policies" && <AdminPolicyCenterPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
|
{activeTab === "admin-contracts" && <AdminContractsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
||||||
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
{activeTab === "admin-usage" && <AdminUsagePage contextOverrides={contextOverrides} platformContext={platformContext} />}
|
{activeTab === "admin-usage" && <AdminUsagePage contextOverrides={contextOverrides} platformContext={platformContext} />}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Ban,
|
Ban,
|
||||||
Bell,
|
Bell,
|
||||||
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
Clock3,
|
Clock3,
|
||||||
@@ -165,7 +166,15 @@ import {
|
|||||||
bindProjectStyleKit,
|
bindProjectStyleKit,
|
||||||
createStyleKit,
|
createStyleKit,
|
||||||
fetchStyleKits,
|
fetchStyleKits,
|
||||||
updateStyleKit
|
updateStyleKit,
|
||||||
|
bindContractProject,
|
||||||
|
createContract,
|
||||||
|
createCustomer,
|
||||||
|
fetchContractClearance,
|
||||||
|
fetchContracts,
|
||||||
|
updateContract,
|
||||||
|
updateCustomer,
|
||||||
|
upsertCustomerContact
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { downloadJson } from "../lib/exporters";
|
import { downloadJson } from "../lib/exporters";
|
||||||
|
|
||||||
@@ -2575,6 +2584,467 @@ export function AdminStyleKitsPage({ platformContext, contextOverrides }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerTypeLabels = {
|
||||||
|
platform: "平台客户",
|
||||||
|
brand: "品牌方",
|
||||||
|
agency: "代理商",
|
||||||
|
distributor: "发行方",
|
||||||
|
internal: "内部项目"
|
||||||
|
};
|
||||||
|
|
||||||
|
const customerStatusLabels = {
|
||||||
|
prospect: "潜在",
|
||||||
|
active: "启用",
|
||||||
|
paused: "暂停",
|
||||||
|
archived: "归档"
|
||||||
|
};
|
||||||
|
|
||||||
|
const contractTypeLabels = {
|
||||||
|
production_license: "制作授权",
|
||||||
|
distribution: "发行授权",
|
||||||
|
work_for_hire: "委托制作",
|
||||||
|
revenue_share: "分成合作",
|
||||||
|
internal: "内部授权"
|
||||||
|
};
|
||||||
|
|
||||||
|
const contractStatusLabels = {
|
||||||
|
draft: "草稿",
|
||||||
|
submitted: "已提交",
|
||||||
|
active: "生效",
|
||||||
|
expiring: "临期",
|
||||||
|
expired: "过期",
|
||||||
|
suspended: "暂停",
|
||||||
|
terminated: "终止"
|
||||||
|
};
|
||||||
|
|
||||||
|
const contractApprovalLabels = {
|
||||||
|
draft: "未送审",
|
||||||
|
legal_review: "法务复核",
|
||||||
|
approved: "已批准",
|
||||||
|
blocked: "已阻断"
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultCustomerForm() {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
legalName: "",
|
||||||
|
code: "",
|
||||||
|
customerType: "distributor",
|
||||||
|
status: "active",
|
||||||
|
industry: "短剧发行",
|
||||||
|
region: "CN",
|
||||||
|
billingEmail: "",
|
||||||
|
taxId: "",
|
||||||
|
notes: "",
|
||||||
|
tagsText: "发行,本地交付"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerFormFromCustomer(customer = {}) {
|
||||||
|
if (!customer?.id) return defaultCustomerForm();
|
||||||
|
return {
|
||||||
|
id: customer.id,
|
||||||
|
name: customer.name || "",
|
||||||
|
legalName: customer.legalName || "",
|
||||||
|
code: customer.code || "",
|
||||||
|
customerType: customer.customerType || "distributor",
|
||||||
|
status: customer.status || "active",
|
||||||
|
industry: customer.industry || "",
|
||||||
|
region: customer.region || "",
|
||||||
|
billingEmail: customer.billingEmail || "",
|
||||||
|
taxId: customer.taxId || "",
|
||||||
|
notes: customer.notes || "",
|
||||||
|
tagsText: (customer.tags || []).join(",")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultContractForm(customerId = "") {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
customerId,
|
||||||
|
contractNumber: "",
|
||||||
|
title: "",
|
||||||
|
contractType: "production_license",
|
||||||
|
status: "draft",
|
||||||
|
approvalStatus: "draft",
|
||||||
|
effectiveAt: new Date().toISOString().slice(0, 10),
|
||||||
|
expiresAt: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
|
||||||
|
signedAt: "",
|
||||||
|
currency: "CNY",
|
||||||
|
amount: 0,
|
||||||
|
licenseScopeJson: JSON.stringify({
|
||||||
|
channels: ["local-file", "private-delivery-portal"],
|
||||||
|
territories: ["CN"],
|
||||||
|
deliverables: ["vertical-video", "delivery-manifest"],
|
||||||
|
platforms: ["private-preview", "internal-review"],
|
||||||
|
exclusive: false,
|
||||||
|
commercialUse: true,
|
||||||
|
language: ["zh-CN"]
|
||||||
|
}, null, 2),
|
||||||
|
rightsJson: JSON.stringify({
|
||||||
|
originalStory: true,
|
||||||
|
derivativeProduction: true,
|
||||||
|
aiGeneratedAssetsAllowed: true,
|
||||||
|
voiceCloneAllowed: false,
|
||||||
|
fixedVoiceRequired: true,
|
||||||
|
singleFramePolicyRequired: true
|
||||||
|
}, null, 2),
|
||||||
|
deliveryTermsJson: JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true, maxAccessDays: 30 }, null, 2),
|
||||||
|
evidenceJson: JSON.stringify({ contractRef: "", rightsEvidenceRef: "", signedCopyPath: "" }, null, 2),
|
||||||
|
riskJson: JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "pending" }, null, 2),
|
||||||
|
approvalNote: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractFormFromContract(contract = {}, fallbackCustomerId = "") {
|
||||||
|
if (!contract?.id) return defaultContractForm(fallbackCustomerId);
|
||||||
|
return {
|
||||||
|
id: contract.id,
|
||||||
|
customerId: contract.customerId || fallbackCustomerId,
|
||||||
|
contractNumber: contract.contractNumber || "",
|
||||||
|
title: contract.title || "",
|
||||||
|
contractType: contract.contractType || "production_license",
|
||||||
|
status: contract.status || "draft",
|
||||||
|
approvalStatus: contract.approvalStatus || "draft",
|
||||||
|
effectiveAt: (contract.effectiveAt || "").slice(0, 10),
|
||||||
|
expiresAt: (contract.expiresAt || "").slice(0, 10),
|
||||||
|
signedAt: (contract.signedAt || "").slice(0, 10),
|
||||||
|
currency: contract.currency || "CNY",
|
||||||
|
amount: contract.amount || 0,
|
||||||
|
licenseScopeJson: JSON.stringify(contract.licenseScope || {}, null, 2),
|
||||||
|
rightsJson: JSON.stringify(contract.rights || {}, null, 2),
|
||||||
|
deliveryTermsJson: JSON.stringify(contract.deliveryTerms || {}, null, 2),
|
||||||
|
evidenceJson: JSON.stringify(contract.evidence || {}, null, 2),
|
||||||
|
riskJson: JSON.stringify(contract.risk || {}, null, 2),
|
||||||
|
approvalNote: contract.approvalNote || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvText(value) {
|
||||||
|
return String(value || "").split(/[,\n,、]/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField(value, label) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value || "{}");
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 不是合法 JSON`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractStatusTone(status) {
|
||||||
|
if (["active", "approved", "pass"].includes(status)) return "active";
|
||||||
|
if (["blocked", "expired", "suspended", "terminated"].includes(status)) return "failed";
|
||||||
|
if (["expiring", "review", "legal_review"].includes(status)) return "needs-evidence";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminContractsPage({ platformContext, contextOverrides }) {
|
||||||
|
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||||
|
const canManage = Boolean(platformContext?.context?.systemAdmin || permissions.has("contract:manage") || permissions.has("customer:manage"));
|
||||||
|
const organizationId = contextOverrides.organizationId || platformContext?.context?.currentOrganization?.id || platformContext?.context?.organization?.id || "";
|
||||||
|
const initialProjectId = contextOverrides.projectId || platformContext?.context?.currentProject?.id || platformContext?.context?.project?.id || "";
|
||||||
|
const [data, setData] = useState({ customers: [], contracts: [], projects: [], events: [], summary: {}, clearance: null });
|
||||||
|
const [selectedCustomerId, setSelectedCustomerId] = useState("");
|
||||||
|
const [selectedContractId, setSelectedContractId] = useState("");
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId);
|
||||||
|
const [customerForm, setCustomerForm] = useState(defaultCustomerForm());
|
||||||
|
const [contractForm, setContractForm] = useState(defaultContractForm());
|
||||||
|
const [contactForm, setContactForm] = useState({ id: "", name: "", role: "", email: "", phone: "", isPrimary: true, status: "active" });
|
||||||
|
const [bindingForm, setBindingForm] = useState({ projectId: initialProjectId, usageMode: "primary-license", status: "active", notes: "" });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const scopeKey = [organizationId, contextOverrides.workspaceId, contextOverrides.projectId].join("|");
|
||||||
|
|
||||||
|
async function load(nextProjectId = selectedProjectId || initialProjectId) {
|
||||||
|
if (!organizationId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchContracts(organizationId, { includeArchived: 1 }, contextOverrides);
|
||||||
|
let clearance = null;
|
||||||
|
const projectId = nextProjectId || result.projects?.[0]?.id || "";
|
||||||
|
if (projectId) {
|
||||||
|
const clearanceResult = await fetchContractClearance(organizationId, { projectId, channelKind: "private-delivery-portal", territory: "CN" }, contextOverrides);
|
||||||
|
clearance = clearanceResult.clearance;
|
||||||
|
}
|
||||||
|
setData({ ...result, clearance });
|
||||||
|
const nextCustomer = result.customers?.find((item) => item.id === selectedCustomerId) || result.customers?.[0] || null;
|
||||||
|
const nextContract = result.contracts?.find((item) => item.id === selectedContractId) || result.contracts?.[0] || null;
|
||||||
|
setSelectedCustomerId(nextCustomer?.id || "");
|
||||||
|
setSelectedContractId(nextContract?.id || "");
|
||||||
|
setCustomerForm(customerFormFromCustomer(nextCustomer));
|
||||||
|
setContractForm(contractFormFromContract(nextContract, nextCustomer?.id || ""));
|
||||||
|
setBindingForm((current) => ({ ...current, projectId: projectId || current.projectId }));
|
||||||
|
setSelectedProjectId(projectId);
|
||||||
|
setNotice("");
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(initialProjectId); }, [scopeKey]);
|
||||||
|
|
||||||
|
const selectedCustomer = data.customers.find((item) => item.id === selectedCustomerId) || null;
|
||||||
|
const selectedContract = data.contracts.find((item) => item.id === selectedContractId) || null;
|
||||||
|
const activeContracts = data.contracts.filter((item) => item.status === "active");
|
||||||
|
const clearance = data.clearance;
|
||||||
|
const licensePreview = selectedContract?.licenseScope || parseJsonFieldSafe(contractForm.licenseScopeJson, {});
|
||||||
|
|
||||||
|
function selectCustomer(customer) {
|
||||||
|
setSelectedCustomerId(customer.id);
|
||||||
|
setCustomerForm(customerFormFromCustomer(customer));
|
||||||
|
if (!contractForm.id) setContractForm(defaultContractForm(customer.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectContract(contract) {
|
||||||
|
setSelectedContractId(contract.id);
|
||||||
|
setContractForm(contractFormFromContract(contract, selectedCustomerId));
|
||||||
|
setBindingForm((current) => {
|
||||||
|
const activeBinding = contract.bindings?.find((item) => item.status === "active") || contract.bindings?.[0];
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
projectId: activeBinding?.projectId || current.projectId || selectedProjectId,
|
||||||
|
usageMode: activeBinding?.usageMode || current.usageMode,
|
||||||
|
status: activeBinding?.status || current.status,
|
||||||
|
notes: activeBinding?.notes || current.notes
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonFieldSafe(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value || "{}");
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCustomer(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canManage) return;
|
||||||
|
setBusy("customer");
|
||||||
|
try {
|
||||||
|
const body = { ...customerForm, tags: parseCsvText(customerForm.tagsText) };
|
||||||
|
const result = customerForm.id ? await updateCustomer(organizationId, customerForm.id, body, contextOverrides) : await createCustomer(organizationId, body, contextOverrides);
|
||||||
|
const saved = result.customer;
|
||||||
|
setData((current) => ({ ...current, customers: result.customers || current.customers, summary: { ...current.summary } }));
|
||||||
|
setSelectedCustomerId(saved?.id || "");
|
||||||
|
setCustomerForm(customerFormFromCustomer(saved));
|
||||||
|
if (!contractForm.customerId) setContractForm((current) => ({ ...current, customerId: saved?.id || "" }));
|
||||||
|
setNotice(`客户“${saved?.name || body.name}”已保存`);
|
||||||
|
await load(selectedProjectId);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveContact(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canManage || !selectedCustomerId) return;
|
||||||
|
setBusy("contact");
|
||||||
|
try {
|
||||||
|
await upsertCustomerContact(organizationId, selectedCustomerId, contactForm, contextOverrides);
|
||||||
|
setContactForm({ id: "", name: "", role: "", email: "", phone: "", isPrimary: true, status: "active" });
|
||||||
|
setNotice("客户联系人已保存");
|
||||||
|
await load(selectedProjectId);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveContract(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canManage) return;
|
||||||
|
setBusy("contract");
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
...contractForm,
|
||||||
|
amount: Number(contractForm.amount || 0),
|
||||||
|
licenseScope: parseJsonField(contractForm.licenseScopeJson, "授权范围"),
|
||||||
|
rights: parseJsonField(contractForm.rightsJson, "权利条款"),
|
||||||
|
deliveryTerms: parseJsonField(contractForm.deliveryTermsJson, "交付条款"),
|
||||||
|
evidence: parseJsonField(contractForm.evidenceJson, "证据引用"),
|
||||||
|
risk: parseJsonField(contractForm.riskJson, "风险策略")
|
||||||
|
};
|
||||||
|
const result = contractForm.id ? await updateContract(organizationId, contractForm.id, body, contextOverrides) : await createContract(organizationId, body, contextOverrides);
|
||||||
|
const saved = result.contract || result.contracts?.find((item) => item.contractNumber === body.contractNumber);
|
||||||
|
setData((current) => ({ ...current, ...result, clearance: current.clearance }));
|
||||||
|
setSelectedContractId(saved?.id || "");
|
||||||
|
setContractForm(contractFormFromContract(saved, contractForm.customerId));
|
||||||
|
setNotice(`合同“${saved?.title || body.title}”已保存并写入授权事件`);
|
||||||
|
await load(selectedProjectId);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindProject(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canManage || !selectedContractId || !bindingForm.projectId) return;
|
||||||
|
setBusy("binding");
|
||||||
|
try {
|
||||||
|
const result = await bindContractProject(organizationId, selectedContractId, bindingForm, contextOverrides);
|
||||||
|
setData((current) => ({ ...current, ...result }));
|
||||||
|
setSelectedProjectId(bindingForm.projectId);
|
||||||
|
setNotice("合同项目绑定已更新,发布清算会自动继承");
|
||||||
|
await load(bindingForm.projectId);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runClearance(projectId = selectedProjectId) {
|
||||||
|
if (!projectId) return;
|
||||||
|
setBusy("clearance");
|
||||||
|
try {
|
||||||
|
const result = await fetchContractClearance(organizationId, { projectId, channelKind: "private-delivery-portal", territory: "CN" }, contextOverrides);
|
||||||
|
setData((current) => ({ ...current, clearance: result.clearance }));
|
||||||
|
setSelectedProjectId(projectId);
|
||||||
|
setNotice(result.clearance.status === "blocked" ? "合同授权清算存在阻断项" : "合同授权清算通过");
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPack() {
|
||||||
|
downloadJson(`contract-clearance-${organizationId}.json`, {
|
||||||
|
organizationId,
|
||||||
|
customers: data.customers,
|
||||||
|
contracts: data.contracts,
|
||||||
|
clearance: data.clearance,
|
||||||
|
exportedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="enterprise-page contract-page">
|
||||||
|
<AdminHeader eyebrow="ADMIN CONSOLE / COMMERCIAL RIGHTS" title="客户、合同与授权清算" description="把客户主档、合同范围、项目绑定和交付放行合在一处管理;发布与客户门户会复用同一套服务端清算规则。" action={<div className="header-actions"><StatusBadge status={clearance?.status || "review"} label={clearance?.status === "pass" ? "当前项目可交付" : clearance?.status === "blocked" ? "存在授权阻断" : "等待清算"} /><button className="subtle" onClick={() => load(selectedProjectId)} disabled={loading}><RefreshCw size={15} />刷新</button><button className="subtle" onClick={exportPack}><Download size={15} />导出包</button></div>} />
|
||||||
|
{notice && <div className={`inline-notice ${notice.includes("阻断") || notice.includes("不能") || notice.includes("没有") || notice.includes("不是") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
|
||||||
|
<div className="admin-kpi-grid compact-kpis">
|
||||||
|
<AdminKpi icon={Building2} label="客户主档" value={data.customers.length} detail={`${data.customers.filter((item) => item.status === "active").length} 个启用`} tone="ok" />
|
||||||
|
<AdminKpi icon={FileKey2} label="合同授权" value={data.contracts.length} detail={`${activeContracts.length} 份生效`} tone="ok" />
|
||||||
|
<AdminKpi icon={Link2} label="项目绑定" value={data.summary?.boundProjects || 0} detail="绑定后进入发布清算" tone="neutral" />
|
||||||
|
<AdminKpi icon={ShieldCheck} label="清算状态" value={clearance?.status || "未运行"} detail={`${clearance?.blockers?.length || 0} 阻断 / ${clearance?.reviewItems?.length || 0} 复核`} tone={clearance?.status === "blocked" ? "warn" : "ok"} />
|
||||||
|
</div>
|
||||||
|
<div className="contract-admin-layout">
|
||||||
|
<section className="studio-card contract-list-card">
|
||||||
|
<SectionBar title="客户主档" detail={loading ? "读取中…" : `${data.customers.length} 个客户`} action={canManage ? <button className="icon-text-button" onClick={() => { setSelectedCustomerId(""); setCustomerForm(defaultCustomerForm()); }}><Plus size={13} />新建</button> : null} />
|
||||||
|
<div className="contract-entity-list">
|
||||||
|
{data.customers.map((customer) => <button key={customer.id} type="button" className={selectedCustomerId === customer.id ? "selected" : ""} onClick={() => selectCustomer(customer)}><div><strong>{customer.name}</strong><span>{customer.code} · {customerTypeLabels[customer.customerType] || customer.customerType}</span><small>{customer.legalName || customer.notes || "未填写主体信息"}</small></div><StatusBadge status={contractStatusTone(customer.status)} label={customerStatusLabels[customer.status] || customer.status} /></button>)}
|
||||||
|
{!data.customers.length && <div className="empty-table">暂无客户主档。</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card contract-list-card">
|
||||||
|
<SectionBar title="合同授权" detail={`${data.contracts.length} 份可见合同`} action={canManage ? <button className="icon-text-button" onClick={() => { setSelectedContractId(""); setContractForm(defaultContractForm(selectedCustomerId)); }}><Plus size={13} />新建</button> : null} />
|
||||||
|
<div className="contract-entity-list">
|
||||||
|
{data.contracts.map((contract) => <button key={contract.id} type="button" className={selectedContractId === contract.id ? "selected" : ""} onClick={() => selectContract(contract)}><div><strong>{contract.title}</strong><span>{contract.contractNumber} · {contract.customerName}</span><small>{contract.bindings?.map((binding) => binding.projectName).join(" / ") || "未绑定项目"}</small></div><StatusBadge status={contractStatusTone(contract.status === "active" ? contract.approvalStatus : contract.status)} label={`${contractStatusLabels[contract.status] || contract.status} · ${contractApprovalLabels[contract.approvalStatus] || contract.approvalStatus}`} /></button>)}
|
||||||
|
{!data.contracts.length && <div className="empty-table">暂无合同授权。</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card wide-card contract-editor-card">
|
||||||
|
<SectionBar title={customerForm.id ? `客户:${selectedCustomer?.name || customerForm.name}` : "新建客户"} detail={canManage ? "保存后落库并审计" : "只读 · 需要 customer:manage"} />
|
||||||
|
<form className="contract-form compact" onSubmit={saveCustomer}>
|
||||||
|
<label>客户名称<input value={customerForm.name} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, name: event.target.value })} required /></label>
|
||||||
|
<label>客户编码<input value={customerForm.code} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, code: event.target.value })} required /></label>
|
||||||
|
<label>客户类型<select value={customerForm.customerType} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, customerType: event.target.value })}>{Object.entries(customerTypeLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||||
|
<label>状态<select value={customerForm.status} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, status: event.target.value })}>{Object.entries(customerStatusLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||||
|
<label>法定主体<input value={customerForm.legalName} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, legalName: event.target.value })} /></label>
|
||||||
|
<label>区域<input value={customerForm.region} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, region: event.target.value })} /></label>
|
||||||
|
<label>账单邮箱<input value={customerForm.billingEmail} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, billingEmail: event.target.value })} /></label>
|
||||||
|
<label>标签<input value={customerForm.tagsText} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, tagsText: event.target.value })} /></label>
|
||||||
|
<label className="contract-form-span">备注<textarea rows="3" value={customerForm.notes} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, notes: event.target.value })} /></label>
|
||||||
|
<div className="form-actions contract-form-span"><button className="primary" type="submit" disabled={!canManage || busy === "customer"}><Save size={15} />保存客户</button></div>
|
||||||
|
</form>
|
||||||
|
<SectionBar title="联系人" detail={`${selectedCustomer?.contacts?.length || 0} 个联系人`} />
|
||||||
|
<div className="contract-contact-grid">
|
||||||
|
<div className="contract-contact-list">
|
||||||
|
{(selectedCustomer?.contacts || []).map((contact) => <button key={contact.id} type="button" onClick={() => setContactForm(contact)}><strong>{contact.name}</strong><span>{contact.role || "联系人"} · {contact.email || contact.phone || "未填联系方式"}</span>{contact.isPrimary && <small>主联系人</small>}</button>)}
|
||||||
|
{!selectedCustomer?.contacts?.length && <div className="empty-table">选择客户后可维护联系人。</div>}
|
||||||
|
</div>
|
||||||
|
<form className="contract-form mini" onSubmit={saveContact}>
|
||||||
|
<label>姓名<input value={contactForm.name} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, name: event.target.value })} required /></label>
|
||||||
|
<label>角色<input value={contactForm.role} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, role: event.target.value })} /></label>
|
||||||
|
<label>邮箱<input value={contactForm.email} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, email: event.target.value })} /></label>
|
||||||
|
<label className="checkbox-line"><input type="checkbox" checked={contactForm.isPrimary} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, isPrimary: event.target.checked })} />主联系人</label>
|
||||||
|
<button className="subtle" type="submit" disabled={!canManage || !selectedCustomerId || busy === "contact"}><Save size={14} />保存联系人</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="enterprise-grid enterprise-grid-main contract-bottom-grid">
|
||||||
|
<section className="studio-card wide-card">
|
||||||
|
<SectionBar title={contractForm.id ? `合同:${selectedContract?.contractNumber || contractForm.contractNumber}` : "新建合同"} detail={canManage ? "授权范围、权利条款和证据会参与放行" : "只读 · 需要 contract:manage"} />
|
||||||
|
<form className="contract-form contract-form-dense" onSubmit={saveContract}>
|
||||||
|
<label>合同标题<input value={contractForm.title} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, title: event.target.value })} required /></label>
|
||||||
|
<label>合同编号<input value={contractForm.contractNumber} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, contractNumber: event.target.value })} required /></label>
|
||||||
|
<label>客户<select value={contractForm.customerId} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, customerId: event.target.value })}>{data.customers.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}</select></label>
|
||||||
|
<label>类型<select value={contractForm.contractType} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, contractType: event.target.value })}>{Object.entries(contractTypeLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||||
|
<label>合同状态<select value={contractForm.status} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, status: event.target.value })}>{Object.entries(contractStatusLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||||
|
<label>审批状态<select value={contractForm.approvalStatus} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, approvalStatus: event.target.value })}>{Object.entries(contractApprovalLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||||
|
<label>生效日期<input type="date" value={contractForm.effectiveAt} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, effectiveAt: event.target.value })} /></label>
|
||||||
|
<label>到期日期<input type="date" value={contractForm.expiresAt} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, expiresAt: event.target.value })} /></label>
|
||||||
|
<label>签署日期<input type="date" value={contractForm.signedAt} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, signedAt: event.target.value })} /></label>
|
||||||
|
<label>金额<input type="number" min="0" value={contractForm.amount} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, amount: event.target.value })} /></label>
|
||||||
|
<label className="contract-form-span">授权范围 JSON<textarea rows="8" value={contractForm.licenseScopeJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, licenseScopeJson: event.target.value })} /></label>
|
||||||
|
<label className="contract-form-span">权利条款 JSON<textarea rows="7" value={contractForm.rightsJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, rightsJson: event.target.value })} /></label>
|
||||||
|
<label>交付条款 JSON<textarea rows="7" value={contractForm.deliveryTermsJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, deliveryTermsJson: event.target.value })} /></label>
|
||||||
|
<label>证据引用 JSON<textarea rows="7" value={contractForm.evidenceJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, evidenceJson: event.target.value })} /></label>
|
||||||
|
<label className="contract-form-span">审批备注<textarea rows="3" value={contractForm.approvalNote} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, approvalNote: event.target.value })} /></label>
|
||||||
|
<div className="approval-policy-note contract-form-span"><ShieldCheck size={15} /><span>合同必须批准、生效、未过期,并明确允许 AI 生成资产、原创/改编制作、一图一完整单画面和固定声线,交付才会放行。</span></div>
|
||||||
|
<div className="form-actions contract-form-span"><button className="primary" type="submit" disabled={!canManage || busy === "contract"}><Save size={15} />保存合同</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card">
|
||||||
|
<SectionBar title="项目绑定与清算" detail={selectedProjectId || "选择项目"} />
|
||||||
|
<form className="contract-form mini" onSubmit={bindProject}>
|
||||||
|
<label>项目<select value={bindingForm.projectId} disabled={!canManage} onChange={(event) => { setBindingForm({ ...bindingForm, projectId: event.target.value }); runClearance(event.target.value); }}>{data.projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></label>
|
||||||
|
<label>用途<select value={bindingForm.usageMode} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, usageMode: event.target.value })}><option value="primary-license">主授权</option><option value="supplemental-rights">补充权利</option><option value="delivery-only">仅交付</option><option value="internal-review">内部评审</option></select></label>
|
||||||
|
<label>绑定状态<select value={bindingForm.status} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, status: event.target.value })}><option value="active">启用</option><option value="review">复核</option><option value="blocked">阻断</option><option value="archived">归档</option></select></label>
|
||||||
|
<label>备注<textarea rows="3" value={bindingForm.notes} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, notes: event.target.value })} /></label>
|
||||||
|
<div className="form-actions"><button className="primary" type="submit" disabled={!canManage || !selectedContractId || busy === "binding"}><Link2 size={15} />绑定合同</button><button className="subtle" type="button" onClick={() => runClearance(bindingForm.projectId)} disabled={busy === "clearance"}><ShieldCheck size={15} />检查清算</button></div>
|
||||||
|
</form>
|
||||||
|
<div className="contract-clearance-panel">
|
||||||
|
<div className="clearance-header"><strong>{clearance?.projectName || "项目授权清算"}</strong><StatusBadge status={contractStatusTone(clearance?.status || "review")} label={clearance?.status || "未运行"} /></div>
|
||||||
|
<div className="contract-scope-strip">
|
||||||
|
<span>渠道:{licensePreview.channels?.join(" / ") || "未定义"}</span>
|
||||||
|
<span>地域:{licensePreview.territories?.join(" / ") || "未定义"}</span>
|
||||||
|
<span>交付物:{licensePreview.deliverables?.join(" / ") || "未定义"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="contract-issue-list">
|
||||||
|
{(clearance?.blockers || []).map((item, index) => <div key={`blocker-${index}`} className="warn"><AlertTriangle size={14} /><span>{item.reason}</span><small>{item.code}</small></div>)}
|
||||||
|
{(clearance?.reviewItems || []).map((item, index) => <div key={`review-${index}`}><ShieldAlert size={14} /><span>{item.reason}</span><small>{item.code}</small></div>)}
|
||||||
|
{clearance && !clearance.blockers?.length && !clearance.reviewItems?.length && <div className="ok"><CheckCircle2 size={14} /><span>当前项目合同授权清算通过。</span><small>{clearance.checkedAt?.replace("T", " ").slice(0, 19)}</small></div>}
|
||||||
|
{!clearance && <div className="empty-table">选择项目后运行合同清算。</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card wide-card">
|
||||||
|
<SectionBar title="授权事件台账" detail={`${data.events.length} 条最近事件`} />
|
||||||
|
<div className="contract-event-table">
|
||||||
|
<div className="contract-event-head"><span>时间</span><span>事件</span><span>合同/项目</span><span>结果</span></div>
|
||||||
|
{data.events.slice(0, 12).map((event) => <div className="contract-event-row" key={event.id}><span>{event.createdAt?.replace("T", " ").slice(0, 19)}</span><strong>{event.eventType}</strong><span>{event.contractNumber || event.customerName}<small>{event.projectName || event.projectId || "组织级"}</small></span><StatusBadge status={contractStatusTone(event.status)} label={event.status} /></div>)}
|
||||||
|
{!data.events.length && <div className="empty-table">暂无授权事件。</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const policyStatusLabels = {
|
const policyStatusLabels = {
|
||||||
draft: "草稿",
|
draft: "草稿",
|
||||||
enforced: "强制",
|
enforced: "强制",
|
||||||
|
|||||||
@@ -483,6 +483,53 @@ export async function fetchOrganizationCommercial(organizationId, overrides = {}
|
|||||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial`, {}, overrides);
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial`, {}, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contractQuery(filters = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const key of ["query", "q", "status", "customerId", "projectId", "channelKind", "territory", "includeArchived", "limit"]) {
|
||||||
|
if (filters[key] !== undefined && filters[key] !== null && String(filters[key]) !== "") params.set(key, String(filters[key]));
|
||||||
|
}
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCustomers(organizationId, filters = {}, overrides = {}) {
|
||||||
|
const query = contractQuery(filters);
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers${query ? `?${query}` : ""}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomer(organizationId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomer(organizationId, customerId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers/${encodeURIComponent(customerId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertCustomerContact(organizationId, customerId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers/${encodeURIComponent(customerId)}/contacts`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchContracts(organizationId, filters = {}, overrides = {}) {
|
||||||
|
const query = contractQuery(filters);
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts${query ? `?${query}` : ""}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContract(organizationId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContract(organizationId, contractId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts/${encodeURIComponent(contractId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindContractProject(organizationId, contractId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts/${encodeURIComponent(contractId)}/bind-project`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchContractClearance(organizationId, filters = {}, overrides = {}) {
|
||||||
|
const query = contractQuery(filters);
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts/clearance${query ? `?${query}` : ""}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchOrganizationEntitlements(organizationId, overrides = {}) {
|
export async function fetchOrganizationEntitlements(organizationId, overrides = {}) {
|
||||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/entitlements`, {}, overrides);
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/entitlements`, {}, overrides);
|
||||||
}
|
}
|
||||||
|
|||||||
+230
@@ -7023,6 +7023,232 @@ body {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contract-admin-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 0.75fr) minmax(280px, 0.95fr) minmax(0, 1.45fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.contract-list-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.contract-entity-list,
|
||||||
|
.contract-contact-list,
|
||||||
|
.contract-issue-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.contract-entity-list {
|
||||||
|
max-height: 560px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
.contract-entity-list button,
|
||||||
|
.contract-contact-list button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
color: inherit;
|
||||||
|
background: #f8fbf8;
|
||||||
|
border: 1px solid #e0e7e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.contract-contact-list button {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-height: 66px;
|
||||||
|
}
|
||||||
|
.contract-entity-list button.selected,
|
||||||
|
.contract-entity-list button:hover,
|
||||||
|
.contract-contact-list button:hover {
|
||||||
|
background: #eef8f2;
|
||||||
|
border-color: #8fc7aa;
|
||||||
|
}
|
||||||
|
.contract-entity-list strong,
|
||||||
|
.contract-contact-list strong,
|
||||||
|
.contract-clearance-panel strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.contract-entity-list span,
|
||||||
|
.contract-entity-list small,
|
||||||
|
.contract-contact-list span,
|
||||||
|
.contract-contact-list small,
|
||||||
|
.contract-scope-strip span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.contract-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.contract-form.compact {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.contract-form-dense {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.contract-form.mini {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.contract-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.contract-form input,
|
||||||
|
.contract-form select,
|
||||||
|
.contract-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
outline: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.contract-form textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.contract-form .checkbox-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.contract-form .checkbox-line input[type="checkbox"] {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.contract-form-span,
|
||||||
|
.contract-form .approval-policy-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.contract-contact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.75fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.contract-bottom-grid {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.contract-clearance-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f7faf8;
|
||||||
|
border: 1px solid #e0e7e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.clearance-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.contract-scope-strip {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.contract-issue-list > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4eae5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.contract-issue-list > div.warn {
|
||||||
|
background: #fff8f2;
|
||||||
|
border-color: #f4c7a5;
|
||||||
|
}
|
||||||
|
.contract-issue-list > div.ok {
|
||||||
|
background: #effaf3;
|
||||||
|
border-color: #a8d7b5;
|
||||||
|
}
|
||||||
|
.contract-issue-list span,
|
||||||
|
.contract-issue-list small {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.contract-issue-list small {
|
||||||
|
grid-column: 2;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.contract-event-table {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.contract-event-head,
|
||||||
|
.contract-event-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(135px, 0.8fr) minmax(160px, 1fr) minmax(180px, 1.2fr) minmax(92px, 0.45fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 720px;
|
||||||
|
}
|
||||||
|
.contract-event-head {
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.contract-event-row {
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.contract-event-row strong,
|
||||||
|
.contract-event-row span,
|
||||||
|
.contract-event-row small {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.contract-event-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
.org-policy-layout {
|
.org-policy-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 360px minmax(0, 1fr);
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
@@ -7278,6 +7504,10 @@ body {
|
|||||||
.style-kit-layout,
|
.style-kit-layout,
|
||||||
.style-kit-form,
|
.style-kit-form,
|
||||||
.style-kit-contract-preview,
|
.style-kit-contract-preview,
|
||||||
|
.contract-admin-layout,
|
||||||
|
.contract-form.compact,
|
||||||
|
.contract-form-dense,
|
||||||
|
.contract-contact-grid,
|
||||||
.org-policy-layout,
|
.org-policy-layout,
|
||||||
.org-policy-form,
|
.org-policy-form,
|
||||||
.org-policy-check-grid,
|
.org-policy-check-grid,
|
||||||
|
|||||||
Reference in New Issue
Block a user