Add customer contract clearance console
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user