diff --git a/package.json b/package.json index a5ecc80..5fa9241 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "smoke:invitations": "node scripts/smoke-invitations.mjs", "smoke:commercial-ops": "node scripts/smoke-commercial-ops.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:plan-templates": "node scripts/smoke-plan-templates.mjs", "smoke:billing-ledger": "node scripts/smoke-billing-ledger.mjs", diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs index df88e0b..9254cf7 100644 --- a/scripts/smoke-all.mjs +++ b/scripts/smoke-all.mjs @@ -13,6 +13,7 @@ const scripts = [ "smoke:invitations", "smoke:commercial-ops", "smoke:commercial-approvals", + "smoke:contracts", "smoke:entitlements", "smoke:plan-templates", "smoke:billing-ledger", diff --git a/scripts/smoke-contracts.mjs b/scripts/smoke-contracts.mjs new file mode 100644 index 0000000..6103b8d --- /dev/null +++ b/scripts/smoke-contracts.mjs @@ -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]); + }); +} diff --git a/scripts/smoke-delivery-clearance.mjs b/scripts/smoke-delivery-clearance.mjs index 6e19b9c..18bb531 100644 --- a/scripts/smoke-delivery-clearance.mjs +++ b/scripts/smoke-delivery-clearance.mjs @@ -15,6 +15,8 @@ const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`; const manifestPath = `${sourceRoot}/manifest.json`; const batchId = `delivery-clearance-batch-${runId}`; const voiceId = `voice-clearance-${runId}`; +const customerId = `cust-clearance-${runId}`; +const contractId = `contract-clearance-${runId}`; const createdAssetIds = []; let deliveryId = ""; @@ -85,6 +87,11 @@ async function bind(headers, assetId, shotId, usageRole) { function cleanup() { 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_releases WHERE project_id = ?", [projectId]); 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 { cleanup(); const ownerBase = await login("producer@local.test"); @@ -236,6 +281,7 @@ try { notes: "清算烟测批准" }) }), "approve knowledge source"); + seedContractClearance(); 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"); diff --git a/scripts/smoke-delivery-portal.mjs b/scripts/smoke-delivery-portal.mjs index ca03760..6aab184 100644 --- a/scripts/smoke-delivery-portal.mjs +++ b/scripts/smoke-delivery-portal.mjs @@ -19,6 +19,8 @@ const deliveryDraftVersion = `portal-draft-${runId}`; const batchId = `smoke-portal-batch-${runId}`; const deliveryLabel = `smoke-portal-delivery-${runId}`; const draftDeliveryLabel = `smoke-portal-draft-${runId}`; +const customerId = `cust-portal-${runId}`; +const contractId = `contract-portal-${runId}`; const auditTargetIds = new Set(); const accessLinkIds = []; const releaseIds = []; @@ -89,6 +91,40 @@ const crossOrganizationHeaders = { const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]); 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 publishedReleaseId = ""; 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]); 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]); + seedContractClearance(); seeded = true; 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"); console.log(`delivery portal smoke passed: ${publishedReleaseId} / ${accessLinkIds.length} links`); } 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 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]); diff --git a/scripts/smoke-project-lifecycle.mjs b/scripts/smoke-project-lifecycle.mjs index a6aac24..32ec600 100644 --- a/scripts/smoke-project-lifecycle.mjs +++ b/scripts/smoke-project-lifecycle.mjs @@ -23,23 +23,36 @@ function expectOk(result, label) { 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 jobId = ""; try { - const login = expectOk(await request("/api/auth/login", { - method: "POST", - body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" }) - }), "owner login"); + const ownerLogin = await login("producer@local.test", "owner login"); const headers = { - authorization: `Bearer ${login.session.token}`, + authorization: `Bearer ${ownerLogin.session.token}`, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": "thunder-mouth" }; - const writerLogin = expectOk(await request("/api/auth/login", { - method: "POST", - body: JSON.stringify({ email: "writer@local.test", password: "Demo@123456" }) - }), "writer login"); + const writerLogin = await login("writer@local.test", "writer login"); const creation = expectOk(await request("/api/projects", { method: "POST", diff --git a/scripts/smoke-release-workflow.mjs b/scripts/smoke-release-workflow.mjs index aa21732..9a126ff 100644 --- a/scripts/smoke-release-workflow.mjs +++ b/scripts/smoke-release-workflow.mjs @@ -17,6 +17,8 @@ const version = `smoke-${runId}`; const sourcePath = `${sourceRoot}/clip.mp4`; const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`; const manifestPath = `${sourceRoot}/manifest.json`; +const customerId = `cust-release-${runId}`; +const contractId = `contract-release-${runId}`; async function request(path, options = {}) { const response = await fetch(`${api}${path}`, { @@ -67,6 +69,41 @@ const reviewerHeaders = { const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]); 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`; let channelId = ""; 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]); 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]); + seedContractClearance(); seeded = true; 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"); console.log(`release workflow smoke passed: ${releaseId}`); } 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 (seeded) { dbRun("DELETE FROM delivery_releases WHERE delivery_id = ?", [createdDeliveryId]); diff --git a/scripts/smoke-work-items.mjs b/scripts/smoke-work-items.mjs index 1b7edf8..eb2ad41 100644 --- a/scripts/smoke-work-items.mjs +++ b/scripts/smoke-work-items.mjs @@ -53,7 +53,7 @@ function assertContract(payload, label) { const tokens = []; try { 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"); tokens.push(ownerToken); diff --git a/server/contracts.mjs b/server/contracts.mjs new file mode 100644 index 0000000..f9f66af --- /dev/null +++ b/server/contracts.mjs @@ -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) }; +} diff --git a/server/db.mjs b/server/db.mjs index 6c44018..8ebb835 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -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_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_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_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)"); @@ -253,6 +260,10 @@ function seedRoles() { ["task:complete", "更新本人负责的协作任务状态"], ["style:read", "查看组织、工作区和项目风格生产标准"], ["style:manage", "管理风格标准、品牌规范和项目绑定"], + ["customer:read", "查看客户主档、联系人和项目归属"], + ["customer:manage", "管理客户主档、联系人和客户状态"], + ["contract:read", "查看合同授权、项目绑定和商业放行"], + ["contract:manage", "管理合同授权、项目绑定和商业放行"], ["script:edit", "编辑剧本和对白"], ["script:read", "查看剧本、分集和镜头"], ["asset:edit", "编辑角色、场景和道具锁"], @@ -289,10 +300,10 @@ function seedRoles() { org_admin: [ "organization:manage", "organization:members:invite", "workspace:create", "workspace: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" ], - 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"], 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"], @@ -395,6 +406,8 @@ function commercialEntitlementDefaults(billing = {}) { ["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project: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.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.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector: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() { const timestamp = now(); const membershipRows = [ @@ -1271,6 +1512,7 @@ withTransaction(() => { seedOrganizationEntitlements(); rollQuotaPeriods(); seedCommercialApprovals(); + seedCustomersAndContracts(); seedMembersAndProjects(); seedModels(); seedModelCatalog(); diff --git a/server/delivery-portal.mjs b/server/delivery-portal.mjs index 29ca4fa..0e994e1 100644 --- a/server/delivery-portal.mjs +++ b/server/delivery-portal.mjs @@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto"; import { basename, dirname, relative, resolve } from "node:path"; import { dbAll, dbGet, dbRun } from "./db.mjs"; import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission } from "./tenant.mjs"; +import { evaluateProjectContractClearance, recordContractLicenseEvent } from "./contracts.mjs"; const projectRoot = resolve(import.meta.dirname, ".."); const defaultExpiryDays = 7; @@ -287,6 +288,25 @@ export function createDeliveryAccessLink(context, releaseId, body = {}, options if (release.status !== "published" || !release.output_path) { 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); if (!releaseFile || basename(releaseFile.relative) !== "release.json") { throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束"); diff --git a/server/local-api.mjs b/server/local-api.mjs index 77c10e2..1165033 100644 --- a/server/local-api.mjs +++ b/server/local-api.mjs @@ -160,6 +160,7 @@ import { backupSummary, createDatabaseBackup } from "./backup.mjs"; import { systemReadiness } from "./readiness.mjs"; import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.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 { composeProject, listCompositions } from "./composition.mjs"; import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs"; @@ -1717,6 +1718,68 @@ createServer(async (req, res) => { 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$/); if (req.method === "GET" && organizationEntitlementsMatch) { const organizationId = decodeURIComponent(organizationEntitlementsMatch[1]); diff --git a/server/production.mjs b/server/production.mjs index ee10f1d..48931e5 100644 --- a/server/production.mjs +++ b/server/production.mjs @@ -3,6 +3,7 @@ import { addAudit, addUsage, hasPermission, httpError, requireEntitlement, requi import { inspectMediaForProject } from "./media-qa.mjs"; import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs"; import { dispatchNotificationEvent } from "./notifications.mjs"; +import { evaluateProjectContractClearance } from "./contracts.mjs"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -908,8 +909,14 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) { const knowledge = knowledgeClearanceSection(context); const qa = qaClearanceSection(context); const jobs = jobClearanceSection(context); - const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers]; - const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems]; + const contracts = evaluateProjectContractClearance(context, project.id, { + 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 checkedAt = now(); const baseCertificate = { @@ -931,6 +938,7 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) { approvedKnowledgeRequired: true, fixedVoiceEvidenceRequired: true, actualLastFrameRequired: true, + contractAuthorizationRequired: true, paidCloudPublishDisabled: true }, counts: { @@ -941,7 +949,8 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) { knowledgeMaterials: knowledge.materials.length, qaReviews: qa.reviews.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)}`; @@ -966,7 +975,8 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) { voices: voices.voices, knowledge, qa, - jobs + jobs, + contracts }, certificate }; diff --git a/server/schema.sql b/server/schema.sql index c9b89ed..1639528 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -561,6 +561,98 @@ CREATE TABLE IF NOT EXISTS commercial_approval_requests ( 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 -- platform records billing snapshots and lifecycle state, while an external -- 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_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_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_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); diff --git a/server/tenant.mjs b/server/tenant.mjs index d358ee2..5bb2e30 100644 --- a/server/tenant.mjs +++ b/server/tenant.mjs @@ -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.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.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.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); @@ -876,6 +878,8 @@ function defaultEntitlementRows(organizationId) { ["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.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.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector: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 WHEN 'tenant' THEN 0 WHEN 'production' THEN 1 - WHEN 'knowledge' THEN 2 - WHEN 'modelops' THEN 3 - WHEN 'delivery' THEN 4 - WHEN 'storage' THEN 5 - WHEN 'system' THEN 6 - WHEN 'feature' THEN 7 - ELSE 8 + WHEN 'commercial' THEN 2 + WHEN 'knowledge' THEN 3 + WHEN 'modelops' THEN 4 + WHEN 'delivery' THEN 5 + WHEN 'storage' THEN 6 + WHEN 'system' THEN 7 + WHEN 'feature' THEN 8 + ELSE 9 END, entitlement_key`, [organizationId] ).map((row) => entitlementPayload(row, organizationId)); diff --git a/src/App.jsx b/src/App.jsx index c1dd4d5..477cdb8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,6 +14,7 @@ import { ChevronRight, Download, FileJson2, + FileKey2, Film, FolderKanban, Gauge, @@ -54,6 +55,7 @@ import { AdminAuditPage, AdminModelsPage, AdminOverviewPage, + AdminContractsPage, AdminPolicyCenterPage, AdminStyleKitsPage, AdminQueuePage, @@ -154,6 +156,7 @@ const navigationGroups = [ { 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-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-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" }, { 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 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 canCreateProject = effectivePermissions.has("project:create"); const canCreateJob = effectivePermissions.has("job:create"); @@ -1895,6 +1898,7 @@ function App() { {(activeTab === "admin-organizations" || activeTab === "admin-members") && } {activeTab === "admin-policies" && } {activeTab === "admin-style-kits" && } + {activeTab === "admin-contracts" && } {activeTab === "admin-models" && } {activeTab === "admin-queue" && } {activeTab === "admin-usage" && } diff --git a/src/components/EnterprisePages.jsx b/src/components/EnterprisePages.jsx index 3f0bee3..46e519e 100644 --- a/src/components/EnterprisePages.jsx +++ b/src/components/EnterprisePages.jsx @@ -5,6 +5,7 @@ import { ArrowUpRight, Ban, Bell, + Building2, CheckCircle2, CircleDollarSign, Clock3, @@ -165,7 +166,15 @@ import { bindProjectStyleKit, createStyleKit, fetchStyleKits, - updateStyleKit + updateStyleKit, + bindContractProject, + createContract, + createCustomer, + fetchContractClearance, + fetchContracts, + updateContract, + updateCustomer, + upsertCustomerContact } from "../lib/api"; 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 ( +
+
} /> + {notice &&
{notice}
} +
+ item.status === "active").length} 个启用`} tone="ok" /> + + + +
+
+
+ { setSelectedCustomerId(""); setCustomerForm(defaultCustomerForm()); }}>新建 : null} /> +
+ {data.customers.map((customer) => )} + {!data.customers.length &&
暂无客户主档。
} +
+
+
+ { setSelectedContractId(""); setContractForm(defaultContractForm(selectedCustomerId)); }}>新建 : null} /> +
+ {data.contracts.map((contract) => )} + {!data.contracts.length &&
暂无合同授权。
} +
+
+
+ +
+ + + + + + + + +