Add customer contract clearance console

This commit is contained in:
xz
2026-09-01 14:38:42 +08:00
parent 1d4154dd20
commit 5849705d1e
19 changed files with 2431 additions and 25 deletions
+1
View File
@@ -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",
+1
View File
@@ -13,6 +13,7 @@ const scripts = [
"smoke:invitations",
"smoke:commercial-ops",
"smoke:commercial-approvals",
"smoke:contracts",
"smoke:entitlements",
"smoke:plan-templates",
"smoke:billing-ledger",
+197
View File
@@ -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]);
});
}
+46
View File
@@ -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");
+42
View File
@@ -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]);
+22 -9
View File
@@ -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",
+43
View File
@@ -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]);
+1 -1
View File
@@ -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);
+873
View File
@@ -0,0 +1,873 @@
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
import { addAudit, hasPermission, httpError, requireEntitlement } from "./tenant.mjs";
const now = () => new Date().toISOString();
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const CUSTOMER_TYPES = ["platform", "brand", "agency", "distributor", "internal"];
const CUSTOMER_STATUSES = ["prospect", "active", "paused", "archived"];
const CONTRACT_TYPES = ["production_license", "distribution", "work_for_hire", "revenue_share", "internal"];
const CONTRACT_STATUSES = ["draft", "submitted", "active", "expiring", "expired", "suspended", "terminated"];
const APPROVAL_STATUSES = ["draft", "legal_review", "approved", "blocked"];
const BINDING_USAGE_MODES = ["primary-license", "supplemental-rights", "delivery-only", "internal-review"];
const BINDING_STATUSES = ["active", "review", "blocked", "archived"];
const DEFAULT_LICENSE_SCOPE = {
channels: ["local-file", "private-delivery-portal"],
territories: ["CN"],
deliverables: ["vertical-video", "delivery-manifest"],
platforms: ["private-preview", "internal-review"],
exclusive: false,
commercialUse: true,
language: ["zh-CN"]
};
const DEFAULT_RIGHTS = {
originalStory: true,
derivativeProduction: true,
aiGeneratedAssetsAllowed: true,
voiceCloneAllowed: false,
fixedVoiceRequired: true,
singleFramePolicyRequired: true
};
function parseJson(value, fallback) {
try {
const parsed = JSON.parse(value);
return parsed === undefined || parsed === null ? fallback : parsed;
} catch {
return fallback;
}
}
function jsonObject(value, fallback = {}) {
if (value === undefined) return fallback;
if (typeof value === "string") {
const parsed = parseJson(value, fallback);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
}
return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
}
function uniqueList(value = []) {
const values = Array.isArray(value) ? value : String(value || "").split(/[,\n,、]/);
return [...new Set(values.map((item) => String(item || "").trim()).filter(Boolean))];
}
function normalizedChoice(value, allowed, fallback, code, message) {
const normalized = String(value || fallback || "").trim();
if (!allowed.includes(normalized)) throw httpError(400, code, message, { allowed });
return normalized;
}
function normalizeDate(value, fallback = null) {
if (value === undefined) return fallback;
const text = String(value || "").trim();
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) throw httpError(400, "date_invalid", "日期格式无效", { value: text });
return date.toISOString();
}
function hasAnyPermission(context, permissions = []) {
return Boolean(context?.systemAdmin || permissions.some((permission) => hasPermission(context, permission)));
}
function requireCustomerRead(context) {
if (hasAnyPermission(context, ["customer:read", "customer:manage", "contract:read", "contract:manage", "compliance:manage"])) return;
throw httpError(403, "permission_denied", "当前角色没有客户/合同查看权限");
}
function requireCustomerManage(context) {
if (hasAnyPermission(context, ["customer:manage"])) return;
throw httpError(403, "permission_denied", "当前角色没有客户主档管理权限");
}
function requireContractRead(context, options = {}) {
const permissions = ["contract:read", "contract:manage", "compliance:manage"];
if (options.allowDeliveryGate) permissions.push("delivery:view", "delivery:approve");
if (hasAnyPermission(context, permissions)) return;
throw httpError(403, "permission_denied", "当前角色没有合同授权查看权限");
}
function requireContractManage(context) {
if (hasAnyPermission(context, ["contract:manage"])) return;
throw httpError(403, "permission_denied", "当前角色没有合同授权管理权限");
}
function assertOrganizationScope(context, organizationId) {
if (context?.organization?.id === organizationId) return;
throw httpError(403, "organization_forbidden", "不能访问其他组织的客户合同数据", { organizationId });
}
function customerPayload(row, contacts = []) {
return {
id: row.id,
organizationId: row.organization_id,
name: row.name,
legalName: row.legal_name || "",
code: row.code,
customerType: row.customer_type,
status: row.status,
industry: row.industry || "",
region: row.region || "",
billingEmail: row.billing_email || "",
taxId: row.tax_id || "",
notes: row.notes || "",
tags: parseJson(row.tags_json, []),
metadata: parseJson(row.metadata_json, {}),
contactCount: Number(row.contact_count || contacts.length || 0),
contractCount: Number(row.contract_count || 0),
activeContractCount: Number(row.active_contract_count || 0),
contacts,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function contactPayload(row) {
return {
id: row.id,
organizationId: row.organization_id,
customerId: row.customer_id,
name: row.name,
role: row.role || "",
email: row.email || "",
phone: row.phone || "",
isPrimary: Boolean(row.is_primary),
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function bindingPayload(row) {
return {
id: row.id,
organizationId: row.organization_id,
workspaceId: row.workspace_id,
workspaceName: row.workspace_name || "",
projectId: row.project_id,
projectName: row.project_name || row.project_id,
contractId: row.contract_id,
customerId: row.customer_id,
usageMode: row.usage_mode,
status: row.status,
notes: row.notes || "",
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function contractPayload(row, bindings = []) {
return {
id: row.id,
organizationId: row.organization_id,
customerId: row.customer_id,
customerName: row.customer_name || "",
customerCode: row.customer_code || "",
customerStatus: row.customer_status || "",
contractNumber: row.contract_number,
title: row.title,
contractType: row.contract_type,
status: row.status,
approvalStatus: row.approval_status,
effectiveAt: row.effective_at || "",
expiresAt: row.expires_at || "",
signedAt: row.signed_at || "",
currency: row.currency,
amount: Number(row.amount || 0),
licenseScope: parseJson(row.license_scope_json, {}),
rights: parseJson(row.rights_json, {}),
deliveryTerms: parseJson(row.delivery_terms_json, {}),
evidence: parseJson(row.evidence_json, {}),
risk: parseJson(row.risk_json, {}),
approvalNote: row.approval_note || "",
bindingCount: Number(row.binding_count || bindings.length || 0),
activeBindingCount: Number(row.active_binding_count || bindings.filter((binding) => binding.status === "active").length || 0),
bindings,
createdBy: row.created_by,
updatedBy: row.updated_by || "",
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function eventPayload(row) {
return {
id: row.id,
organizationId: row.organization_id,
contractId: row.contract_id || "",
contractNumber: row.contract_number || "",
customerId: row.customer_id || "",
customerName: row.customer_name || "",
workspaceId: row.workspace_id || "",
projectId: row.project_id || "",
projectName: row.project_name || "",
eventType: row.event_type,
status: row.status,
actorUserId: row.actor_user_id || "",
actorName: row.actor_name || "",
metadata: parseJson(row.metadata_json, {}),
createdAt: row.created_at
};
}
function normalizeCustomerBody(body = {}, current = null) {
const name = String(body.name ?? current?.name ?? "").trim().slice(0, 160);
if (name.length < 2) throw httpError(400, "customer_name_required", "客户名称至少需要 2 个字符");
const code = String(body.code ?? current?.code ?? `CUST-${Date.now()}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toUpperCase()
.slice(0, 64);
if (code.length < 2) throw httpError(400, "customer_code_required", "客户编码至少需要 2 个字符");
return {
name,
code,
legalName: String(body.legalName ?? body.legal_name ?? current?.legal_name ?? "").trim().slice(0, 200),
customerType: normalizedChoice(body.customerType ?? body.customer_type ?? current?.customer_type, CUSTOMER_TYPES, "platform", "customer_type_invalid", "客户类型无效"),
status: normalizedChoice(body.status ?? current?.status, CUSTOMER_STATUSES, "active", "customer_status_invalid", "客户状态无效"),
industry: String(body.industry ?? current?.industry ?? "").trim().slice(0, 120),
region: String(body.region ?? current?.region ?? "").trim().slice(0, 80),
billingEmail: String(body.billingEmail ?? body.billing_email ?? current?.billing_email ?? "").trim().slice(0, 240),
taxId: String(body.taxId ?? body.tax_id ?? current?.tax_id ?? "").trim().slice(0, 80),
notes: String(body.notes ?? current?.notes ?? "").trim().slice(0, 2000),
tags: uniqueList(body.tags ?? parseJson(current?.tags_json, [])),
metadata: jsonObject(body.metadata ?? body.metadata_json, parseJson(current?.metadata_json, {}))
};
}
function normalizeContactBody(body = {}, current = null) {
const name = String(body.name ?? current?.name ?? "").trim().slice(0, 120);
if (name.length < 2) throw httpError(400, "contact_name_required", "联系人名称至少需要 2 个字符");
const status = normalizedChoice(body.status ?? current?.status, ["active", "inactive"], "active", "contact_status_invalid", "联系人状态无效");
return {
name,
status,
role: String(body.role ?? current?.role ?? "").trim().slice(0, 120),
email: String(body.email ?? current?.email ?? "").trim().slice(0, 240),
phone: String(body.phone ?? current?.phone ?? "").trim().slice(0, 80),
isPrimary: Boolean(body.isPrimary ?? body.is_primary ?? current?.is_primary ?? false)
};
}
function normalizeLicenseScope(value, current = null) {
const source = jsonObject(value, current ? parseJson(current.license_scope_json, DEFAULT_LICENSE_SCOPE) : DEFAULT_LICENSE_SCOPE);
return {
...DEFAULT_LICENSE_SCOPE,
...source,
channels: uniqueList(source.channels || DEFAULT_LICENSE_SCOPE.channels),
territories: uniqueList(source.territories || DEFAULT_LICENSE_SCOPE.territories),
deliverables: uniqueList(source.deliverables || DEFAULT_LICENSE_SCOPE.deliverables),
platforms: uniqueList(source.platforms || DEFAULT_LICENSE_SCOPE.platforms),
language: uniqueList(source.language || DEFAULT_LICENSE_SCOPE.language),
exclusive: Boolean(source.exclusive),
commercialUse: source.commercialUse !== false
};
}
function normalizeRights(value, current = null) {
return {
...DEFAULT_RIGHTS,
...jsonObject(value, current ? parseJson(current.rights_json, DEFAULT_RIGHTS) : DEFAULT_RIGHTS)
};
}
function normalizeContractBody(body = {}, current = null) {
const title = String(body.title ?? current?.title ?? "").trim().slice(0, 180);
if (title.length < 2) throw httpError(400, "contract_title_required", "合同标题至少需要 2 个字符");
const contractNumber = String(body.contractNumber ?? body.contract_number ?? current?.contract_number ?? `CTR-${Date.now()}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toUpperCase()
.slice(0, 80);
if (contractNumber.length < 2) throw httpError(400, "contract_number_required", "合同编号至少需要 2 个字符");
const amount = Number(body.amount ?? current?.amount ?? 0);
if (!Number.isFinite(amount) || amount < 0) throw httpError(400, "contract_amount_invalid", "合同金额必须是大于或等于 0 的数字");
return {
customerId: String(body.customerId ?? body.customer_id ?? current?.customer_id ?? "").trim(),
contractNumber,
title,
contractType: normalizedChoice(body.contractType ?? body.contract_type ?? current?.contract_type, CONTRACT_TYPES, "production_license", "contract_type_invalid", "合同类型无效"),
status: normalizedChoice(body.status ?? current?.status, CONTRACT_STATUSES, "draft", "contract_status_invalid", "合同状态无效"),
approvalStatus: normalizedChoice(body.approvalStatus ?? body.approval_status ?? current?.approval_status, APPROVAL_STATUSES, "draft", "contract_approval_status_invalid", "合同审批状态无效"),
effectiveAt: normalizeDate(body.effectiveAt ?? body.effective_at, current?.effective_at || null),
expiresAt: normalizeDate(body.expiresAt ?? body.expires_at, current?.expires_at || null),
signedAt: normalizeDate(body.signedAt ?? body.signed_at, current?.signed_at || null),
currency: String(body.currency ?? current?.currency ?? "CNY").trim().slice(0, 12) || "CNY",
amount,
licenseScope: normalizeLicenseScope(body.licenseScope ?? body.license_scope_json, current),
rights: normalizeRights(body.rights ?? body.rights_json, current),
deliveryTerms: jsonObject(body.deliveryTerms ?? body.delivery_terms_json, current ? parseJson(current.delivery_terms_json, {}) : {}),
evidence: jsonObject(body.evidence ?? body.evidence_json, current ? parseJson(current.evidence_json, {}) : {}),
risk: jsonObject(body.risk ?? body.risk_json, current ? parseJson(current.risk_json, {}) : {}),
approvalNote: String(body.approvalNote ?? body.approval_note ?? current?.approval_note ?? "").trim().slice(0, 2000)
};
}
function normalizeBindingBody(body = {}) {
return {
projectId: String(body.projectId ?? body.project_id ?? "").trim(),
usageMode: normalizedChoice(body.usageMode ?? body.usage_mode, BINDING_USAGE_MODES, "primary-license", "contract_usage_mode_invalid", "合同项目用途无效"),
status: normalizedChoice(body.status, BINDING_STATUSES, "active", "contract_binding_status_invalid", "合同绑定状态无效"),
notes: String(body.notes || "").trim().slice(0, 1000)
};
}
function ensureCustomer(organizationId, customerId) {
const row = dbGet("SELECT * FROM customers WHERE organization_id = ? AND id = ?", [organizationId, customerId]);
if (!row) throw httpError(404, "customer_not_found", "客户不存在或不属于当前组织", { customerId });
return row;
}
function ensureContract(organizationId, contractId) {
const row = dbGet(
`SELECT pc.*, c.name AS customer_name, c.code AS customer_code, c.status AS customer_status
FROM production_contracts pc
JOIN customers c ON c.id = pc.customer_id
WHERE pc.organization_id = ? AND pc.id = ?`,
[organizationId, contractId]
);
if (!row) throw httpError(404, "contract_not_found", "合同不存在或不属于当前组织", { contractId });
return row;
}
function accessibleProjectRows(context, organizationId) {
if (context?.organization?.id !== organizationId) return [];
if (context.systemAdmin || context.orgElevated) {
return dbAll(
`SELECT p.*, w.name AS workspace_name, w.id AS workspace_id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
WHERE w.organization_id = ?
ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, w.name, p.updated_at DESC`,
[organizationId]
);
}
return dbAll(
`SELECT DISTINCT p.*, w.name AS workspace_name, w.id AS workspace_id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ? AND wm.status = 'active'
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ? AND pm.status = 'active'
WHERE w.organization_id = ?
AND (wm.access_mode IS NULL OR wm.access_mode = 'all' OR (wm.access_mode = 'project-only' AND pm.user_id IS NOT NULL))
ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, w.name, p.updated_at DESC`,
[context.user.id, context.user.id, organizationId]
);
}
function ensureProjectInOrganization(context, projectId) {
const project = dbGet(
`SELECT p.*, w.organization_id, w.name AS workspace_name, w.id AS workspace_id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id = ? AND w.organization_id = ?`,
[projectId, context.organization.id]
);
if (!project) throw httpError(404, "project_not_found", "项目不存在或不属于当前组织", { projectId });
const visible = accessibleProjectRows(context, context.organization.id).some((item) => item.id === project.id);
if (!visible) throw httpError(404, "project_not_found", "项目不存在或当前角色不可访问", { projectId });
return project;
}
function customerContacts(organizationId, customerId) {
return dbAll(
"SELECT * FROM customer_contacts WHERE organization_id = ? AND customer_id = ? ORDER BY is_primary DESC, status, updated_at DESC",
[organizationId, customerId]
).map(contactPayload);
}
function contractBindings(organizationId, contractId = "") {
const params = [organizationId];
const contractClause = contractId ? "AND cpb.contract_id = ?" : "";
if (contractId) params.push(contractId);
return dbAll(
`SELECT cpb.*, p.name AS project_name, w.name AS workspace_name
FROM contract_project_bindings cpb
JOIN projects p ON p.id = cpb.project_id
JOIN workspaces w ON w.id = cpb.workspace_id
WHERE cpb.organization_id = ? ${contractClause}
ORDER BY CASE cpb.status WHEN 'active' THEN 0 WHEN 'review' THEN 1 ELSE 2 END, cpb.updated_at DESC`,
params
).map(bindingPayload);
}
function recentContractEvents(organizationId, limit = 40) {
return dbAll(
`SELECT e.*, pc.contract_number, c.name AS customer_name, p.name AS project_name, u.display_name AS actor_name
FROM contract_license_events e
LEFT JOIN production_contracts pc ON pc.id = e.contract_id
LEFT JOIN customers c ON c.id = e.customer_id
LEFT JOIN projects p ON p.id = e.project_id
LEFT JOIN users u ON u.id = e.actor_user_id
WHERE e.organization_id = ?
ORDER BY e.created_at DESC, e.rowid DESC
LIMIT ?`,
[organizationId, Number(limit || 40)]
).map(eventPayload);
}
export function listCustomers(context, organizationId, options = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerRead(context);
const includeArchived = options.includeArchived === true || options.includeArchived === "1";
const status = String(options.status || "").trim();
const search = String(options.search || options.q || "").trim().toLowerCase();
const clauses = ["c.organization_id = ?"];
const params = [organizationId];
if (!includeArchived) clauses.push("c.status != 'archived'");
if (status) {
clauses.push("c.status = ?");
params.push(status);
}
if (search) {
clauses.push("(lower(c.name) LIKE ? OR lower(c.code) LIKE ? OR lower(c.legal_name) LIKE ?)");
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
const rows = dbAll(
`SELECT c.*,
(SELECT COUNT(*) FROM customer_contacts cc WHERE cc.customer_id = c.id) AS contact_count,
(SELECT COUNT(*) FROM production_contracts pc WHERE pc.customer_id = c.id) AS contract_count,
(SELECT COUNT(*) FROM production_contracts pc WHERE pc.customer_id = c.id AND pc.status IN ('active', 'expiring')) AS active_contract_count
FROM customers c
WHERE ${clauses.join(" AND ")}
ORDER BY CASE c.status WHEN 'active' THEN 0 WHEN 'prospect' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END, c.updated_at DESC`,
params
);
const customers = rows.map((row) => customerPayload(row, customerContacts(organizationId, row.id)));
return {
customers,
summary: {
total: customers.length,
active: customers.filter((item) => item.status === "active").length,
prospects: customers.filter((item) => item.status === "prospect").length,
archived: customers.filter((item) => item.status === "archived").length
}
};
}
export function createCustomer(context, organizationId, body = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerManage(context);
requireEntitlement(context, "limit.customers", 1);
const customer = normalizeCustomerBody(body);
const exists = dbGet("SELECT id FROM customers WHERE organization_id = ? AND code = ?", [organizationId, customer.code]);
if (exists) throw httpError(409, "customer_code_exists", "客户编码已存在", { code: customer.code });
const timestamp = now();
const id = makeId("customer");
withTransaction(() => {
dbRun(
`INSERT INTO customers(
id, organization_id, name, legal_name, code, customer_type, status, industry, region,
billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, organizationId, customer.name, customer.legalName, customer.code, customer.customerType, customer.status, customer.industry, customer.region, customer.billingEmail, customer.taxId, customer.notes, JSON.stringify(customer.tags), JSON.stringify(customer.metadata), context.user.id, timestamp, timestamp]
);
addAudit({ context, action: "customer.created", targetType: "customer", targetId: id, metadata: { code: customer.code, status: customer.status } });
});
const payload = customerPayload(ensureCustomer(organizationId, id), customerContacts(organizationId, id));
return { customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
}
export function updateCustomer(context, organizationId, customerId, body = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerManage(context);
const current = ensureCustomer(organizationId, customerId);
const customer = normalizeCustomerBody(body, current);
const collision = dbGet("SELECT id FROM customers WHERE organization_id = ? AND code = ? AND id != ?", [organizationId, customer.code, customerId]);
if (collision) throw httpError(409, "customer_code_exists", "客户编码已存在", { code: customer.code });
const timestamp = now();
dbRun(
`UPDATE customers
SET name = ?, legal_name = ?, code = ?, customer_type = ?, status = ?, industry = ?, region = ?,
billing_email = ?, tax_id = ?, notes = ?, tags_json = ?, metadata_json = ?, updated_at = ?
WHERE organization_id = ? AND id = ?`,
[customer.name, customer.legalName, customer.code, customer.customerType, customer.status, customer.industry, customer.region, customer.billingEmail, customer.taxId, customer.notes, JSON.stringify(customer.tags), JSON.stringify(customer.metadata), timestamp, organizationId, customerId]
);
addAudit({ context, action: "customer.updated", targetType: "customer", targetId: customerId, metadata: { code: customer.code, status: customer.status } });
const payload = customerPayload(ensureCustomer(organizationId, customerId), customerContacts(organizationId, customerId));
return { customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
}
export function upsertCustomerContact(context, organizationId, customerId, body = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerManage(context);
ensureCustomer(organizationId, customerId);
const contactId = String(body.id || "").trim();
const current = contactId ? dbGet("SELECT * FROM customer_contacts WHERE organization_id = ? AND customer_id = ? AND id = ?", [organizationId, customerId, contactId]) : null;
if (contactId && !current) throw httpError(404, "customer_contact_not_found", "联系人不存在或不属于当前客户", { contactId });
const contact = normalizeContactBody(body, current);
const timestamp = now();
const id = contactId || makeId("contact");
withTransaction(() => {
if (contact.isPrimary) {
dbRun("UPDATE customer_contacts SET is_primary = 0, updated_at = ? WHERE organization_id = ? AND customer_id = ?", [timestamp, organizationId, customerId]);
}
if (current) {
dbRun(
"UPDATE customer_contacts SET name = ?, role = ?, email = ?, phone = ?, is_primary = ?, status = ?, updated_at = ? WHERE id = ? AND organization_id = ? AND customer_id = ?",
[contact.name, contact.role, contact.email, contact.phone, contact.isPrimary ? 1 : 0, contact.status, timestamp, id, organizationId, customerId]
);
} else {
dbRun(
"INSERT INTO customer_contacts(id, organization_id, customer_id, name, role, email, phone, is_primary, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[id, organizationId, customerId, contact.name, contact.role, contact.email, contact.phone, contact.isPrimary ? 1 : 0, contact.status, timestamp, timestamp]
);
}
addAudit({ context, action: current ? "customer.contact.updated" : "customer.contact.created", targetType: "customer_contact", targetId: id, metadata: { customerId, isPrimary: contact.isPrimary } });
});
const payload = customerPayload(ensureCustomer(organizationId, customerId), customerContacts(organizationId, customerId));
return { contact: payload.contacts.find((item) => item.id === id), customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
}
export function listContracts(context, organizationId, options = {}) {
assertOrganizationScope(context, organizationId);
requireContractRead(context);
const includeArchived = options.includeArchived === true || options.includeArchived === "1";
const status = String(options.status || "").trim();
const customerId = String(options.customerId || options.customer_id || "").trim();
const projectId = String(options.projectId || options.project_id || "").trim();
const clauses = ["pc.organization_id = ?"];
const params = [organizationId];
if (!includeArchived) clauses.push("pc.status != 'terminated'");
if (status) {
clauses.push("pc.status = ?");
params.push(status);
}
if (customerId) {
clauses.push("pc.customer_id = ?");
params.push(customerId);
}
if (projectId) {
clauses.push("EXISTS (SELECT 1 FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id AND cpb.project_id = ? AND cpb.organization_id = pc.organization_id)");
params.push(projectId);
}
const rows = dbAll(
`SELECT pc.*, c.name AS customer_name, c.code AS customer_code, c.status AS customer_status,
(SELECT COUNT(*) FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id) AS binding_count,
(SELECT COUNT(*) FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id AND cpb.status = 'active') AS active_binding_count
FROM production_contracts pc
JOIN customers c ON c.id = pc.customer_id
WHERE ${clauses.join(" AND ")}
ORDER BY CASE pc.status WHEN 'active' THEN 0 WHEN 'expiring' THEN 1 WHEN 'submitted' THEN 2 WHEN 'draft' THEN 3 ELSE 4 END, pc.updated_at DESC`,
params
);
const allBindings = contractBindings(organizationId);
const contracts = rows.map((row) => contractPayload(row, allBindings.filter((binding) => binding.contractId === row.id)));
const customers = listCustomers(context, organizationId, { includeArchived: true }).customers;
const projects = accessibleProjectRows(context, organizationId).map((project) => ({
id: project.id,
name: project.name,
status: project.status,
workspaceId: project.workspace_id,
workspaceName: project.workspace_name,
readiness: Number(project.readiness || 0),
risk: project.risk || ""
}));
const clearance = projectId ? evaluateProjectContractClearance(context, projectId, { channelKind: options.channelKind || options.channel_kind || "" }) : null;
return {
contracts,
customers,
projects,
events: recentContractEvents(organizationId, Number(options.limit || 40)),
clearance,
summary: {
total: contracts.length,
active: contracts.filter((item) => item.status === "active").length,
approved: contracts.filter((item) => item.approvalStatus === "approved").length,
boundProjects: new Set(allBindings.filter((binding) => binding.status === "active").map((binding) => binding.projectId)).size,
blockers: clearance?.blockers?.length || 0
}
};
}
export function createContract(context, organizationId, body = {}) {
assertOrganizationScope(context, organizationId);
requireContractManage(context);
requireEntitlement(context, "limit.contracts", 1);
const contract = normalizeContractBody(body);
const customer = ensureCustomer(organizationId, contract.customerId);
const exists = dbGet("SELECT id FROM production_contracts WHERE organization_id = ? AND contract_number = ?", [organizationId, contract.contractNumber]);
if (exists) throw httpError(409, "contract_number_exists", "合同编号已存在", { contractNumber: contract.contractNumber });
const timestamp = now();
const id = makeId("contract");
withTransaction(() => {
dbRun(
`INSERT INTO production_contracts(
id, organization_id, customer_id, contract_number, title, contract_type, status,
approval_status, effective_at, expires_at, signed_at, currency, amount,
license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json,
approval_note, created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, organizationId, customer.id, contract.contractNumber, contract.title, contract.contractType, contract.status, contract.approvalStatus, contract.effectiveAt, contract.expiresAt, contract.signedAt, contract.currency, contract.amount, JSON.stringify(contract.licenseScope), JSON.stringify(contract.rights), JSON.stringify(contract.deliveryTerms), JSON.stringify(contract.evidence), JSON.stringify(contract.risk), contract.approvalNote, context.user.id, context.user.id, timestamp, timestamp]
);
writeContractEvent(context, {
contractId: id,
customerId: customer.id,
eventType: "contract.created",
metadata: { contractNumber: contract.contractNumber, status: contract.status, approvalStatus: contract.approvalStatus }
});
addAudit({ context, action: "contract.created", targetType: "production_contract", targetId: id, metadata: { customerId: customer.id, contractNumber: contract.contractNumber } });
});
return { contract: contractPayload(ensureContract(organizationId, id), []), ...listContracts(context, organizationId, { includeArchived: true }) };
}
export function updateContract(context, organizationId, contractId, body = {}) {
assertOrganizationScope(context, organizationId);
requireContractManage(context);
const current = ensureContract(organizationId, contractId);
const contract = normalizeContractBody(body, current);
const customer = ensureCustomer(organizationId, contract.customerId);
const collision = dbGet("SELECT id FROM production_contracts WHERE organization_id = ? AND contract_number = ? AND id != ?", [organizationId, contract.contractNumber, contractId]);
if (collision) throw httpError(409, "contract_number_exists", "合同编号已存在", { contractNumber: contract.contractNumber });
const timestamp = now();
withTransaction(() => {
dbRun(
`UPDATE production_contracts
SET customer_id = ?, contract_number = ?, title = ?, contract_type = ?, status = ?, approval_status = ?,
effective_at = ?, expires_at = ?, signed_at = ?, currency = ?, amount = ?,
license_scope_json = ?, rights_json = ?, delivery_terms_json = ?, evidence_json = ?, risk_json = ?,
approval_note = ?, updated_by = ?, updated_at = ?
WHERE organization_id = ? AND id = ?`,
[customer.id, contract.contractNumber, contract.title, contract.contractType, contract.status, contract.approvalStatus, contract.effectiveAt, contract.expiresAt, contract.signedAt, contract.currency, contract.amount, JSON.stringify(contract.licenseScope), JSON.stringify(contract.rights), JSON.stringify(contract.deliveryTerms), JSON.stringify(contract.evidence), JSON.stringify(contract.risk), contract.approvalNote, context.user.id, timestamp, organizationId, contractId]
);
dbRun("UPDATE contract_project_bindings SET customer_id = ?, updated_at = ? WHERE organization_id = ? AND contract_id = ?", [customer.id, timestamp, organizationId, contractId]);
writeContractEvent(context, {
contractId,
customerId: customer.id,
eventType: "contract.updated",
metadata: { previous: { status: current.status, approvalStatus: current.approval_status }, next: { status: contract.status, approvalStatus: contract.approvalStatus } }
});
addAudit({ context, action: "contract.updated", targetType: "production_contract", targetId: contractId, metadata: { customerId: customer.id, contractNumber: contract.contractNumber } });
});
return { contract: contractPayload(ensureContract(organizationId, contractId), contractBindings(organizationId, contractId)), ...listContracts(context, organizationId, { includeArchived: true }) };
}
export function bindContractProject(context, organizationId, contractId, body = {}) {
assertOrganizationScope(context, organizationId);
requireContractManage(context);
const contract = ensureContract(organizationId, contractId);
const binding = normalizeBindingBody(body);
if (!binding.projectId) throw httpError(400, "project_required", "合同绑定必须选择项目");
const project = ensureProjectInOrganization(context, binding.projectId);
const timestamp = now();
let bindingId = "";
withTransaction(() => {
const current = dbGet("SELECT * FROM contract_project_bindings WHERE organization_id = ? AND project_id = ? AND contract_id = ?", [organizationId, project.id, contract.id]);
if (current) {
bindingId = current.id;
dbRun(
"UPDATE contract_project_bindings SET customer_id = ?, usage_mode = ?, status = ?, notes = ?, updated_at = ? WHERE id = ?",
[contract.customer_id, binding.usageMode, binding.status, binding.notes, timestamp, current.id]
);
} else {
bindingId = makeId("contract-binding");
dbRun(
"INSERT INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[bindingId, organizationId, project.workspace_id, project.id, contract.id, contract.customer_id, binding.usageMode, binding.status, binding.notes, context.user.id, timestamp, timestamp]
);
}
writeContractEvent(context, {
contractId: contract.id,
customerId: contract.customer_id,
workspaceId: project.workspace_id,
projectId: project.id,
eventType: current ? "contract.project_binding.updated" : "contract.project_binding.created",
metadata: { bindingId, usageMode: binding.usageMode, status: binding.status }
});
addAudit({ context, action: current ? "contract.project_binding.updated" : "contract.project_binding.created", targetType: "contract_project_binding", targetId: bindingId, metadata: { contractId, projectId: project.id, status: binding.status } });
});
return {
binding: contractBindings(organizationId, contractId).find((item) => item.id === bindingId),
clearance: evaluateProjectContractClearance(context, project.id),
...listContracts(context, organizationId, { includeArchived: true, projectId: project.id })
};
}
function listAllows(list, value) {
if (!value) return true;
const normalized = String(value || "").trim().toLowerCase();
const allowed = uniqueList(list).map((item) => item.toLowerCase());
return allowed.includes(normalized) || allowed.includes("all") || allowed.includes("global") || allowed.includes("*");
}
function hasEvidence(evidence = {}) {
return Boolean(evidence.contractRef || evidence.rightsEvidenceRef || evidence.signedCopyPath || evidence.signedCopySha256);
}
function contractIssueSet(contract, binding, options = {}) {
const blockers = [];
const reviewItems = [];
const licenseScope = contract.licenseScope || {};
const rights = contract.rights || {};
const evidence = contract.evidence || {};
const checkedAt = Date.now();
const channelKind = String(options.channelKind || options.channel_kind || options.channel?.kind || "").trim();
const territory = String(options.territory || "CN").trim();
const requiredDeliverables = uniqueList(options.deliverables || ["vertical-video"]);
if (binding.status !== "active") blockers.push({ type: "contract", code: "binding_not_active", status: binding.status, reason: "项目合同绑定未启用", contractId: contract.id, bindingId: binding.id });
if (!["active", "expiring"].includes(contract.status)) blockers.push({ type: "contract", code: "contract_not_active", status: contract.status, reason: "合同不是可交付状态", contractId: contract.id });
if (contract.approvalStatus !== "approved") blockers.push({ type: "contract", code: "contract_not_approved", status: contract.approvalStatus, reason: "合同尚未通过法务/商务审批", contractId: contract.id });
if (contract.customerStatus && contract.customerStatus !== "active") blockers.push({ type: "contract", code: "customer_not_active", status: contract.customerStatus, reason: "客户主档未处于启用状态", contractId: contract.id, customerId: contract.customerId });
if (contract.effectiveAt && new Date(contract.effectiveAt).getTime() > checkedAt) blockers.push({ type: "contract", code: "contract_not_effective", reason: "合同尚未到生效时间", contractId: contract.id, effectiveAt: contract.effectiveAt });
if (contract.expiresAt && new Date(contract.expiresAt).getTime() < checkedAt) blockers.push({ type: "contract", code: "contract_expired", reason: "合同已过期", contractId: contract.id, expiresAt: contract.expiresAt });
if (contract.expiresAt) {
const daysLeft = Math.ceil((new Date(contract.expiresAt).getTime() - checkedAt) / 86400000);
if (daysLeft >= 0 && daysLeft <= 30) reviewItems.push({ type: "contract", code: "contract_expiring_soon", reason: `合同将在 ${daysLeft} 天内到期`, contractId: contract.id, expiresAt: contract.expiresAt });
}
if (!hasEvidence(evidence)) blockers.push({ type: "contract", code: "contract_evidence_missing", reason: "合同缺少签署/原创/授权证据引用", contractId: contract.id });
if (channelKind && !listAllows(licenseScope.channels, channelKind)) blockers.push({ type: "contract", code: "channel_not_licensed", reason: "交付渠道不在合同授权范围内", contractId: contract.id, channelKind, allowed: licenseScope.channels || [] });
if (territory && !listAllows(licenseScope.territories, territory)) blockers.push({ type: "contract", code: "territory_not_licensed", reason: "交付地域不在合同授权范围内", contractId: contract.id, territory, allowed: licenseScope.territories || [] });
for (const deliverable of requiredDeliverables) {
if (!listAllows(licenseScope.deliverables, deliverable)) blockers.push({ type: "contract", code: "deliverable_not_licensed", reason: "交付物类型不在合同授权范围内", contractId: contract.id, deliverable, allowed: licenseScope.deliverables || [] });
}
if (licenseScope.commercialUse !== true) blockers.push({ type: "contract", code: "commercial_use_not_allowed", reason: "合同未明确允许商业使用", contractId: contract.id });
if (rights.originalStory !== true) blockers.push({ type: "contract", code: "original_story_missing", reason: "合同未确认原创故事权利", contractId: contract.id });
if (rights.derivativeProduction !== true) blockers.push({ type: "contract", code: "derivative_right_missing", reason: "合同未确认改编/制作授权", contractId: contract.id });
if (rights.aiGeneratedAssetsAllowed !== true) blockers.push({ type: "contract", code: "ai_assets_not_allowed", reason: "合同未允许 AI 生成资产用于制作", contractId: contract.id });
if (rights.fixedVoiceRequired !== true) reviewItems.push({ type: "contract", code: "fixed_voice_not_required_by_contract", reason: "合同未显式要求固定声线,建议补充以匹配平台策略", contractId: contract.id });
if (rights.singleFramePolicyRequired !== true) blockers.push({ type: "contract", code: "single_frame_policy_not_required", reason: "合同未纳入一图一完整单画面生产约束", contractId: contract.id });
if (channelKind === "private-delivery-portal" && contract.deliveryTerms?.customerPortalAllowed === false) blockers.push({ type: "contract", code: "portal_not_allowed", reason: "合同交付条款未允许客户门户访问", contractId: contract.id });
return { blockers, reviewItems };
}
export function evaluateProjectContractClearance(context, projectId = "", options = {}) {
requireContractRead(context, { allowDeliveryGate: true });
const targetProjectId = String(projectId || context.project?.id || "").trim();
if (!targetProjectId) throw httpError(400, "project_required", "合同授权清算必须绑定项目");
const project = ensureProjectInOrganization(context, targetProjectId);
const rows = dbAll(
`SELECT cpb.*, p.name AS project_name, w.name AS workspace_name,
pc.contract_number, pc.title, pc.contract_type, pc.status AS contract_status, pc.approval_status,
pc.effective_at, pc.expires_at, pc.signed_at, pc.currency, pc.amount,
pc.license_scope_json, pc.rights_json, pc.delivery_terms_json, pc.evidence_json, pc.risk_json,
c.name AS customer_name, c.code AS customer_code, c.status AS customer_status
FROM contract_project_bindings cpb
JOIN projects p ON p.id = cpb.project_id
JOIN workspaces w ON w.id = cpb.workspace_id
JOIN production_contracts pc ON pc.id = cpb.contract_id
JOIN customers c ON c.id = cpb.customer_id
WHERE cpb.organization_id = ? AND cpb.workspace_id = ? AND cpb.project_id = ?
ORDER BY CASE cpb.status WHEN 'active' THEN 0 WHEN 'review' THEN 1 ELSE 2 END, cpb.updated_at DESC`,
[context.organization.id, project.workspace_id, project.id]
);
const blockers = [];
const reviewItems = [];
const bindings = rows.map((row) => {
const binding = bindingPayload(row);
const contract = {
id: row.contract_id,
organizationId: row.organization_id,
customerId: row.customer_id,
customerName: row.customer_name || "",
customerCode: row.customer_code || "",
customerStatus: row.customer_status || "",
contractNumber: row.contract_number,
title: row.title,
contractType: row.contract_type,
status: row.contract_status,
approvalStatus: row.approval_status,
effectiveAt: row.effective_at || "",
expiresAt: row.expires_at || "",
signedAt: row.signed_at || "",
currency: row.currency,
amount: Number(row.amount || 0),
licenseScope: parseJson(row.license_scope_json, {}),
rights: parseJson(row.rights_json, {}),
deliveryTerms: parseJson(row.delivery_terms_json, {}),
evidence: parseJson(row.evidence_json, {}),
risk: parseJson(row.risk_json, {})
};
const issues = contractIssueSet(contract, binding, options);
blockers.push(...issues.blockers);
reviewItems.push(...issues.reviewItems);
return { ...binding, contract };
});
if (!bindings.some((binding) => binding.status === "active")) {
blockers.push({ type: "contract", code: "project_contract_missing", reason: "项目没有启用中的客户/合同授权绑定", projectId: project.id });
}
const status = blockers.length ? "blocked" : reviewItems.length ? "review" : "pass";
return {
schema: "ai-drama-platform.contract-clearance.v1",
checkedAt: now(),
status,
organizationId: context.organization.id,
workspaceId: project.workspace_id,
projectId: project.id,
projectName: project.name,
requested: {
channelKind: options.channelKind || options.channel_kind || options.channel?.kind || "",
territory: options.territory || "CN",
deliverables: uniqueList(options.deliverables || ["vertical-video"])
},
blockers,
reviewItems,
bindings,
counts: {
bindings: bindings.length,
activeBindings: bindings.filter((binding) => binding.status === "active").length,
blockers: blockers.length,
reviewItems: reviewItems.length
}
};
}
function writeContractEvent(context, body = {}) {
const timestamp = now();
const id = body.id || makeId("contract-event");
dbRun(
"INSERT INTO contract_license_events(id, organization_id, contract_id, customer_id, workspace_id, project_id, event_type, status, actor_user_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
id,
context.organization.id,
body.contractId || null,
body.customerId || null,
body.workspaceId || null,
body.projectId || null,
String(body.eventType || "contract.event").trim(),
String(body.status || "recorded").trim(),
context.user.id,
JSON.stringify(body.metadata || {}),
timestamp
]
);
return eventPayload(dbGet(
`SELECT e.*, pc.contract_number, c.name AS customer_name, p.name AS project_name, u.display_name AS actor_name
FROM contract_license_events e
LEFT JOIN production_contracts pc ON pc.id = e.contract_id
LEFT JOIN customers c ON c.id = e.customer_id
LEFT JOIN projects p ON p.id = e.project_id
LEFT JOIN users u ON u.id = e.actor_user_id
WHERE e.id = ?`,
[id]
));
}
export function recordContractLicenseEvent(context, body = {}) {
requireContractRead(context);
const contractId = String(body.contractId || body.contract_id || "").trim();
const customerId = String(body.customerId || body.customer_id || "").trim();
const projectId = String(body.projectId || body.project_id || "").trim();
const contract = contractId ? ensureContract(context.organization.id, contractId) : null;
const customer = customerId ? ensureCustomer(context.organization.id, customerId) : contract ? ensureCustomer(context.organization.id, contract.customer_id) : null;
const project = projectId ? ensureProjectInOrganization(context, projectId) : null;
const event = writeContractEvent(context, {
contractId: contract?.id || null,
customerId: customer?.id || null,
workspaceId: project?.workspace_id || body.workspaceId || body.workspace_id || null,
projectId: project?.id || null,
eventType: body.eventType || body.event_type || "contract.clearance.checked",
status: body.status || "recorded",
metadata: body.metadata || {}
});
addAudit({ context, action: "contract.license_event.recorded", targetType: "contract_license_event", targetId: event.id, metadata: { contractId: event.contractId, projectId: event.projectId, eventType: event.eventType } });
return { event, events: recentContractEvents(context.organization.id) };
}
+244 -2
View File
@@ -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();
+20
View File
@@ -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", "发布产物路径不满足交付门户安全约束");
+63
View File
@@ -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]);
+14 -4
View File
@@ -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
};
+99
View File
@@ -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);
+12 -7
View File
@@ -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));
+5 -1
View File
@@ -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") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-policies" && <AdminPolicyCenterPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-contracts" && <AdminContractsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-usage" && <AdminUsagePage contextOverrides={contextOverrides} platformContext={platformContext} />}
+471 -1
View File
@@ -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 (
<div className="enterprise-page contract-page">
<AdminHeader eyebrow="ADMIN CONSOLE / COMMERCIAL RIGHTS" title="客户、合同与授权清算" description="把客户主档、合同范围、项目绑定和交付放行合在一处管理;发布与客户门户会复用同一套服务端清算规则。" action={<div className="header-actions"><StatusBadge status={clearance?.status || "review"} label={clearance?.status === "pass" ? "当前项目可交付" : clearance?.status === "blocked" ? "存在授权阻断" : "等待清算"} /><button className="subtle" onClick={() => load(selectedProjectId)} disabled={loading}><RefreshCw size={15} />刷新</button><button className="subtle" onClick={exportPack}><Download size={15} />导出包</button></div>} />
{notice && <div className={`inline-notice ${notice.includes("阻断") || notice.includes("不能") || notice.includes("没有") || notice.includes("不是") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
<div className="admin-kpi-grid compact-kpis">
<AdminKpi icon={Building2} label="客户主档" value={data.customers.length} detail={`${data.customers.filter((item) => item.status === "active").length} 个启用`} tone="ok" />
<AdminKpi icon={FileKey2} label="合同授权" value={data.contracts.length} detail={`${activeContracts.length} 份生效`} tone="ok" />
<AdminKpi icon={Link2} label="项目绑定" value={data.summary?.boundProjects || 0} detail="绑定后进入发布清算" tone="neutral" />
<AdminKpi icon={ShieldCheck} label="清算状态" value={clearance?.status || "未运行"} detail={`${clearance?.blockers?.length || 0} 阻断 / ${clearance?.reviewItems?.length || 0} 复核`} tone={clearance?.status === "blocked" ? "warn" : "ok"} />
</div>
<div className="contract-admin-layout">
<section className="studio-card contract-list-card">
<SectionBar title="客户主档" detail={loading ? "读取中…" : `${data.customers.length} 个客户`} action={canManage ? <button className="icon-text-button" onClick={() => { setSelectedCustomerId(""); setCustomerForm(defaultCustomerForm()); }}><Plus size={13} />新建</button> : null} />
<div className="contract-entity-list">
{data.customers.map((customer) => <button key={customer.id} type="button" className={selectedCustomerId === customer.id ? "selected" : ""} onClick={() => selectCustomer(customer)}><div><strong>{customer.name}</strong><span>{customer.code} · {customerTypeLabels[customer.customerType] || customer.customerType}</span><small>{customer.legalName || customer.notes || "未填写主体信息"}</small></div><StatusBadge status={contractStatusTone(customer.status)} label={customerStatusLabels[customer.status] || customer.status} /></button>)}
{!data.customers.length && <div className="empty-table">暂无客户主档。</div>}
</div>
</section>
<section className="studio-card contract-list-card">
<SectionBar title="合同授权" detail={`${data.contracts.length} 份可见合同`} action={canManage ? <button className="icon-text-button" onClick={() => { setSelectedContractId(""); setContractForm(defaultContractForm(selectedCustomerId)); }}><Plus size={13} />新建</button> : null} />
<div className="contract-entity-list">
{data.contracts.map((contract) => <button key={contract.id} type="button" className={selectedContractId === contract.id ? "selected" : ""} onClick={() => selectContract(contract)}><div><strong>{contract.title}</strong><span>{contract.contractNumber} · {contract.customerName}</span><small>{contract.bindings?.map((binding) => binding.projectName).join(" / ") || "未绑定项目"}</small></div><StatusBadge status={contractStatusTone(contract.status === "active" ? contract.approvalStatus : contract.status)} label={`${contractStatusLabels[contract.status] || contract.status} · ${contractApprovalLabels[contract.approvalStatus] || contract.approvalStatus}`} /></button>)}
{!data.contracts.length && <div className="empty-table">暂无合同授权。</div>}
</div>
</section>
<section className="studio-card wide-card contract-editor-card">
<SectionBar title={customerForm.id ? `客户:${selectedCustomer?.name || customerForm.name}` : "新建客户"} detail={canManage ? "保存后落库并审计" : "只读 · 需要 customer:manage"} />
<form className="contract-form compact" onSubmit={saveCustomer}>
<label>客户名称<input value={customerForm.name} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, name: event.target.value })} required /></label>
<label>客户编码<input value={customerForm.code} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, code: event.target.value })} required /></label>
<label>客户类型<select value={customerForm.customerType} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, customerType: event.target.value })}>{Object.entries(customerTypeLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label>状态<select value={customerForm.status} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, status: event.target.value })}>{Object.entries(customerStatusLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label>法定主体<input value={customerForm.legalName} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, legalName: event.target.value })} /></label>
<label>区域<input value={customerForm.region} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, region: event.target.value })} /></label>
<label>账单邮箱<input value={customerForm.billingEmail} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, billingEmail: event.target.value })} /></label>
<label>标签<input value={customerForm.tagsText} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, tagsText: event.target.value })} /></label>
<label className="contract-form-span">备注<textarea rows="3" value={customerForm.notes} disabled={!canManage} onChange={(event) => setCustomerForm({ ...customerForm, notes: event.target.value })} /></label>
<div className="form-actions contract-form-span"><button className="primary" type="submit" disabled={!canManage || busy === "customer"}><Save size={15} />保存客户</button></div>
</form>
<SectionBar title="联系人" detail={`${selectedCustomer?.contacts?.length || 0} 个联系人`} />
<div className="contract-contact-grid">
<div className="contract-contact-list">
{(selectedCustomer?.contacts || []).map((contact) => <button key={contact.id} type="button" onClick={() => setContactForm(contact)}><strong>{contact.name}</strong><span>{contact.role || "联系人"} · {contact.email || contact.phone || "未填联系方式"}</span>{contact.isPrimary && <small>主联系人</small>}</button>)}
{!selectedCustomer?.contacts?.length && <div className="empty-table">选择客户后可维护联系人。</div>}
</div>
<form className="contract-form mini" onSubmit={saveContact}>
<label>姓名<input value={contactForm.name} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, name: event.target.value })} required /></label>
<label>角色<input value={contactForm.role} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, role: event.target.value })} /></label>
<label>邮箱<input value={contactForm.email} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, email: event.target.value })} /></label>
<label className="checkbox-line"><input type="checkbox" checked={contactForm.isPrimary} disabled={!canManage || !selectedCustomerId} onChange={(event) => setContactForm({ ...contactForm, isPrimary: event.target.checked })} />主联系人</label>
<button className="subtle" type="submit" disabled={!canManage || !selectedCustomerId || busy === "contact"}><Save size={14} />保存联系人</button>
</form>
</div>
</section>
</div>
<div className="enterprise-grid enterprise-grid-main contract-bottom-grid">
<section className="studio-card wide-card">
<SectionBar title={contractForm.id ? `合同:${selectedContract?.contractNumber || contractForm.contractNumber}` : "新建合同"} detail={canManage ? "授权范围、权利条款和证据会参与放行" : "只读 · 需要 contract:manage"} />
<form className="contract-form contract-form-dense" onSubmit={saveContract}>
<label>合同标题<input value={contractForm.title} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, title: event.target.value })} required /></label>
<label>合同编号<input value={contractForm.contractNumber} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, contractNumber: event.target.value })} required /></label>
<label>客户<select value={contractForm.customerId} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, customerId: event.target.value })}>{data.customers.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}</select></label>
<label>类型<select value={contractForm.contractType} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, contractType: event.target.value })}>{Object.entries(contractTypeLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label>合同状态<select value={contractForm.status} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, status: event.target.value })}>{Object.entries(contractStatusLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label>审批状态<select value={contractForm.approvalStatus} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, approvalStatus: event.target.value })}>{Object.entries(contractApprovalLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label>生效日期<input type="date" value={contractForm.effectiveAt} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, effectiveAt: event.target.value })} /></label>
<label>到期日期<input type="date" value={contractForm.expiresAt} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, expiresAt: event.target.value })} /></label>
<label>签署日期<input type="date" value={contractForm.signedAt} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, signedAt: event.target.value })} /></label>
<label>金额<input type="number" min="0" value={contractForm.amount} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, amount: event.target.value })} /></label>
<label className="contract-form-span">授权范围 JSON<textarea rows="8" value={contractForm.licenseScopeJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, licenseScopeJson: event.target.value })} /></label>
<label className="contract-form-span">权利条款 JSON<textarea rows="7" value={contractForm.rightsJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, rightsJson: event.target.value })} /></label>
<label>交付条款 JSON<textarea rows="7" value={contractForm.deliveryTermsJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, deliveryTermsJson: event.target.value })} /></label>
<label>证据引用 JSON<textarea rows="7" value={contractForm.evidenceJson} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, evidenceJson: event.target.value })} /></label>
<label className="contract-form-span">审批备注<textarea rows="3" value={contractForm.approvalNote} disabled={!canManage} onChange={(event) => setContractForm({ ...contractForm, approvalNote: event.target.value })} /></label>
<div className="approval-policy-note contract-form-span"><ShieldCheck size={15} /><span>合同必须批准、生效、未过期,并明确允许 AI 生成资产、原创/改编制作、一图一完整单画面和固定声线,交付才会放行。</span></div>
<div className="form-actions contract-form-span"><button className="primary" type="submit" disabled={!canManage || busy === "contract"}><Save size={15} />保存合同</button></div>
</form>
</section>
<section className="studio-card">
<SectionBar title="项目绑定与清算" detail={selectedProjectId || "选择项目"} />
<form className="contract-form mini" onSubmit={bindProject}>
<label>项目<select value={bindingForm.projectId} disabled={!canManage} onChange={(event) => { setBindingForm({ ...bindingForm, projectId: event.target.value }); runClearance(event.target.value); }}>{data.projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></label>
<label>用途<select value={bindingForm.usageMode} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, usageMode: event.target.value })}><option value="primary-license">主授权</option><option value="supplemental-rights">补充权利</option><option value="delivery-only">仅交付</option><option value="internal-review">内部评审</option></select></label>
<label>绑定状态<select value={bindingForm.status} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, status: event.target.value })}><option value="active">启用</option><option value="review">复核</option><option value="blocked">阻断</option><option value="archived">归档</option></select></label>
<label>备注<textarea rows="3" value={bindingForm.notes} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, notes: event.target.value })} /></label>
<div className="form-actions"><button className="primary" type="submit" disabled={!canManage || !selectedContractId || busy === "binding"}><Link2 size={15} />绑定合同</button><button className="subtle" type="button" onClick={() => runClearance(bindingForm.projectId)} disabled={busy === "clearance"}><ShieldCheck size={15} />检查清算</button></div>
</form>
<div className="contract-clearance-panel">
<div className="clearance-header"><strong>{clearance?.projectName || "项目授权清算"}</strong><StatusBadge status={contractStatusTone(clearance?.status || "review")} label={clearance?.status || "未运行"} /></div>
<div className="contract-scope-strip">
<span>渠道:{licensePreview.channels?.join(" / ") || "未定义"}</span>
<span>地域:{licensePreview.territories?.join(" / ") || "未定义"}</span>
<span>交付物:{licensePreview.deliverables?.join(" / ") || "未定义"}</span>
</div>
<div className="contract-issue-list">
{(clearance?.blockers || []).map((item, index) => <div key={`blocker-${index}`} className="warn"><AlertTriangle size={14} /><span>{item.reason}</span><small>{item.code}</small></div>)}
{(clearance?.reviewItems || []).map((item, index) => <div key={`review-${index}`}><ShieldAlert size={14} /><span>{item.reason}</span><small>{item.code}</small></div>)}
{clearance && !clearance.blockers?.length && !clearance.reviewItems?.length && <div className="ok"><CheckCircle2 size={14} /><span>当前项目合同授权清算通过。</span><small>{clearance.checkedAt?.replace("T", " ").slice(0, 19)}</small></div>}
{!clearance && <div className="empty-table">选择项目后运行合同清算。</div>}
</div>
</div>
</section>
<section className="studio-card wide-card">
<SectionBar title="授权事件台账" detail={`${data.events.length} 条最近事件`} />
<div className="contract-event-table">
<div className="contract-event-head"><span>时间</span><span>事件</span><span>合同/项目</span><span>结果</span></div>
{data.events.slice(0, 12).map((event) => <div className="contract-event-row" key={event.id}><span>{event.createdAt?.replace("T", " ").slice(0, 19)}</span><strong>{event.eventType}</strong><span>{event.contractNumber || event.customerName}<small>{event.projectName || event.projectId || "组织级"}</small></span><StatusBadge status={contractStatusTone(event.status)} label={event.status} /></div>)}
{!data.events.length && <div className="empty-table">暂无授权事件。</div>}
</div>
</section>
</div>
</div>
);
}
const policyStatusLabels = {
draft: "草稿",
enforced: "强制",
+47
View File
@@ -483,6 +483,53 @@ export async function fetchOrganizationCommercial(organizationId, overrides = {}
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial`, {}, overrides);
}
function contractQuery(filters = {}) {
const params = new URLSearchParams();
for (const key of ["query", "q", "status", "customerId", "projectId", "channelKind", "territory", "includeArchived", "limit"]) {
if (filters[key] !== undefined && filters[key] !== null && String(filters[key]) !== "") params.set(key, String(filters[key]));
}
return params.toString();
}
export async function fetchCustomers(organizationId, filters = {}, overrides = {}) {
const query = contractQuery(filters);
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers${query ? `?${query}` : ""}`, {}, overrides);
}
export async function createCustomer(organizationId, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers`, { method: "POST", body: JSON.stringify(body) }, overrides);
}
export async function updateCustomer(organizationId, customerId, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers/${encodeURIComponent(customerId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
}
export async function upsertCustomerContact(organizationId, customerId, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/customers/${encodeURIComponent(customerId)}/contacts`, { method: "POST", body: JSON.stringify(body) }, overrides);
}
export async function fetchContracts(organizationId, filters = {}, overrides = {}) {
const query = contractQuery(filters);
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts${query ? `?${query}` : ""}`, {}, overrides);
}
export async function createContract(organizationId, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts`, { method: "POST", body: JSON.stringify(body) }, overrides);
}
export async function updateContract(organizationId, contractId, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts/${encodeURIComponent(contractId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
}
export async function bindContractProject(organizationId, contractId, body, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts/${encodeURIComponent(contractId)}/bind-project`, { method: "POST", body: JSON.stringify(body) }, overrides);
}
export async function fetchContractClearance(organizationId, filters = {}, overrides = {}) {
const query = contractQuery(filters);
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/contracts/clearance${query ? `?${query}` : ""}`, {}, overrides);
}
export async function fetchOrganizationEntitlements(organizationId, overrides = {}) {
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/entitlements`, {}, overrides);
}
+230
View File
@@ -7023,6 +7023,232 @@ body {
margin-top: 14px;
}
.contract-admin-layout {
display: grid;
grid-template-columns: minmax(240px, 0.75fr) minmax(280px, 0.95fr) minmax(0, 1.45fr);
gap: 14px;
align-items: start;
}
.contract-list-card {
min-width: 0;
}
.contract-entity-list,
.contract-contact-list,
.contract-issue-list {
display: grid;
gap: 8px;
}
.contract-entity-list {
max-height: 560px;
overflow: auto;
padding-right: 2px;
}
.contract-entity-list button,
.contract-contact-list button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
min-height: 78px;
padding: 10px 11px;
color: inherit;
background: #f8fbf8;
border: 1px solid #e0e7e2;
border-radius: 8px;
text-align: left;
}
.contract-contact-list button {
grid-template-columns: minmax(0, 1fr);
min-height: 66px;
}
.contract-entity-list button.selected,
.contract-entity-list button:hover,
.contract-contact-list button:hover {
background: #eef8f2;
border-color: #8fc7aa;
}
.contract-entity-list strong,
.contract-contact-list strong,
.contract-clearance-panel strong {
display: block;
overflow: hidden;
color: var(--ink);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-entity-list span,
.contract-entity-list small,
.contract-contact-list span,
.contract-contact-list small,
.contract-scope-strip span {
display: block;
overflow: hidden;
color: var(--faint);
font-size: 10px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-form {
display: grid;
gap: 10px;
}
.contract-form.compact {
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: end;
}
.contract-form-dense {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.contract-form.mini {
grid-template-columns: 1fr;
}
.contract-form label {
display: grid;
gap: 6px;
min-width: 0;
color: var(--muted);
font-size: 11px;
font-weight: 800;
}
.contract-form input,
.contract-form select,
.contract-form textarea {
width: 100%;
min-width: 0;
min-height: 36px;
padding: 8px 10px;
color: var(--ink);
background: #ffffff;
border: 1px solid var(--line);
border-radius: 7px;
outline: 0;
font: inherit;
font-size: 12px;
}
.contract-form textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
line-height: 1.55;
}
.contract-form .checkbox-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0;
}
.contract-form .checkbox-line input[type="checkbox"] {
flex: 0 0 auto;
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
padding: 0;
accent-color: var(--accent);
}
.contract-form-span,
.contract-form .approval-policy-note {
grid-column: 1 / -1;
}
.contract-contact-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.75fr);
gap: 12px;
align-items: start;
margin-top: 8px;
}
.contract-bottom-grid {
margin-top: 14px;
}
.contract-clearance-panel {
display: grid;
gap: 10px;
margin-top: 12px;
padding: 12px;
background: #f7faf8;
border: 1px solid #e0e7e2;
border-radius: 8px;
}
.clearance-header {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.contract-scope-strip {
display: grid;
gap: 6px;
}
.contract-issue-list > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 9px 10px;
background: #ffffff;
border: 1px solid #e4eae5;
border-radius: 8px;
}
.contract-issue-list > div.warn {
background: #fff8f2;
border-color: #f4c7a5;
}
.contract-issue-list > div.ok {
background: #effaf3;
border-color: #a8d7b5;
}
.contract-issue-list span,
.contract-issue-list small {
min-width: 0;
font-size: 11px;
line-height: 1.45;
}
.contract-issue-list small {
grid-column: 2;
color: var(--faint);
}
.contract-event-table {
overflow: auto;
}
.contract-event-head,
.contract-event-row {
display: grid;
grid-template-columns: minmax(135px, 0.8fr) minmax(160px, 1fr) minmax(180px, 1.2fr) minmax(92px, 0.45fr);
gap: 10px;
align-items: center;
min-width: 720px;
}
.contract-event-head {
padding: 0 10px 8px;
color: var(--faint);
font-size: 10px;
font-weight: 900;
}
.contract-event-row {
min-height: 52px;
padding: 8px 10px;
border-top: 1px solid var(--line);
}
.contract-event-row strong,
.contract-event-row span,
.contract-event-row small {
overflow: hidden;
font-size: 11px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-event-row small {
display: block;
color: var(--faint);
}
.org-policy-layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
@@ -7278,6 +7504,10 @@ body {
.style-kit-layout,
.style-kit-form,
.style-kit-contract-preview,
.contract-admin-layout,
.contract-form.compact,
.contract-form-dense,
.contract-contact-grid,
.org-policy-layout,
.org-policy-form,
.org-policy-check-grid,