From 1786432b86b6848a3a8caab284948fe3c87358de Mon Sep 17 00:00:00 2001 From: Eclipseic1848 <237380389+Eclipseic1848@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:38:31 -0700 Subject: [PATCH 1/2] feat: align order input with Sheet3 --- CONTEXT.md | 11 +- README.md | 8 +- .../api/migrations/026_sheet3_order_input.sql | 100 +++++++ .../api/src/authorization.integration.test.ts | 4 +- .../src/backup-restore.integration.test.ts | 2 +- apps/api/src/domain/business-regions.ts | 1 + .../domain/performance-import-xlsx.test.ts | 30 +- .../api/src/domain/performance-import-xlsx.ts | 15 +- apps/api/src/domain/performance.test.ts | 8 + apps/api/src/domain/performance.ts | 12 +- apps/api/src/imports-api.integration.test.ts | 16 +- .../src/ledger-governance.integration.test.ts | 51 +++- apps/api/src/modules/authorization.ts | 10 + apps/api/src/modules/exports.ts | 8 +- apps/api/src/modules/formal-reports.ts | 11 +- apps/api/src/modules/imports.ts | 45 +-- apps/api/src/modules/order-query.ts | 10 +- apps/api/src/modules/performance.ts | 274 ++++++++++++------ apps/api/src/services/import-job.ts | 172 ++++++++--- .../api/src/test-database.integration.test.ts | 1 + apps/api/src/validation.test.ts | 9 +- apps/api/src/validation.ts | 15 +- apps/web/e2e/analysis.spec.ts | 2 +- apps/web/e2e/login.spec.ts | 24 +- apps/web/e2e/money-inputs.spec.ts | 13 +- ...\345\205\245\346\250\241\346\235\277.xlsx" | Bin 8900 -> 5521 bytes apps/web/src/app-types.ts | 10 +- apps/web/src/pages/orders-page.tsx | 48 ++- apps/web/src/shared-ui.tsx | 2 +- apps/web/src/styles.css | 1 + .../0005-use-immutable-performance-events.md | 2 +- docs/adr/0006-exclude-collaboration-data.md | 2 +- ...et3-order-input-and-split-collaboration.md | 9 + docs/performance-import.md | 8 +- docs/v1-scope.md | 7 +- 35 files changed, 705 insertions(+), 236 deletions(-) create mode 100644 apps/api/migrations/026_sheet3_order_input.sql create mode 100644 docs/adr/0031-use-sheet3-order-input-and-split-collaboration.md diff --git a/CONTEXT.md b/CONTEXT.md index ee4e43e..c0e4e9d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -90,13 +90,21 @@ _Avoid_: 自动暂停订单、异常即删除 _Avoid_: 原始金额、业绩净额 **正向计入订单**: -当前计入业绩金额大于 0 的订单。只有正向计入订单允许修改营业额;暂停订单或当前计入业绩金额不大于 0 的订单不能修改营业额。 +当前计入业绩金额大于 0 的订单。正向计入和应收未收订单允许修改营业额;暂停或零金额订单不能修改营业额。 _Avoid_: 数据为正、正常数据 **零金额订单**: 订单当前营业额为 0 的业绩主记录。零金额订单允许录入和查询,但不计入业绩总额,也不具备营业额修改资格。 _Avoid_: 无效订单、赠送订单 +**应收未收订单**: +首次录入的系统营业额为负数、用负值表示尚未收回金额的业绩主记录。负数按原值进入账本并单独标识,不解释为退款、暂停或导入错误;应收状态解除时通过不可变营业额调整事件记录。 +_Avoid_: 负数错误、退款订单、历史待核订单 + +**协作业绩分配**: +一笔订单事件在主业务员与一名协作人之间按固定比例拆分的个人及组织业绩归属。协作人份额按分舍入,主业务员取得剩余金额,两者合计始终等于公司事件金额;后续调整沿用订单协作比例并按事件发生时组织关系分别固化。 +_Avoid_: 重复计算、额外奖励、公司营业额加成 + **首次计入事件**: 零金额订单首次获得正营业额时,在同一业绩主记录下新增的正向事件。该事件以当时的完整正营业额计入操作发生月,使订单转为正向计入订单。 _Avoid_: 零金额修改、新建订单 @@ -177,6 +185,7 @@ _Avoid_: 数字订单号、自动清洗编号、人工合并 **Excel 批量导入**: 业绩数据维护角色将轻流或未来线下工作簿中的多行订单和历史调整受控转录到 SampleFlow 的入口。它与人工录入使用相同的服务端校验、权限、订单唯一性、组织解析和确认入账规则。v1 仅接收未加密 `.xlsx` 固定值,不执行公式、宏或外部连接;账号、权限、目标、目标确认和审批结果不得导入。事件必须携带权威来源发生日期,系统自动推导记账月;已关闭期间须先取得相应重开授权。 标准模板仅用于首次入账;其他获批格式可导入营业额修改、暂停、重启和首次计入。其金额含义与人工操作一致:营业额修改填写调整后的新营业额,首次计入填写计入金额,暂停和重启填写 0;同一订单同一天多条事件必须提供从 1 开始连续且唯一的业务顺序。 +标准模板采用“分子”工作表字段:收样月份、日期、订单编号、客户姓名、客户单位、省份、业务员、部门、组别、系统营业额、服务类型、备注、协作人和协作比例;中间空白列只为兼容原工作表而忽略。标准批次每个订单编号只能有一行首次入账,订单编号同时作为稳定来源标识;系统营业额允许负数并表示应收未收。收样月份必须与日期一致,部门和组别必须与业务员在该日期的有效组织关系一致。 专用历史配置必须在人事批准前固化整体和逐月的来源行数、订单数、事件数及金额对账基准;预检实际值不一致时整批阻断,不能仅凭整体合计放行缺月或截断文件。 _Avoid_: Excel 覆盖更新、直接灌库 diff --git a/README.md b/README.md index ee5357f..ae0e341 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它 - 系统账号、首次改密、会话安全和角色权限矩阵。 - 部门、小组、人员身份和带有效期的组织任职。 -- 订单台账与只追加、不覆盖的业绩事件链。 +- 以“分子”Sheet3 字段为准的订单台账与只追加、不覆盖的业绩事件链;负数系统营业额明确标记为“应收未收”。 - 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。 - 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。 - 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。 -- 人工录入与受控 `.xlsx` 导入;预检、逐月核对、确认、回滚和幂等证据分离。 +- 人工录入与受控 `.xlsx` 导入;标准模板覆盖日期、客户单位、省份、组织、服务类型、备注及协作分配,预检、确认、回滚和幂等证据分离。 - PostgreSQL、React、Fastify 和 Docker Compose 组成的模块化单体。 ## 项目状态 @@ -67,7 +67,9 @@ npm.cmd run verify ## Excel 导入与数据边界 -在“订单业绩 → Excel 导入”下载标准模板。上传后先预检,只有获授权角色显式确认才写入正式账本。专用历史导入还要求来源哈希、逐月基线、组织映射和重复检测。 +在“订单业绩 → Excel 导入”下载标准模板。标准模板只读取名为“分子”的工作表,表头与原始 Sheet3 业务字段一致;一行代表一笔新订单,订单编号同时作为稳定来源标识。上传后先预检,只有获授权角色显式确认才写入正式账本。专用历史导入仍使用独立配置,并要求来源哈希、逐月基线、组织映射和重复检测。 + +`系统营业额` 允许为负,负数表示应收未收。填写协作人时必须同时填写 0—1 之间的协作比例;个人、组别和部门业绩按比例拆分,公司总额不重复增加。完整规则见 [`docs/performance-import.md`](docs/performance-import.md)。 不要提交、上传到 Issue/PR 或写入日志: diff --git a/apps/api/migrations/026_sheet3_order_input.sql b/apps/api/migrations/026_sheet3_order_input.sql new file mode 100644 index 0000000..bd391af --- /dev/null +++ b/apps/api/migrations/026_sheet3_order_input.sql @@ -0,0 +1,100 @@ +alter table performance_orders drop constraint performance_orders_original_amount_check; +alter table performance_orders drop constraint performance_orders_current_revenue_check; +alter table performance_events drop constraint performance_events_resulting_current_revenue_check; + +alter table performance_orders drop constraint performance_orders_lifecycle_state_check; +alter table performance_orders add constraint performance_orders_lifecycle_state_check + check(lifecycle_state in ('draft','active','paused','zero','receivable_pending','historical_review_required')); + +alter table performance_orders drop constraint performance_orders_state_amounts_check; +alter table performance_orders add constraint performance_orders_state_amounts_check check ( + lifecycle_state = 'historical_review_required' + or (lifecycle_state = 'draft' and current_revenue = 0 and counted_amount = 0) + or (lifecycle_state = 'active' and current_revenue > 0 and counted_amount > 0) + or (lifecycle_state = 'paused' and current_revenue > 0 and counted_amount = 0) + or (lifecycle_state = 'zero' and current_revenue = 0 and counted_amount = 0) + or (lifecycle_state = 'receivable_pending' and current_revenue < 0 and counted_amount < 0) +); + +alter table performance_orders + add column collaborator_person_id bigint references people(id), + add column collaborator_name text, + add column collaboration_ratio numeric(7,6), + add constraint performance_orders_collaboration_check check ( + (collaborator_person_id is null and collaborator_name is null and collaboration_ratio is null) + or (collaborator_person_id is not null and collaborator_name is not null and collaborator_name <> '' + and collaboration_ratio > 0 and collaboration_ratio < 1 + and collaborator_person_id is distinct from salesperson_person_id) + ); + +alter table performance_events + add column service_type text, + add column collaborator_person_id bigint references people(id), + add column collaborator_name text, + add column collaboration_ratio numeric(7,6), + add column collaborator_department_unit_id bigint references org_units(id), + add column collaborator_department_name text, + add column collaborator_group_unit_id bigint references org_units(id), + add column collaborator_group_name text, + add column collaborator_leader_person_id bigint references people(id), + add column collaborator_leader_name text, + add column collaborator_supervisor_person_id bigint references people(id), + add column collaborator_supervisor_name text, + add constraint performance_events_collaboration_check check ( + (collaborator_person_id is null and collaborator_name is null and collaboration_ratio is null + and collaborator_department_unit_id is null and collaborator_department_name is null + and collaborator_group_unit_id is null and collaborator_group_name is null + and collaborator_leader_person_id is null and collaborator_leader_name is null + and collaborator_supervisor_person_id is null and collaborator_supervisor_name is null) + or (collaborator_person_id is not null and collaborator_name is not null and collaborator_name <> '' + and collaboration_ratio > 0 and collaboration_ratio < 1 + and collaborator_person_id is distinct from salesperson_person_id + and collaborator_department_unit_id is not null and collaborator_department_name is not null + and collaborator_group_unit_id is not null and collaborator_group_name is not null + and collaborator_leader_person_id is not null and collaborator_leader_name is not null + and collaborator_supervisor_person_id is not null and collaborator_supervisor_name is not null) + ); + +create index performance_events_collaborator_person_idx + on performance_events(collaborator_person_id,accounting_month) where collaborator_person_id is not null; +create index performance_events_collaborator_group_idx + on performance_events(collaborator_group_unit_id,accounting_month) where collaborator_group_unit_id is not null; +create index performance_events_collaborator_department_idx + on performance_events(collaborator_department_unit_id,accounting_month) where collaborator_department_unit_id is not null; + +create view performance_event_attributions as +select event.id as event_id,'primary'::text as attribution_role, + (event.delta_amount-round(event.delta_amount*coalesce(event.collaboration_ratio,0),2))::numeric(14,2) as attributed_amount, + event.salesperson_person_id,event.salesperson_name,event.department_unit_id,event.department_name, + event.group_unit_id,event.group_name,event.leader_person_id,event.leader_name, + event.supervisor_person_id,event.supervisor_name +from performance_events event +union all +select event.id,'collaborator',round(event.delta_amount*event.collaboration_ratio,2)::numeric(14,2), + event.collaborator_person_id,event.collaborator_name,event.collaborator_department_unit_id,event.collaborator_department_name, + event.collaborator_group_unit_id,event.collaborator_group_name,event.collaborator_leader_person_id,event.collaborator_leader_name, + event.collaborator_supervisor_person_id,event.collaborator_supervisor_name +from performance_events event +where event.collaborator_person_id is not null; + +alter table import_configs drop constraint import_configs_fixed_event_type_check; +alter table import_configs add constraint import_configs_fixed_event_type_check + check(fixed_event_type in ('initial','legacy_adjustment')); +alter table import_configs drop constraint import_configs_legacy_mode_check; +alter table import_configs add constraint import_configs_legacy_mode_check check ( + (allow_legacy_source_key and fixed_event_type='legacy_adjustment') + or (not allow_legacy_source_key and fixed_event_type is distinct from 'legacy_adjustment') +); + +insert into import_configs( + config_key,version,name,status,sheet_name,expected_headers,column_mapping,required_columns, + allowed_event_types,business_region_mapping,person_mapping,fixed_event_type,allow_legacy_source_key,approved_at +) values ( + 'standard-performance',2,'标准“分子”新订单模板 v2','approved','分子', + '["收样月份","日期","订单编号(来源于轻流系统)","客户姓名","客户单位","省份","业务员","部门","组别","系统营业额","服务类型","备注",null,"协作人","协作比例"]'::jsonb, + '{"sourceMonth":"收样月份","occurredOn":"日期","sourceRecordId":"订单编号(来源于轻流系统)","orderNo":"订单编号(来源于轻流系统)","customerName":"客户姓名","customerUnit":"客户单位","businessRegionSourceText":"省份","salespersonSourceKey":"业务员","sourceDepartment":"部门","sourceGroup":"组别","amount":"系统营业额","serviceType":"服务类型","reason":"备注","collaboratorSourceKey":"协作人","collaborationRatio":"协作比例"}'::jsonb, + '["sourceMonth","occurredOn","orderNo","customerName","customerUnit","businessRegionSourceText","salespersonSourceKey","sourceDepartment","sourceGroup","amount","serviceType"]'::jsonb, + '["initial"]'::jsonb, + '{"北京市":"CN-BJ","天津市":"CN-TJ","河北省":"CN-HE","山西省":"CN-SX","内蒙古自治区":"CN-NM","辽宁省":"CN-LN","吉林省":"CN-JL","黑龙江省":"CN-HL","上海市":"CN-SH","江苏省":"CN-JS","浙江省":"CN-ZJ","安徽省":"CN-AH","福建省":"CN-FJ","江西省":"CN-JX","山东省":"CN-SD","河南省":"CN-HA","湖北省":"CN-HB","湖南省":"CN-HN","广东省":"CN-GD","广西壮族自治区":"CN-GX","海南省":"CN-HI","重庆市":"CN-CQ","四川省":"CN-SC","贵州省":"CN-GZ","云南省":"CN-YN","西藏自治区":"CN-XZ","陕西省":"CN-SN","甘肃省":"CN-GS","青海省":"CN-QH","宁夏回族自治区":"CN-NX","新疆维吾尔自治区":"CN-XJ","台湾省":"CN-TW","外贸":"EXT-TRADE"}'::jsonb, + '{}'::jsonb,'initial',false,now() +); diff --git a/apps/api/src/authorization.integration.test.ts b/apps/api/src/authorization.integration.test.ts index 3fb1660..5a8beef 100644 --- a/apps/api/src/authorization.integration.test.ts +++ b/apps/api/src/authorization.integration.test.ts @@ -1747,11 +1747,11 @@ test("订单导出复用组合筛选、权限范围与安全审计", async () => assert.equal(response.body.charCodeAt(0), 0xfeff); const lines = response.body.split("\r\n"); assert.equal(lines.length, 52); - assert.equal(lines[0], '\ufeff"订单编号","客户","客户单位","业务员","部门","小组","来源区域原文","标准业务区域","到样日期","当前营业额","当前计入金额","状态"'); + assert.equal(lines[0], '\ufeff"收样月份","日期","订单编号","客户姓名","客户单位","省份","业务员","部门","组别","系统营业额","服务类型","备注","协作人","协作比例","当前计入金额","状态"'); assert.ok(lines.some((line) => line.includes('"EXPORT-MATCH-0001"'))); assert.ok(lines.every((line) => !line.includes("EXPORT-OUT-OF-SCOPE"))); assert.ok(lines.some((line) => line.includes('"\'\t=危险客户"') && line.includes('"\'+危险单位"') && line.includes('"\'\t=江苏原文"'))); - assert.ok(lines.some((line) => line.includes(',123.45,120.5,"active"'))); + assert.ok(lines.some((line) => line.includes(',123.45,,"导出回归",,,120.5,"active"'))); async function assertExportPlanHasNoPerRowEventScan() { assert.ok(exportQuery); diff --git a/apps/api/src/backup-restore.integration.test.ts b/apps/api/src/backup-restore.integration.test.ts index 58fe716..d1f6fc3 100644 --- a/apps/api/src/backup-restore.integration.test.ts +++ b/apps/api/src/backup-restore.integration.test.ts @@ -179,7 +179,7 @@ test("自定义格式备份只恢复到显式新库且保持来源与恢复摘 SOURCE_DB_NAME: targetName, }, backupDirectory); assert.equal(targetSummary.stdout, sourceSummary.stdout); - assert.match(sourceSummary.stdout, /^schema_migrations\|25\|[a-f0-9]{32}$/m); + assert.match(sourceSummary.stdout, /^schema_migrations\|26\|[a-f0-9]{32}$/m); assert.match(sourceSummary.stdout, /^users\|1\|[a-f0-9]{32}$/m); const appDatabaseUrl = roleUrl(targetUrl.toString(), roles.app, roles.appPassword); diff --git a/apps/api/src/domain/business-regions.ts b/apps/api/src/domain/business-regions.ts index 9706aa4..a6e10e7 100644 --- a/apps/api/src/domain/business-regions.ts +++ b/apps/api/src/domain/business-regions.ts @@ -7,6 +7,7 @@ export const STANDARD_BUSINESS_REGIONS = [ ["CN-HI", "海南省"], ["CN-CQ", "重庆市"], ["CN-SC", "四川省"], ["CN-GZ", "贵州省"], ["CN-YN", "云南省"], ["CN-XZ", "西藏自治区"], ["CN-SN", "陕西省"], ["CN-GS", "甘肃省"], ["CN-QH", "青海省"], ["CN-NX", "宁夏回族自治区"], ["CN-XJ", "新疆维吾尔自治区"], + ["CN-TW", "台湾省"], ["EXT-TRADE", "外贸"], ] as const; diff --git a/apps/api/src/domain/performance-import-xlsx.test.ts b/apps/api/src/domain/performance-import-xlsx.test.ts index 3732cef..8484665 100644 --- a/apps/api/src/domain/performance-import-xlsx.test.ts +++ b/apps/api/src/domain/performance-import-xlsx.test.ts @@ -14,6 +14,19 @@ const standard: ImportLayout = { }, }; +const sheet3: ImportLayout = { + sheetName: "分子", + expectedHeaders: ["收样月份", "日期", "订单编号(来源于轻流系统)", "客户姓名", "客户单位", "省份", "业务员", "部门", "组别", "系统营业额", "服务类型", "备注", null, "协作人", "协作比例"], + columnMapping: { + sourceMonth: "收样月份", occurredOn: "日期", sourceRecordId: "订单编号(来源于轻流系统)", + orderNo: "订单编号(来源于轻流系统)", customerName: "客户姓名", customerUnit: "客户单位", + businessRegionSourceText: "省份", salespersonSourceKey: "业务员", sourceDepartment: "部门", + sourceGroup: "组别", amount: "系统营业额", serviceType: "服务类型", reason: "备注", + collaboratorSourceKey: "协作人", collaborationRatio: "协作比例", + }, + fixedEventType: "initial", +}; + test("同一领域字段可由两个获批列布局精确映射,不猜测列名", () => { const date = new Date("2026-03-05T00:00:00Z"); const standardRows = mapImportWorksheetRows([ @@ -119,11 +132,26 @@ test("获批历史布局可保留忽略列、固定历史事件类型并精确 assert.equal(rows[0]?.amount, -25.5); }); +test("Sheet3 新订单布局保留负数、组织来源与协作比例", () => { + const rows = mapImportWorksheetRows([ + sheet3.expectedHeaders!, + ["3月", new Date("2026-03-05T00:00:00Z"), "SF-001", "客户甲", "单位甲", "台湾省", "业务员甲", "销售部", "一组", -100, "检测", "应收未收", null, "业务员乙", 0.2], + ], sheet3); + assert.deepEqual(rows[0], { + sheet: "分子", rowNumber: 2, sourceRecordId: "SF-001", sourceMonth: "3月", orderNo: "SF-001", + occurredOn: "2026-03-05", customerName: "客户甲", customerUnit: "单位甲", businessRegionSourceText: "台湾省", + salespersonSourceKey: "业务员甲", sourceDepartment: "销售部", sourceGroup: "一组", serviceType: "检测", + collaboratorSourceKey: "业务员乙", collaborationRatio: 0.2, eventType: "initial", amount: -100, reason: "应收未收", + }); +}); + test("可下载的标准模板与标准配置兼容且不含公式", async () => { const template = fileURLToPath(new URL("../../../web/public/SampleFlow标准业绩导入模板.xlsx", import.meta.url)); const bytes = await readFile(template); - const rows = await parseImportWorkbook("SampleFlow标准业绩导入模板.xlsx", bytes, standard); + const rows = await parseImportWorkbook("SampleFlow标准业绩导入模板.xlsx", bytes, sheet3); assert.equal(rows.length, 1); assert.equal(rows[0]?.orderNo, "001-A"); assert.equal(rows[0]?.businessRegionSourceText, "外贸"); + assert.equal(rows[0]?.sourceDepartment, "E2E 销售部"); + assert.equal(rows[0]?.sourceGroup, "E2E 销售组"); }); diff --git a/apps/api/src/domain/performance-import-xlsx.ts b/apps/api/src/domain/performance-import-xlsx.ts index 7f18f37..3827ed1 100644 --- a/apps/api/src/domain/performance-import-xlsx.ts +++ b/apps/api/src/domain/performance-import-xlsx.ts @@ -9,7 +9,8 @@ export class ImportWorkbookError extends Error {} export type ImportLayout = Readonly<{ sheetName: string; expectedHeaders?: readonly unknown[]; - columnMapping: Readonly, string> & Partial>>; + columnMapping: Readonly, string> + & Partial>>; personMapping?: Readonly>; fixedEventType?: ImportEventType; }>; @@ -53,6 +54,7 @@ export function mapImportWorksheetRows(data: readonly (readonly unknown[])[], la : undefined; const rawBusinessSequence = layout.columnMapping.businessSequence ? value(row, "businessSequence") : undefined; const rawCorrectionRequestId = layout.columnMapping.correctionRequestId ? value(row, "correctionRequestId") : undefined; + const rawCollaborationRatio = layout.columnMapping.collaborationRatio ? value(row, "collaborationRatio") : undefined; const correctionRequestId = bigintIdCell(rawCorrectionRequestId); return [{ sheet: layout.sheetName, @@ -60,6 +62,7 @@ export function mapImportWorksheetRows(data: readonly (readonly unknown[])[], la ...(typeof rawBusinessSequence === "number" ? { businessSequence: rawBusinessSequence } : {}), ...(correctionRequestId === undefined ? {} : { correctionRequestId }), ...(sourceRecordId ? { sourceRecordId } : {}), + ...(layout.columnMapping.sourceMonth ? { sourceMonth: textCell(value(row, "sourceMonth")) } : {}), orderNo: textCell(value(row, "orderNo")), occurredOn: dateCell(value(row, "occurredOn")), customerName: textCell(value(row, "customerName")), @@ -69,7 +72,17 @@ export function mapImportWorksheetRows(data: readonly (readonly unknown[])[], la const sourceValue = textCell(value(row, "salespersonSourceKey")); return layout.personMapping?.[sourceValue] ?? sourceValue; })(), + ...(layout.columnMapping.sourceDepartment ? { sourceDepartment: textCell(value(row, "sourceDepartment")) } : {}), + ...(layout.columnMapping.sourceGroup ? { sourceGroup: textCell(value(row, "sourceGroup")) } : {}), serviceType: textCell(value(row, "serviceType")), + ...(layout.columnMapping.collaboratorSourceKey ? { + collaboratorSourceKey: (() => { + const sourceValue = textCell(value(row, "collaboratorSourceKey")); + return layout.personMapping?.[sourceValue] ?? sourceValue; + })(), + } : {}), + ...(rawCollaborationRatio === undefined || rawCollaborationRatio === null || rawCollaborationRatio === "" ? {} + : { collaborationRatio: typeof rawCollaborationRatio === "number" ? rawCollaborationRatio : Number.NaN }), eventType, amount, reason: textCell(value(row, "reason")), diff --git a/apps/api/src/domain/performance.test.ts b/apps/api/src/domain/performance.test.ts index 0044199..0918a11 100644 --- a/apps/api/src/domain/performance.test.ts +++ b/apps/api/src/domain/performance.test.ts @@ -32,3 +32,11 @@ test("首次计入金额舍入为零时拒绝改变账本状态", () => { PerformanceRuleError, ); }); + +test("负数首次入账表示应收未收并可通过营业额调整解除", () => { + const pending = decidePerformanceEvent({ currentRevenue: 0, countedAmount: 0, lifecycle: "draft" }, { type: "initial", amount: -2000 }); + assert.deepEqual(pending.next, { currentRevenue: -2000, countedAmount: -2000, lifecycle: "receivable_pending" }); + const settled = decidePerformanceEvent(pending.next, { type: "revenue_change", newAmount: 2000 }); + assert.equal(settled.deltaAmount, 4000); + assert.deepEqual(settled.next, { currentRevenue: 2000, countedAmount: 2000, lifecycle: "active" }); +}); diff --git a/apps/api/src/domain/performance.ts b/apps/api/src/domain/performance.ts index bd42589..4c13647 100644 --- a/apps/api/src/domain/performance.ts +++ b/apps/api/src/domain/performance.ts @@ -1,7 +1,7 @@ export type PerformanceState = { currentRevenue: number; countedAmount: number; - lifecycle: "draft" | "active" | "paused" | "zero"; + lifecycle: "draft" | "active" | "paused" | "zero" | "receivable_pending"; }; export type PerformanceCommand = @@ -26,17 +26,17 @@ function money(value: number): number { export function decidePerformanceEvent(state: PerformanceState, command: PerformanceCommand): PerformanceDecision { if (command.type === "initial") { if (state.lifecycle !== "draft") throw new PerformanceRuleError("只有草稿订单可以首次入账"); - if (command.amount < 0) throw new PerformanceRuleError("首次录入金额不能为负数"); const amount = money(command.amount); - return { eventType: "initial", deltaAmount: amount, next: { currentRevenue: amount, countedAmount: amount, lifecycle: amount > 0 ? "active" : "zero" } }; + const lifecycle = amount > 0 ? "active" : amount < 0 ? "receivable_pending" : "zero"; + return { eventType: "initial", deltaAmount: amount, next: { currentRevenue: amount, countedAmount: amount, lifecycle } }; } if (command.type === "revenue_change") { - if (state.lifecycle !== "active" || state.countedAmount <= 0 || state.currentRevenue <= 0) throw new PerformanceRuleError("只有正向计入订单可以修改营业额"); - if (command.newAmount < 0) throw new PerformanceRuleError("新营业额不能为负数"); + if (!['active','receivable_pending'].includes(state.lifecycle) || state.countedAmount !== state.currentRevenue || state.currentRevenue === 0) throw new PerformanceRuleError("只有正向计入或应收未收订单可以修改营业额"); const newAmount = money(command.newAmount); const delta = money(newAmount - state.currentRevenue); if (delta === 0) throw new PerformanceRuleError("新营业额与当前营业额相同"); - return { eventType: "revenue_change", deltaAmount: delta, next: { currentRevenue: newAmount, countedAmount: money(state.countedAmount + delta), lifecycle: newAmount > 0 ? "active" : "zero" } }; + const lifecycle = newAmount > 0 ? "active" : newAmount < 0 ? "receivable_pending" : "zero"; + return { eventType: "revenue_change", deltaAmount: delta, next: { currentRevenue: newAmount, countedAmount: money(state.countedAmount + delta), lifecycle } }; } if (command.type === "pause") { if (state.lifecycle !== "active" || state.countedAmount <= 0) throw new PerformanceRuleError("只有正向计入订单可以暂停"); diff --git a/apps/api/src/imports-api.integration.test.ts b/apps/api/src/imports-api.integration.test.ts index 0eca483..19b64e1 100644 --- a/apps/api/src/imports-api.integration.test.ts +++ b/apps/api/src/imports-api.integration.test.ts @@ -128,18 +128,20 @@ test("导入配置草稿与批准遵守销售助理组长/人事职责分离", a const templateConfig = { configKey: "standard-template", name: "标准模板", - sheetName: "业绩导入", - expectedHeaders: ["来源记录标识", "轻流订单编号", "发生日期", "客户姓名", "客户单位", "业务区域", "业务员来源标识", "服务类型", "事件类型", "金额", "原因"], + sheetName: "分子", + expectedHeaders: ["收样月份", "日期", "订单编号(来源于轻流系统)", "客户姓名", "客户单位", "省份", "业务员", "部门", "组别", "系统营业额", "服务类型", "备注", null, "协作人", "协作比例"], columnMapping: { - sourceRecordId: "来源记录标识", orderNo: "轻流订单编号", occurredOn: "发生日期", - customerName: "客户姓名", customerUnit: "客户单位", businessRegionSourceText: "业务区域", - salespersonSourceKey: "业务员来源标识", serviceType: "服务类型", eventType: "事件类型", - amount: "金额", reason: "原因", + sourceMonth: "收样月份", occurredOn: "日期", sourceRecordId: "订单编号(来源于轻流系统)", + orderNo: "订单编号(来源于轻流系统)", customerName: "客户姓名", customerUnit: "客户单位", + businessRegionSourceText: "省份", salespersonSourceKey: "业务员", sourceDepartment: "部门", + sourceGroup: "组别", amount: "系统营业额", serviceType: "服务类型", reason: "备注", + collaboratorSourceKey: "协作人", collaborationRatio: "协作比例", }, - requiredColumns: ["sourceRecordId", "orderNo", "occurredOn", "customerName", "customerUnit", "businessRegionSourceText", "salespersonSourceKey", "serviceType", "eventType", "amount", "reason"], + requiredColumns: ["sourceMonth", "occurredOn", "orderNo", "customerName", "customerUnit", "businessRegionSourceText", "salespersonSourceKey", "sourceDepartment", "sourceGroup", "amount", "serviceType"], allowedEventTypes: ["initial"], businessRegionMapping: { 外贸: "EXT-TRADE" }, personMapping: {}, + fixedEventType: "initial", allowLegacySourceKey: false, }; const templateCreated = await app.inject({ method: "POST", url: "/api/imports/configs", headers: leader, payload: templateConfig }); diff --git a/apps/api/src/ledger-governance.integration.test.ts b/apps/api/src/ledger-governance.integration.test.ts index c849a6c..2b34b04 100644 --- a/apps/api/src/ledger-governance.integration.test.ts +++ b/apps/api/src/ledger-governance.integration.test.ts @@ -22,6 +22,7 @@ async function writeHeaders(app:Parameters[1]>[0] async function seedLedgerScenario(databaseUrl:string){ const member=await seedTestUser(databaseUrl,{username:"ledger_member",displayName:"账本业务员",password:"Role@123",roleCode:"salesperson",roleName:"业务员"}); + const collaborator=await seedTestUser(databaseUrl,{username:"ledger_collaborator",displayName:"账本协作人",password:"Role@123",roleCode:"salesperson",roleName:"业务员"}); const leader=await seedTestUser(databaseUrl,{username:"ledger_leader",displayName:"账本组长",password:"Role@123",roleCode:"sales_leader",roleName:"业务员组长"}); const supervisor=await seedTestUser(databaseUrl,{username:"ledger_supervisor",displayName:"账本主管",password:"Role@123",roleCode:"sales_supervisor",roleName:"业务主管"}); const assistant=await seedTestUser(databaseUrl,{username:"ledger_assistant",displayName:"账本销售助理",password:"Role@123",roleCode:"sales_assistant",roleName:"销售助理"}); @@ -29,23 +30,67 @@ async function seedLedgerScenario(databaseUrl:string){ const hr=await seedTestUser(databaseUrl,{username:"ledger_hr",displayName:"账本人事",password:"Role@123",roleCode:"hr",roleName:"人事部"}); const client=new Client({connectionString:databaseUrl});await client.connect(); try{ - const people=await client.query<{user_id:string;id:string}>("select user_id::text,p.id::text from people p where user_id=any($1::bigint[])",[[member,leader,supervisor,assistant,assistantLeader,hr]]); + const people=await client.query<{user_id:string;id:string}>("select user_id::text,p.id::text from people p where user_id=any($1::bigint[])",[[member,collaborator,leader,supervisor,assistant,assistantLeader,hr]]); const personId=(userId:string)=>people.rows.find((row)=>row.user_id===userId)!.id; const department=await client.query<{id:string}>("insert into org_units(name,unit_type) values('账本部门','department') returning id::text"); const group=await client.query<{id:string}>("insert into org_units(name,unit_type,parent_id) values('账本小组','group',$1) returning id::text",[department.rows[0]!.id]); await client.query("begin"); await client.query(`insert into org_responsibilities(person_id,org_unit_id,responsibility_type,effective_from) values($1,$3,'leader','2026-01-01'),($2,$4,'supervisor','2026-01-01')`,[personId(leader),personId(supervisor),group.rows[0]!.id,department.rows[0]!.id]); - await client.query("insert into org_memberships(person_id,department_id,group_id,effective_from) values($1,$2,$3,'2026-01-01')",[personId(member),department.rows[0]!.id,group.rows[0]!.id]); + await client.query("insert into org_memberships(person_id,department_id,group_id,effective_from) values($1,$3,$4,'2026-01-01'),($2,$3,$4,'2026-01-01')",[personId(member),personId(collaborator),department.rows[0]!.id,group.rows[0]!.id]); await client.query("update org_units set is_active=true where id=any($1::bigint[])",[[department.rows[0]!.id,group.rows[0]!.id]]); await client.query("commit"); return { - memberPersonId:personId(member),leaderPersonId:personId(leader),supervisorPersonId:personId(supervisor), + memberPersonId:personId(member),collaboratorPersonId:personId(collaborator),leaderPersonId:personId(leader),supervisorPersonId:personId(supervisor), assistantPersonId:personId(assistant),assistantLeaderPersonId:personId(assistantLeader),hrPersonId:personId(hr), departmentId:department.rows[0]!.id,groupId:group.rows[0]!.id, }; }finally{await client.end();} } +test("负数新订单保留应收未收语义且协作业绩按比例分配不放大公司总额",async()=>{ + await withMigratedTestDatabase(async(database)=>{ + const scenario=await seedLedgerScenario(database.url); + await withTestApi(database.url,async(app)=>{ + const assistant=await writeHeaders(app,"ledger_assistant"); + const primary=await writeHeaders(app,"ledger_member"); + const collaborator=await writeHeaders(app,"ledger_collaborator"); + const created=await app.inject({method:"POST",url:"/api/performance/orders",headers:assistant,payload:{ + orderNo:"RECEIVABLE-COLLAB-1",customerName:"协作客户",customerUnit:"协作单位",businessRegionSourceText:"台湾省", + businessRegionCode:"CN-TW",salespersonPersonId:scenario.memberPersonId,collaboratorPersonId:scenario.collaboratorPersonId, + collaborationRatio:0.2,serviceType:"检测服务",sourceReceivedOn:"2026-08-15",amount:-10000,reason:"应收未收", + }}); + assert.equal(created.statusCode,201,created.body); + const orderId=created.json<{id:string}>().id; + const order=await app.inject({method:"GET",url:"/api/performance/orders?orderNo=RECEIVABLE-COLLAB-1",headers:{cookie:assistant.cookie}}); + assert.equal(order.statusCode,200,order.body); + const orderRow=order.json().orders[0]; + assert.equal(orderRow.lifecycleState,"receivable_pending"); + assert.equal(orderRow.currentRevenue,"-10000.00"); + assert.equal(orderRow.businessRegionCode,"CN-TW"); + assert.equal(orderRow.collaboratorName,"账本协作人"); + assert.equal(orderRow.collaborationRatio,"0.200000"); + assert.equal(orderRow.serviceType,"检测服务"); + assert.equal(orderRow.note,"应收未收"); + const primaryDashboard=await app.inject({method:"GET",url:"/api/performance/dashboard?month=2026-08",headers:{cookie:primary.cookie}}); + const collaboratorDashboard=await app.inject({method:"GET",url:"/api/performance/dashboard?month=2026-08",headers:{cookie:collaborator.cookie}}); + const companyDashboard=await app.inject({method:"GET",url:"/api/performance/dashboard?month=2026-08",headers:{cookie:assistant.cookie}}); + assert.equal(primaryDashboard.json().metrics.total,"-8000.00"); + assert.equal(collaboratorDashboard.json().metrics.total,"-2000.00"); + assert.equal(companyDashboard.json().metrics.total,"-10000.00"); + const adjusted=await app.inject({method:"POST",url:`/api/performance/orders/${orderId}/events`,headers:assistant,payload:{type:"revenue_change",newAmount:5000,reason:"到账并转正",idempotencyKey:"receivable-collab-settle"}}); + assert.equal(adjusted.statusCode,201,adjusted.body); + assert.equal(adjusted.json().state.lifecycle,"active"); + const adjustedOrder=await app.inject({method:"GET",url:"/api/performance/orders?orderNo=RECEIVABLE-COLLAB-1",headers:{cookie:assistant.cookie}}); + assert.equal(adjustedOrder.json().orders[0].note,"应收未收"); + const credits=new Client({connectionString:database.url});await credits.connect(); + try{ + const amounts=await credits.query<{attribution_role:string;amount:string}>(`select attribution_role,sum(attributed_amount)::text amount from performance_event_attributions credit join performance_events event on event.id=credit.event_id where event.order_id=$1 group by attribution_role order by attribution_role`,[orderId]); + assert.deepEqual(amounts.rows,[{attribution_role:"collaborator",amount:"1000.00"},{attribution_role:"primary",amount:"4000.00"}]); + }finally{await credits.end();} + },{clock:()=>new Date("2026-09-01T01:02:03.000Z")}); + }); +}); + test("正常调整由服务端确定操作日并按稳定顺序幂等追加不可变事件",async()=>{ await withMigratedTestDatabase(async(database)=>{ const scenario=await seedLedgerScenario(database.url); diff --git a/apps/api/src/modules/authorization.ts b/apps/api/src/modules/authorization.ts index 22ce127..04b3c86 100644 --- a/apps/api/src/modules/authorization.ts +++ b/apps/api/src/modules/authorization.ts @@ -103,6 +103,16 @@ export function canReadPerformance(access: PerformanceAccess): boolean { } export function performanceScopeSql(alias: string, firstParameter: number): string { + return `($${firstParameter}::boolean + or ${alias}.salesperson_person_id=any($${firstParameter + 1}::bigint[]) + or ${alias}.collaborator_person_id=any($${firstParameter + 1}::bigint[]) + or ${alias}.group_unit_id=any($${firstParameter + 2}::bigint[]) + or ${alias}.collaborator_group_unit_id=any($${firstParameter + 2}::bigint[]) + or ${alias}.department_unit_id=any($${firstParameter + 3}::bigint[]) + or ${alias}.collaborator_department_unit_id=any($${firstParameter + 3}::bigint[]))`; +} + +export function performanceAttributionScopeSql(alias: string, firstParameter: number): string { return `($${firstParameter}::boolean or ${alias}.salesperson_person_id=any($${firstParameter + 1}::bigint[]) or ${alias}.group_unit_id=any($${firstParameter + 2}::bigint[]) diff --git a/apps/api/src/modules/exports.ts b/apps/api/src/modules/exports.ts index f566bde..5d25597 100644 --- a/apps/api/src/modules/exports.ts +++ b/apps/api/src/modules/exports.ts @@ -28,9 +28,9 @@ async function auditOrderExport(db:Database,input:{actorUserId:string;filters:Or [input.actorUserId,input.requestId,JSON.stringify({filterSummary,rowCount:input.rowCount,status:input.status,requestId:input.requestId,fileSha256:input.fileSha256,...(input.failureCode?{failureCode:input.failureCode}:{})}),ipAddress], ); } -type OrderExportRow={orderNo:string;customerName:string;customerUnit:string;salespersonName:string;departmentName:string|null;groupName:string|null;businessRegionSourceText:string|null;businessRegionCode:string|null;sourceReceivedOn:string;currentRevenue:string;countedAmount:string;lifecycleState:string}; +type OrderExportRow={orderNo:string;customerName:string;customerUnit:string;salespersonName:string;departmentName:string|null;groupName:string|null;businessRegionSourceText:string|null;businessRegionCode:string|null;sourceReceivedOn:string;serviceType:string|null;note:string|null;collaboratorName:string|null;collaborationRatio:string|null;currentRevenue:string;countedAmount:string;lifecycleState:string}; type PausableSource={pause():void;resume():void}; -const orderExportHeader=["订单编号","客户","客户单位","业务员","部门","小组","来源区域原文","标准业务区域","到样日期","当前营业额","当前计入金额","状态"]; +const orderExportHeader=["收样月份","日期","订单编号","客户姓名","客户单位","省份","业务员","部门","组别","系统营业额","服务类型","备注","协作人","协作比例","当前计入金额","状态"]; export async function registerExports(app:FastifyInstance,db:Database,clock:()=>Date=()=>new Date()){ app.get("/api/exports/formal-reports/:goalId.csv",async(request,reply)=>{ if(!request.currentUser)return reply.code(401).send({message:"尚未登录"}); @@ -79,6 +79,8 @@ export async function registerExports(app:FastifyInstance,db:Database,clock:()=> latest.department_name as "departmentName",latest.group_name as "groupName", performance_orders.business_region_source_text as "businessRegionSourceText", performance_orders.business_region_code as "businessRegionCode",performance_orders.source_received_on::text as "sourceReceivedOn", + performance_orders.service_type as "serviceType",latest.note, + performance_orders.collaborator_name as "collaboratorName",performance_orders.collaboration_ratio::text as "collaborationRatio", performance_orders.current_revenue::text as "currentRevenue",performance_orders.counted_amount::text as "countedAmount", performance_orders.lifecycle_state as "lifecycleState" from performance_orders @@ -99,7 +101,7 @@ export async function registerExports(app:FastifyInstance,db:Database,clock:()=> query.on("row",(row:OrderExportRow)=>{ if(finished)return; rowCount+=1; - write(`\r\n${csvLine([row.orderNo,row.customerName,row.customerUnit,row.salespersonName,row.departmentName,row.groupName,row.businessRegionSourceText,standardBusinessRegionName(row.businessRegionCode??"")??row.businessRegionCode,row.sourceReceivedOn,Number(row.currentRevenue),Number(row.countedAmount),row.lifecycleState])}`); + write(`\r\n${csvLine([`${Number(row.sourceReceivedOn.slice(5,7))}月`,row.sourceReceivedOn,row.orderNo,row.customerName,row.customerUnit,row.businessRegionSourceText??standardBusinessRegionName(row.businessRegionCode??"")??row.businessRegionCode,row.salespersonName,row.departmentName,row.groupName,Number(row.currentRevenue),row.serviceType,row.note,row.collaboratorName,row.collaborationRatio===null?null:Number(row.collaborationRatio),Number(row.countedAmount),row.lifecycleState])}`); }); query.once("error",fail); query.once("end",()=>{ diff --git a/apps/api/src/modules/formal-reports.ts b/apps/api/src/modules/formal-reports.ts index fe23455..1fcf393 100644 --- a/apps/api/src/modules/formal-reports.ts +++ b/apps/api/src/modules/formal-reports.ts @@ -86,11 +86,11 @@ export async function loadFormalReport( let scopeSql = "true"; let scopeValues: unknown[] = []; if (goal.level === "personal") { - scopeSql = "e.salesperson_person_id=$3"; + scopeSql = "credit.salesperson_person_id=$3"; scopeValues = [goal.owner_person_id]; } else if (goal.level === "group" || goal.level === "department") { if (goal.org_unit_id !== null) { - scopeSql = goal.level === "group" ? "e.group_unit_id=$3" : "e.department_unit_id=$3"; + scopeSql = goal.level === "group" ? "credit.group_unit_id=$3" : "credit.department_unit_id=$3"; scopeValues = [goal.org_unit_id]; } else { return { @@ -102,11 +102,12 @@ export async function loadFormalReport( } const calculated = await database.query<{ actual_amount: string; gap_amount: string; achievement_rate: string | null }>( - `select coalesce(sum(e.delta_amount),0)::numeric(14,2)::text as actual_amount, - ($2::numeric-coalesce(sum(e.delta_amount),0))::numeric(14,2)::text as gap_amount, + `select coalesce(sum(credit.attributed_amount),0)::numeric(14,2)::text as actual_amount, + ($2::numeric-coalesce(sum(credit.attributed_amount),0))::numeric(14,2)::text as gap_amount, case when $2::numeric=0 then null - else round(coalesce(sum(e.delta_amount),0)*100/$2::numeric,2)::text end as achievement_rate + else round(coalesce(sum(credit.attributed_amount),0)*100/$2::numeric,2)::text end as achievement_rate from performance_events e + join performance_event_attributions credit on credit.event_id=e.id where e.accounting_month=$1 and ${scopeSql}`, [`${goal.period_month}-01`, targetAmount, ...scopeValues], ); diff --git a/apps/api/src/modules/imports.ts b/apps/api/src/modules/imports.ts index 2c36f04..d20e9d5 100644 --- a/apps/api/src/modules/imports.ts +++ b/apps/api/src/modules/imports.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest } from "fastify"; import { z } from "zod"; import type { Database } from "../db.js"; import { recordOperation } from "../observability.js"; -import { postgresBigintIdSchema } from "../validation.js"; +import { postgresBigintIdSchema, signedMoneySchema } from "../validation.js"; import { standardBusinessRegionName } from "../domain/business-regions.js"; import { ImportWorkbookError, parseImportWorkbook, type ImportColumn, type ImportLayout } from "../domain/performance-import-xlsx.js"; import { confirmDimensionBackfillBatch, confirmImportBatch, ImportJobError, preflightDimensionBackfillRows, preflightImportRows, type ImportEventType } from "../services/import-job.js"; @@ -11,18 +11,16 @@ import { hasAnyRole, PERFORMANCE_EDITOR_ROLES } from "./auth.js"; const columnNames = [ "sourceRecordId", "orderNo", "occurredOn", "customerName", "customerUnit", "businessRegionSourceText", "salespersonSourceKey", "serviceType", "eventType", "businessSequence", "correctionRequestId", "amount", "reason", + "sourceMonth", "sourceDepartment", "sourceGroup", "collaboratorSourceKey", "collaborationRatio", ] as const satisfies readonly ImportColumn[]; -const requiredColumnNames = columnNames.filter((name) => name !== "sourceRecordId" && name !== "eventType" && name !== "businessSequence" && name !== "correctionRequestId"); +const optionalColumnNames = ["sourceRecordId", "eventType", "businessSequence", "correctionRequestId", "sourceMonth", "sourceDepartment", "sourceGroup", "collaboratorSourceKey", "collaborationRatio"] as const; +const requiredColumnNames = columnNames.filter((name) => !optionalColumnNames.includes(name as typeof optionalColumnNames[number])); const columnMappingSchema = z.object({ ...Object.fromEntries(requiredColumnNames.map((name) => [name, z.string().min(1).max(100)])), - sourceRecordId: z.string().min(1).max(100).optional(), - eventType: z.string().min(1).max(100).optional(), - businessSequence: z.string().min(1).max(100).optional(), - correctionRequestId: z.string().min(1).max(100).optional(), -} as Record, z.ZodString> & { sourceRecordId: z.ZodOptional; eventType: z.ZodOptional; businessSequence: z.ZodOptional; correctionRequestId: z.ZodOptional }); -const reconciliationAmountSchema = z.number().finite().min(-99_999_999_999.99).max(99_999_999_999.99) - .refine((value) => Math.abs(value * 100 - Math.round(value * 100)) < 1e-7, "对账金额最多保留两位小数"); + ...Object.fromEntries(optionalColumnNames.map((name) => [name, z.string().min(1).max(100).optional()])), +} as Record, z.ZodString> & Record>); +const reconciliationAmountSchema = signedMoneySchema; const expectedReconciliationSchema = z.strictObject({ rows: z.number().int().nonnegative(), orders: z.number().int().nonnegative(), @@ -48,7 +46,7 @@ const configSchema = z.strictObject({ ), personMapping: z.record(z.string().min(1).max(200), z.string().min(1).max(200)).default({}), expectedReconciliation: expectedReconciliationSchema.optional(), - fixedEventType: z.literal("legacy_adjustment").optional(), + fixedEventType: z.enum(["initial", "legacy_adjustment"]).optional(), allowLegacySourceKey: z.boolean().default(false), }); type ConfigInput = z.infer; @@ -81,18 +79,21 @@ function decodeBase64(value: string): Buffer { function configInputError(input: ConfigInput): string | null { const mappedHeaders = Object.values(input.columnMapping).filter((header): header is string => Boolean(header)); const namedExpectedHeaders = input.expectedHeaders.filter((header): header is string => header !== null); - if (new Set(mappedHeaders).size !== mappedHeaders.length) return "每个业务字段必须映射到不同的精确表头"; + const duplicatedMappedHeaders = mappedHeaders.filter((header, index) => mappedHeaders.indexOf(header) !== index); + if (duplicatedMappedHeaders.some((header) => header !== input.columnMapping.orderNo || input.columnMapping.sourceRecordId !== header)) { + return "除订单编号兼作来源记录标识外,每个业务字段必须映射到不同的精确表头"; + } if (new Set(namedExpectedHeaders).size !== namedExpectedHeaders.length) return "完整表头契约不能包含重复的具名列"; if (input.requiredColumns.some((column) => !input.columnMapping[column] && !(column === "eventType" && input.fixedEventType))) return "必填字段必须存在对应列映射或固定事件类型"; if (!input.allowLegacySourceKey && !input.columnMapping.sourceRecordId) return "非历史配置必须映射稳定来源记录标识"; - if (!input.fixedEventType && !input.columnMapping.eventType) return "配置必须映射事件类型,或将获批历史格式固定为 legacy_adjustment"; - if (input.fixedEventType && !input.allowLegacySourceKey) return "固定历史事件类型只能用于获批历史配置"; - if (input.allowLegacySourceKey && !input.fixedEventType) return "历史行号来源键只能用于固定为 legacy_adjustment 的专用历史配置"; - if (input.fixedEventType && input.columnMapping.eventType) return "固定历史事件类型与事件类型列映射不能同时配置"; - if (input.fixedEventType && (input.allowedEventTypes.length !== 1 || input.allowedEventTypes[0] !== input.fixedEventType)) return "固定历史配置只能允许 legacy_adjustment"; - if (!input.fixedEventType && input.allowedEventTypes.includes("legacy_adjustment")) return "普通配置不能允许 legacy_adjustment"; - if (input.expectedReconciliation && !input.fixedEventType) return "预期迁移对账基准只能用于专用历史配置"; - if (input.fixedEventType && !input.expectedReconciliation) return "专用历史配置必须固化整体和逐月迁移对账基准"; + if (!input.fixedEventType && !input.columnMapping.eventType) return "配置必须映射事件类型,或固定为 initial/legacy_adjustment"; + if (input.fixedEventType === "legacy_adjustment" && !input.allowLegacySourceKey) return "固定历史事件类型只能用于获批历史配置"; + if (input.allowLegacySourceKey && input.fixedEventType !== "legacy_adjustment") return "历史行号来源键只能用于固定为 legacy_adjustment 的专用历史配置"; + if (input.fixedEventType && input.columnMapping.eventType) return "固定事件类型与事件类型列映射不能同时配置"; + if (input.fixedEventType && (input.allowedEventTypes.length !== 1 || input.allowedEventTypes[0] !== input.fixedEventType)) return "允许事件类型必须与固定事件类型完全一致"; + if (input.fixedEventType !== "legacy_adjustment" && input.allowedEventTypes.includes("legacy_adjustment")) return "普通配置不能允许 legacy_adjustment"; + if (input.expectedReconciliation && input.fixedEventType !== "legacy_adjustment") return "预期迁移对账基准只能用于专用历史配置"; + if (input.fixedEventType === "legacy_adjustment" && !input.expectedReconciliation) return "专用历史配置必须固化整体和逐月迁移对账基准"; if (input.expectedReconciliation) { const months = input.expectedReconciliation.monthly; if (new Set(months.map((item) => item.month)).size !== months.length) return "逐月迁移对账基准不能包含重复月份"; @@ -193,7 +194,7 @@ export async function registerImports(app: FastifyInstance, database: Database) const result = await client.query( `update import_configs set status='approved',approved_by=$2,approved_at=now() where id=$1 and status='draft' and created_by is distinct from $2 and business_region_mapping<>'{}'::jsonb - and (fixed_event_type is null or expected_reconciliation is not null) + and (fixed_event_type is distinct from 'legacy_adjustment' or expected_reconciliation is not null) returning id`, [params.data.id, request.currentUser.id], ); @@ -225,7 +226,7 @@ export async function registerImports(app: FastifyInstance, database: Database) return reply.code(400).send({ message: "上传参数无效" }); } try { - const config = await database.query<{ sheet_name: string; expected_headers: unknown[]; column_mapping: ImportLayout["columnMapping"]; person_mapping: Record; fixed_event_type:"legacy_adjustment"|null }>( + const config = await database.query<{ sheet_name: string; expected_headers: unknown[]; column_mapping: ImportLayout["columnMapping"]; person_mapping: Record; fixed_event_type:ImportEventType|null }>( "select sheet_name,expected_headers,column_mapping,person_mapping,fixed_event_type from import_configs where id=$1 and status='approved'", [parsed.data.configId], ); @@ -269,7 +270,7 @@ export async function registerImports(app: FastifyInstance, database: Database) return reply.code(400).send({ message: "上传参数无效" }); } try { - const config = await database.query<{ sheet_name:string; expected_headers:unknown[]; column_mapping:ImportLayout["columnMapping"]; person_mapping:Record; fixed_event_type:"legacy_adjustment"|null }>( + const config = await database.query<{ sheet_name:string; expected_headers:unknown[]; column_mapping:ImportLayout["columnMapping"]; person_mapping:Record; fixed_event_type:ImportEventType|null }>( "select sheet_name,expected_headers,column_mapping,person_mapping,fixed_event_type from import_configs where id=$1 and status='approved'", [parsed.data.configId], ); diff --git a/apps/api/src/modules/order-query.ts b/apps/api/src/modules/order-query.ts index e85ab63..63f5b7f 100644 --- a/apps/api/src/modules/order-query.ts +++ b/apps/api/src/modules/order-query.ts @@ -7,7 +7,7 @@ export const orderFilterQuerySchema = z.object({ search: z.string().trim().max(100).optional(), orderNo: z.string().trim().min(1).max(100).optional(), month: z.string().regex(/^[1-9]\d{3}-(0[1-9]|1[0-2])$/).optional(), - status: z.enum(["draft", "active", "paused", "zero", "historical_review_required"]).optional(), + status: z.enum(["draft", "active", "paused", "zero", "receivable_pending", "historical_review_required"]).optional(), salesperson: orderTextFilterSchema.optional(), department: orderTextFilterSchema.optional(), group: orderTextFilterSchema.optional(), @@ -44,7 +44,11 @@ export function normalizeOrderFilters(input: z.infer= $${firstParameter + 1}::date and ${orderAlias}.source_received_on < $${firstParameter + 1}::date + interval '1 month')) and ($${firstParameter + 2}::text is null or ${orderAlias}.lifecycle_state=$${firstParameter + 2}) diff --git a/apps/api/src/modules/performance.ts b/apps/api/src/modules/performance.ts index fe942c3..b013550 100644 --- a/apps/api/src/modules/performance.ts +++ b/apps/api/src/modules/performance.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import type { Database } from "../db.js"; -import { nonnegativeMoneySchema, pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js"; +import { nonnegativeMoneySchema, pageNumberSchema, pageSizeSchema, postgresBigintIdSchema, signedMoneySchema } from "../validation.js"; import { businessDate } from "../domain/business-time.js"; import { decidePerformanceEvent, @@ -12,7 +12,7 @@ import { } from "../domain/performance.js"; import { standardBusinessRegionName } from "../domain/business-regions.js"; import { hasAnyRole, PERFORMANCE_EDITOR_ROLES } from "./auth.js"; -import { canReadPerformance, pendingGoalSql, pendingGoalValues, performanceScopeSql, performanceScopeValues, resolvePerformanceAccess } from "./authorization.js"; +import { canReadPerformance, pendingGoalSql, pendingGoalValues, performanceAttributionScopeSql, performanceScopeSql, performanceScopeValues, resolvePerformanceAccess } from "./authorization.js"; import { recordEventAnalysisDimensions } from "./event-analysis-dimensions.js"; import { achievementCalculationReason, loadFormalReport } from "./formal-reports.js"; import { latestOrderEventJoinSql, normalizeOrderFilters, orderFilterQuerySchema, orderFilterSql, orderFilterValues, type OrderFilters } from "./order-query.js"; @@ -150,6 +150,13 @@ function analysisQueryDigest(value: unknown): string { return createHash("sha256").update(JSON.stringify(value), "utf8").digest("base64url"); } +function scopedEventAmountsSql(firstParameter: number): string { + return `select credit.event_id,sum(credit.attributed_amount)::numeric(14,2) as delta_amount + from performance_event_attributions credit + where ${performanceAttributionScopeSql("credit", firstParameter)} + group by credit.event_id`; +} + function encodeAnalysisCursor(value: AnalysisCustomerCursor | AnalysisEventCursor | AnalysisSnapshot): string { return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } @@ -168,7 +175,8 @@ function resultingLifecycleStateSql(alias: string): string { return `case when ${alias}.event_type='legacy_adjustment' then null when ${alias}.event_type='pause' then 'paused' when ${alias}.event_type in ('restart','first_include') then 'active' - when ${alias}.resulting_current_revenue>0 then 'active' else 'zero' end`; + when ${alias}.resulting_current_revenue>0 then 'active' + when ${alias}.resulting_current_revenue<0 then 'receivable_pending' else 'zero' end`; } const DEPARTMENT_ACHIEVEMENT_ROLES = ["sales_supervisor", "sales_manager", "hr", "general_manager"] as const; @@ -184,10 +192,19 @@ const createOrderSchema = z.strictObject({ businessRegionSourceText: z.string().trim().min(1).max(100), businessRegionCode: z.string().refine((value) => standardBusinessRegionName(value) !== undefined, "必须选择标准业务区域"), salespersonPersonId: postgresBigintIdSchema, + collaboratorPersonId: postgresBigintIdSchema.optional(), + collaborationRatio: z.number().finite().gt(0).lt(1).multipleOf(0.000001).optional(), serviceType: z.string().trim().max(200).optional().default(""), sourceReceivedOn: dateSchema, - amount: moneySchema, + amount: signedMoneySchema, reason: z.string().trim().max(500).optional().default("首次录入"), +}).superRefine((value, context) => { + if ((value.collaboratorPersonId === undefined) !== (value.collaborationRatio === undefined)) { + context.addIssue({ code: "custom", message: "协作人和协作比例必须同时填写或同时留空" }); + } + if (value.collaboratorPersonId === value.salespersonPersonId) { + context.addIssue({ code: "custom", path: ["collaboratorPersonId"], message: "协作人不能与业务员相同" }); + } }); const eventBase = { @@ -196,7 +213,7 @@ const eventBase = { correctionRequestId: postgresBigintIdSchema.optional(), }; const eventSchema = z.discriminatedUnion("type", [ - z.strictObject({ type: z.literal("revenue_change"), newAmount: moneySchema, ...eventBase }), + z.strictObject({ type: z.literal("revenue_change"), newAmount: signedMoneySchema, ...eventBase }), z.strictObject({ type: z.literal("pause"), ...eventBase }), z.strictObject({ type: z.literal("restart"), ...eventBase }), z.strictObject({ type: z.literal("first_include"), amount: moneySchema.positive(), ...eventBase }), @@ -212,6 +229,9 @@ type OrderRow = { salesperson_person_id: string; salesperson_name: string; service_type: string | null; + collaborator_person_id: string | null; + collaborator_name: string | null; + collaboration_ratio: string | null; source_received_on: string; original_amount: string; current_revenue: string; @@ -231,7 +251,7 @@ type PersonalAchievementEvent = Readonly<{ sequence: number; reason: string | null; resultingCountedAmount: string; - resultingLifecycleState: "active" | "paused" | "zero" | null; + resultingLifecycleState: "active" | "paused" | "zero" | "receivable_pending" | null; departmentName: string | null; groupName: string | null; }>; @@ -279,7 +299,7 @@ type AnalysisEventRow = Readonly<{ deltaAmount: string; resultingCurrentRevenue: string; resultingCountedAmount: string; - resultingLifecycleState: "active" | "paused" | "zero" | null; + resultingLifecycleState: "active" | "paused" | "zero" | "receivable_pending" | null; accountingMonth: string; occurredOn: string; reason: string | null; @@ -321,16 +341,17 @@ async function loadPersonalAchievement(database: QueryDatabase, personId: string ) select (select goal_id from active_goal) as goal_id, (select target_amount::text from active_goal) as target_amount, - coalesce(sum(event.delta_amount),0)::numeric(14,2)::text as actual_amount, + coalesce(sum(credit.attributed_amount),0)::numeric(14,2)::text as actual_amount, case when (select target_amount from active_goal)>0 - then ((select target_amount from active_goal)-coalesce(sum(event.delta_amount),0))::numeric(14,2)::text + then ((select target_amount from active_goal)-coalesce(sum(credit.attributed_amount),0))::numeric(14,2)::text else null end as gap_amount, case when (select target_amount from active_goal)>0 - then round(coalesce(sum(event.delta_amount),0)*100/(select target_amount from active_goal),2)::text + then round(coalesce(sum(credit.attributed_amount),0)*100/(select target_amount from active_goal),2)::text else null end as achievement_rate, count(event.id)::text as event_count from performance_events event - where event.accounting_month=$1::date and event.salesperson_person_id=$2`, + join performance_event_attributions credit on credit.event_id=event.id and credit.salesperson_person_id=$2 + where event.accounting_month=$1::date`, [`${periodMonth}-01`, personId], ); return formatAchievement(result.rows[0]!, periodMonth, today); @@ -365,12 +386,13 @@ async function loadPersonalAchievementEvents(database: QueryDatabase, personId: where g.period_month=$1::date and g.goal_level='personal' and g.owner_person_id=$2 ),personal_events as ( select event.id,event.order_id,orders.qingflow_order_no,orders.customer_name, - event.event_type,event.delta_amount,event.resulting_current_revenue,event.resulting_counted_amount, + event.event_type,credit.attributed_amount as delta_amount,event.resulting_current_revenue,event.resulting_counted_amount, event.accounting_month,event.occurred_on,event.order_sequence,event.reason, - event.department_name,event.group_name + credit.department_name,credit.group_name from performance_events event + join performance_event_attributions credit on credit.event_id=event.id and credit.salesperson_person_id=$2 join performance_orders orders on orders.id=event.order_id - where event.accounting_month=$1::date and event.salesperson_person_id=$2 + where event.accounting_month=$1::date ) select (select goal_id from active_goal) as goal_id, (select target_amount::text from active_goal) as target_amount, @@ -460,10 +482,10 @@ async function loadGroupAchievements(database: QueryDatabase, groupIds: string[] member_count: string; }>( `with event_group_names as ( - select distinct on (event.group_unit_id) event.group_unit_id,event.group_name - from performance_events event - where event.accounting_month=$1::date and event.group_unit_id=any($2::bigint[]) - order by event.group_unit_id,event.occurred_on desc,event.order_sequence desc,event.id desc + select distinct on (credit.group_unit_id) credit.group_unit_id,credit.group_name + from performance_events event join performance_event_attributions credit on credit.event_id=event.id + where event.accounting_month=$1::date and credit.group_unit_id=any($2::bigint[]) + order by credit.group_unit_id,event.occurred_on desc,event.order_sequence desc,event.id desc ),selected_groups as ( select unit.id,coalesce(snapshot.group_name,unit.name) as name from org_units unit @@ -479,18 +501,20 @@ async function loadGroupAchievements(database: QueryDatabase, groupIds: string[] ) select selected.id::text as group_id,selected.name as group_name, goal.goal_id,goal.target_amount::text, - coalesce(sum(event.delta_amount),0)::numeric(14,2)::text as actual_amount, + coalesce(sum(credit.attributed_amount),0)::numeric(14,2)::text as actual_amount, case when goal.target_amount>0 - then (goal.target_amount-coalesce(sum(event.delta_amount),0))::numeric(14,2)::text + then (goal.target_amount-coalesce(sum(credit.attributed_amount),0))::numeric(14,2)::text else null end as gap_amount, case when goal.target_amount>0 - then round(coalesce(sum(event.delta_amount),0)*100/goal.target_amount,2)::text + then round(coalesce(sum(credit.attributed_amount),0)*100/goal.target_amount,2)::text else null end as achievement_rate, count(event.id)::text as event_count, - count(distinct coalesce('id:'||event.salesperson_person_id::text,'legacy:'||event.salesperson_name))::text as member_count + count(distinct coalesce('id:'||credit.salesperson_person_id::text,'legacy:'||credit.salesperson_name))::text as member_count from selected_groups selected left join active_goals goal on goal.org_unit_id=selected.id - left join performance_events event on event.group_unit_id=selected.id and event.accounting_month=$1::date + left join performance_event_attributions credit on credit.group_unit_id=selected.id + and credit.event_id in (select id from performance_events where accounting_month=$1::date) + left join performance_events event on event.id=credit.event_id and event.accounting_month=$1::date group by selected.id,selected.name,goal.goal_id,goal.target_amount order by selected.name,selected.id`, [`${periodMonth}-01`, groupIds], @@ -510,9 +534,9 @@ async function loadGroupAchievementDetails(database: QueryDatabase, groupId: str events: GroupAchievementEventRow[]; }>( `with event_group_name as ( - select event.group_name - from performance_events event - where event.accounting_month=$1::date and event.group_unit_id=$2 + select credit.group_name + from performance_events event join performance_event_attributions credit on credit.event_id=event.id + where event.accounting_month=$1::date and credit.group_unit_id=$2 order by event.occurred_on desc,event.order_sequence desc,event.id desc limit 1 ),selected_group as ( @@ -528,19 +552,20 @@ async function loadGroupAchievementDetails(database: QueryDatabase, groupId: str limit 1 ),group_events as ( select event.id,event.order_id,orders.qingflow_order_no,orders.customer_name, - event.event_type,event.delta_amount,event.resulting_current_revenue,event.resulting_counted_amount, + event.event_type,credit.attributed_amount as delta_amount,event.resulting_current_revenue,event.resulting_counted_amount, event.accounting_month,event.occurred_on,event.order_sequence,event.reason, - event.department_name,event.group_name,event.salesperson_person_id,event.salesperson_name, - coalesce('id:'||event.salesperson_person_id::text,'legacy:'||event.salesperson_name) as person_key, - sum(event.delta_amount) over( - partition by coalesce('id:'||event.salesperson_person_id::text,'legacy:'||event.salesperson_name) + credit.department_name,credit.group_name,credit.salesperson_person_id,credit.salesperson_name, + coalesce('id:'||credit.salesperson_person_id::text,'legacy:'||credit.salesperson_name) as person_key, + sum(credit.attributed_amount) over( + partition by coalesce('id:'||credit.salesperson_person_id::text,'legacy:'||credit.salesperson_name) )::numeric(14,2)::text as member_actual_amount, - sum(event.delta_amount) over( - partition by coalesce('id:'||event.salesperson_person_id::text,'legacy:'||event.salesperson_name),event.order_id + sum(credit.attributed_amount) over( + partition by coalesce('id:'||credit.salesperson_person_id::text,'legacy:'||credit.salesperson_name),event.order_id )::numeric(14,2)::text as order_actual_amount from performance_events event + join performance_event_attributions credit on credit.event_id=event.id and credit.group_unit_id=$2 join performance_orders orders on orders.id=event.order_id - where event.accounting_month=$1::date and event.group_unit_id=$2 + where event.accounting_month=$1::date ) select (select name from selected_group) as group_name, (select goal_id from active_goal) as goal_id, @@ -755,14 +780,19 @@ async function loadOrganizationAchievementHierarchy( export const ORGANIZATION_ACHIEVEMENT_SQL = `with scope_events as ( - select event.*,orders.qingflow_order_no,orders.customer_name, - coalesce('id:'||event.department_unit_id::text,'legacy:'||coalesce(event.department_name,'待补齐组织归属')) as department_key, - coalesce('id:'||event.group_unit_id::text,'legacy:'||coalesce(event.group_name,'待补齐小组')) as group_key, - coalesce('id:'||event.salesperson_person_id::text,'legacy:'||event.salesperson_name) as person_key + select event.id,event.order_id,event.event_type,credit.attributed_amount as delta_amount, + event.resulting_current_revenue,event.resulting_counted_amount,event.accounting_month,event.occurred_on, + event.order_sequence,event.reason,orders.qingflow_order_no,orders.customer_name, + credit.department_unit_id,credit.department_name,credit.group_unit_id,credit.group_name, + credit.salesperson_person_id,credit.salesperson_name, + coalesce('id:'||credit.department_unit_id::text,'legacy:'||coalesce(credit.department_name,'待补齐组织归属')) as department_key, + coalesce('id:'||credit.group_unit_id::text,'legacy:'||coalesce(credit.group_name,'待补齐小组')) as group_key, + coalesce('id:'||credit.salesperson_person_id::text,'legacy:'||credit.salesperson_name) as person_key from performance_events event + join performance_event_attributions credit on credit.event_id=event.id join performance_orders orders on orders.id=event.order_id where event.accounting_month=$1::date - and ($3::boolean or event.department_unit_id=any($2::bigint[])) + and ($3::boolean or credit.department_unit_id=any($2::bigint[])) ),active_department_goals as ( select distinct on (goal.org_unit_id) goal.id::text as goal_id,goal.org_unit_id, version.amount::numeric(14,2) as target_amount @@ -940,6 +970,7 @@ function commandFromBody(body: z.infer): PerformanceCommand function allowedActions(lifecycle: OrderRow["lifecycle_state"]): string[] { if (lifecycle === "active") return ["revenue_change", "pause"]; + if (lifecycle === "receivable_pending") return ["revenue_change"]; if (lifecycle === "paused") return ["restart"]; if (lifecycle === "zero") return ["first_include"]; return []; @@ -955,10 +986,17 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl app.get("/api/performance/people", async (request, reply) => { const denied = requireEditor(request, reply); if (denied) return denied; + const parsed = z.object({ occurredOn: dateSchema.optional() }).safeParse(request.query); + if (!parsed.success) return reply.code(400).send({ message: "日期格式无效" }); + const occurredOn = parsed.data.occurredOn ?? businessDate(clock()); const result = await db.query( - `select distinct p.id::text as id,p.display_name as "displayName" + `select p.id::text as id,p.display_name as "displayName",department.name as "departmentName",team.name as "groupName" from people p join org_memberships m on m.person_id=p.id + join org_units department on department.id=m.department_id and department.unit_type='department' + join org_units team on team.id=m.group_id and team.unit_type='group' + where m.effective_from<=$1::date and (m.effective_to is null or m.effective_to>=$1::date) order by "displayName",id`, + [occurredOn], ); return { people: result.rows }; }); @@ -986,12 +1024,22 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl return reply.code(403).send({ message: "当前角色没有业务查看权限" }); } const result = await client.query( - `with scoped_analysis as materialized ( - select e.delta_amount,dimensions.event_id as dimensions_event_id, + `with monthly_events as materialized ( + select id from performance_events where accounting_month=$1::date + ), scoped_credits as materialized ( + select credit.event_id,credit.attributed_amount + from performance_event_attributions credit + where ${performanceAttributionScopeSql("credit", 2)} + ), scoped_amounts as materialized ( + select scoped_credits.event_id,sum(scoped_credits.attributed_amount)::numeric(14,2) as delta_amount + from scoped_credits join monthly_events on monthly_events.id=scoped_credits.event_id + group by scoped_credits.event_id + ), + scoped_analysis as materialized ( + select scoped_amounts.delta_amount,dimensions.event_id as dimensions_event_id, dimensions.business_region_code,dimensions.customer_unit - from performance_events e - left join performance_event_analysis_dimensions dimensions on dimensions.event_id=e.id - where e.accounting_month=$1::date and ${performanceScopeSql("e", 2)} + from scoped_amounts + left join performance_event_analysis_dimensions dimensions on dimensions.event_id=scoped_amounts.event_id ), summary as ( select count(*)::bigint as ledger_count, coalesce(sum(delta_amount),0.00) as ledger_amount, @@ -1104,14 +1152,17 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const result = await client.query( `with dimension_cutoff as ( select coalesce($10::bigint,max(dimension_sequence)) as sequence from performance_event_analysis_dimensions + ), monthly_events as materialized ( + select id from performance_events where accounting_month=$1::date + ), scoped_amounts as materialized (${scopedEventAmountsSql(3)} ), raw_scoped_analysis as materialized ( - select e.id as event_id,e.delta_amount,dimensions.customer_unit - from performance_events e + select e.id as event_id,scoped_amounts.delta_amount,dimensions.customer_unit + from monthly_events e + join scoped_amounts on scoped_amounts.event_id=e.id join performance_event_analysis_dimensions dimensions on dimensions.event_id=e.id cross join dimension_cutoff - where e.accounting_month=$1::date and dimensions.business_region_code=$2 + where dimensions.business_region_code=$2 and dimensions.dimension_sequence<=dimension_cutoff.sequence - and ${performanceScopeSql("e", 3)} ), cutoff as ( select coalesce($9::bigint,max(event_id)) as event_id from raw_scoped_analysis ), scoped_analysis as materialized ( @@ -1163,13 +1214,16 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl } if (parsed.data.level === "months") { const result = await client.query( - `with scoped_analysis as materialized ( - select to_char(e.accounting_month,'YYYY-MM') as month,e.delta_amount - from performance_events e + `with yearly_events as materialized ( + select id,accounting_month from performance_events + where accounting_month >= $1::date and accounting_month < $1::date + interval '1 year' + ), scoped_amounts as materialized (${scopedEventAmountsSql(4)}), + scoped_analysis as materialized ( + select to_char(e.accounting_month,'YYYY-MM') as month,scoped_amounts.delta_amount + from yearly_events e + join scoped_amounts on scoped_amounts.event_id=e.id join performance_event_analysis_dimensions dimensions on dimensions.event_id=e.id - where e.accounting_month >= $1::date and e.accounting_month < $1::date + interval '1 year' - and dimensions.business_region_code=$2 and dimensions.customer_unit=$3 - and ${performanceScopeSql("e", 4)} + where dimensions.business_region_code=$2 and dimensions.customer_unit=$3 ), months as ( select month,count(*)::bigint as event_count,sum(delta_amount) as total_amount from scoped_analysis group by month @@ -1203,9 +1257,12 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const result = await client.query( `with dimension_cutoff as ( select coalesce($10::bigint,max(dimension_sequence)) as sequence from performance_event_analysis_dimensions + ), monthly_events as materialized ( + select * from performance_events where accounting_month=$1::date + ), scoped_amounts as materialized (${scopedEventAmountsSql(4)} ), raw_scoped_analysis as materialized ( select e.id as "__eventId",e.id::text,e.order_id::text as "orderId",orders.qingflow_order_no as "orderNo", - orders.customer_name as "customerName",e.event_type as "eventType",e.delta_amount::text as "deltaAmount", + orders.customer_name as "customerName",e.event_type as "eventType",scoped_amounts.delta_amount::text as "deltaAmount", e.resulting_current_revenue::text as "resultingCurrentRevenue", e.resulting_counted_amount::text as "resultingCountedAmount", ${resultingLifecycleStateSql("e")} as "resultingLifecycleState", @@ -1213,13 +1270,13 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl e.salesperson_name as "salespersonName",e.department_name as "departmentName",e.group_name as "groupName", e.order_sequence as sequence,dimensions.business_region_code as "businessRegionCode", dimensions.business_region_source_text as "businessRegionSourceText",dimensions.customer_unit as "customerUnit" - from performance_events e + from monthly_events e + join scoped_amounts on scoped_amounts.event_id=e.id join performance_event_analysis_dimensions dimensions on dimensions.event_id=e.id join performance_orders orders on orders.id=e.order_id cross join dimension_cutoff - where e.accounting_month=$1::date and dimensions.business_region_code=$2 and dimensions.customer_unit=$3 + where dimensions.business_region_code=$2 and dimensions.customer_unit=$3 and dimensions.dimension_sequence<=dimension_cutoff.sequence - and ${performanceScopeSql("e", 4)} ), cutoff as ( select coalesce($9::bigint,max("__eventId")) as event_id from raw_scoped_analysis ), scoped_analysis as materialized ( @@ -1302,27 +1359,31 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const canViewSalesAchievement = hasAnyRole(currentUser, SALES_ACHIEVEMENT_ROLES); const canViewDepartmentAchievement = hasAnyRole(currentUser, DEPARTMENT_ACHIEVEMENT_ROLES); const metrics = await client.query<{ total: string; event_count: string; negative_total: string }>( - `select coalesce(sum(delta_amount),0)::text as total, count(*)::text as event_count, - coalesce(sum(delta_amount) filter (where delta_amount < 0),0)::text as negative_total - from performance_events e where accounting_month = $1 and ${performanceScopeSql("e", 2)}`, [`${month}-01`, ...scopeValues]); + `select coalesce(sum(credit.attributed_amount),0)::text as total,count(distinct e.id)::text as event_count, + coalesce(sum(credit.attributed_amount) filter (where credit.attributed_amount < 0),0)::text as negative_total + from performance_events e join performance_event_attributions credit on credit.event_id=e.id + where e.accounting_month = $1 and ${performanceAttributionScopeSql("credit", 2)}`, [`${month}-01`, ...scopeValues]); const monthly = await client.query<{ month: string; total: string }>( - `select to_char(accounting_month, 'YYYY-MM') as month, sum(delta_amount)::text as total - from performance_events e where extract(year from accounting_month) = extract(year from $1::date) - and ${performanceScopeSql("e", 2)} - group by accounting_month order by accounting_month`, [`${month}-01`, ...scopeValues]); + `select to_char(e.accounting_month, 'YYYY-MM') as month,sum(credit.attributed_amount)::text as total + from performance_events e join performance_event_attributions credit on credit.event_id=e.id + where extract(year from e.accounting_month) = extract(year from $1::date) + and ${performanceAttributionScopeSql("credit", 2)} + group by e.accounting_month order by e.accounting_month`, [`${month}-01`, ...scopeValues]); const groups = await client.query<{ id: string; name: string; total: string }>( - `select coalesce(e.group_unit_id::text,'legacy:'||e.group_name) as id, - string_agg(distinct e.group_name,' / ' order by e.group_name) as name, - sum(e.delta_amount)::text as total - from performance_events e where e.accounting_month = $1 and ${performanceScopeSql("e", 2)} - group by coalesce(e.group_unit_id::text,'legacy:'||e.group_name) - order by sum(e.delta_amount) desc limit 5`, [`${month}-01`, ...scopeValues]); + `select coalesce(credit.group_unit_id::text,'legacy:'||credit.group_name) as id, + string_agg(distinct credit.group_name,' / ' order by credit.group_name) as name, + sum(credit.attributed_amount)::text as total + from performance_events e join performance_event_attributions credit on credit.event_id=e.id + where e.accounting_month = $1 and ${performanceAttributionScopeSql("credit", 2)} + group by coalesce(credit.group_unit_id::text,'legacy:'||credit.group_name) + order by sum(credit.attributed_amount) desc limit 5`, [`${month}-01`, ...scopeValues]); const recent = await client.query( - `select o.qingflow_order_no as "orderNo", e.salesperson_name as "salespersonName", + `select o.qingflow_order_no as "orderNo",credit.salesperson_name as "salespersonName", e.event_type as "eventType", to_char(e.accounting_month, 'YYYY-MM') as month, - e.delta_amount::text as amount, e.group_name as "groupName" - from performance_events e join performance_orders o on o.id = e.order_id - where e.accounting_month=$1 and ${performanceScopeSql("e", 2)} + credit.attributed_amount::text as amount,credit.group_name as "groupName" + from performance_events e join performance_event_attributions credit on credit.event_id=e.id + join performance_orders o on o.id = e.order_id + where e.accounting_month=$1 and ${performanceAttributionScopeSql("credit", 2)} order by e.created_at desc, e.id desc limit 8`, [`${month}-01`, ...scopeValues]); const pending = await client.query<{ count: string }>( `select count(*)::text as count from goal_versions v join goals g on g.id=v.goal_id @@ -1457,11 +1518,13 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl select performance_orders.id as "__id",performance_orders.id::text,performance_orders.created_at as "__cursorCreatedAt", qingflow_order_no as "orderNo",customer_name as "customerName",customer_unit as "customerUnit", performance_orders.salesperson_name as "salespersonName",service_type as "serviceType", + business_region_source_text as "businessRegionSourceText",business_region_code as "businessRegionCode", + performance_orders.collaborator_name as "collaboratorName",performance_orders.collaboration_ratio::text as "collaborationRatio", source_received_on as "sourceReceivedOn",original_amount::text as "originalAmount", current_revenue::text as "currentRevenue",counted_amount::text as "countedAmount", lifecycle_state as "lifecycleState",posted_at as "postedAt", latest.department_name as "departmentName",latest.group_name as "groupName", - latest.leader_name as "leaderName",latest.supervisor_name as "supervisorName" + latest.leader_name as "leaderName",latest.supervisor_name as "supervisorName",latest.note from performance_orders ${latestOrderEventJoinSql("performance_orders", "latest")} cross join cutoff @@ -1491,11 +1554,13 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const result = await db.query( `select id::text, created_at as "__cursorCreatedAt",qingflow_order_no as "orderNo", customer_name as "customerName", customer_unit as "customerUnit", performance_orders.salesperson_name as "salespersonName", service_type as "serviceType", + business_region_source_text as "businessRegionSourceText",business_region_code as "businessRegionCode", + performance_orders.collaborator_name as "collaboratorName",performance_orders.collaboration_ratio::text as "collaborationRatio", source_received_on as "sourceReceivedOn", original_amount::text as "originalAmount", current_revenue::text as "currentRevenue", counted_amount::text as "countedAmount", lifecycle_state as "lifecycleState", posted_at as "postedAt", latest.department_name as "departmentName", latest.group_name as "groupName", - latest.leader_name as "leaderName", latest.supervisor_name as "supervisorName" + latest.leader_name as "leaderName", latest.supervisor_name as "supervisorName",latest.note from performance_orders ${latestOrderEventJoinSql("performance_orders", "latest")} where ${performanceScopeSql("latest", 2)} @@ -1553,6 +1618,9 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl e.occurred_on::text as "occurredOn", e.reason, e.salesperson_name as "salespersonName", e.department_name as "departmentName", e.group_name as "groupName", e.leader_name as "leaderName", e.supervisor_name as "supervisorName", e.occurred_at as "occurredAt", e.order_sequence as sequence, + e.service_type as "serviceType",e.collaborator_name as "collaboratorName", + e.collaboration_ratio::text as "collaborationRatio",e.collaborator_department_name as "collaboratorDepartmentName", + e.collaborator_group_name as "collaboratorGroupName", dimensions.business_region_code as "businessRegionCode", dimensions.business_region_source_text as "businessRegionSourceText", dimensions.customer_unit as "customerUnit", @@ -1586,15 +1654,19 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl await client.query("begin"); await assertAccountingPeriodOpen(client, accountingMonth(input.sourceReceivedOn)); const organization = await resolveOrganization(client, String(input.salespersonPersonId), input.sourceReceivedOn); + const collaboratorOrganization = input.collaboratorPersonId + ? await resolveOrganization(client, String(input.collaboratorPersonId), input.sourceReceivedOn) + : null; const order = await client.query<{ id: string }>( `insert into performance_orders (qingflow_order_no, customer_name, customer_unit, business_region_source_text, business_region_code, - salesperson_person_id, salesperson_name, service_type, source_received_on, - original_amount, current_revenue, counted_amount, lifecycle_state, created_by, posted_at) - values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now()) returning id::text`, + salesperson_person_id, salesperson_name, service_type, collaborator_person_id,collaborator_name,collaboration_ratio, + source_received_on,original_amount,current_revenue,counted_amount,lifecycle_state,created_by,posted_at) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,now()) returning id::text`, [input.orderNo, input.customerName, input.customerUnit, input.businessRegionSourceText, input.businessRegionCode, - organization.personId, organization.salespersonName, input.serviceType || null, input.sourceReceivedOn, - input.amount, decision.next.currentRevenue, decision.next.countedAmount, decision.next.lifecycle, request.currentUser!.id], + organization.personId, organization.salespersonName, input.serviceType || null, + collaboratorOrganization?.personId ?? null,collaboratorOrganization?.salespersonName ?? null,input.collaborationRatio ?? null, + input.sourceReceivedOn,input.amount,decision.next.currentRevenue,decision.next.countedAmount,decision.next.lifecycle,request.currentUser!.id], ); const orderId = order.rows[0]!.id; const event = await client.query<{id:string}>( @@ -1602,14 +1674,23 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl (order_id, event_type, delta_amount, resulting_current_revenue, resulting_counted_amount, accounting_month, occurred_on, reason, salesperson_name, department_name, group_name, leader_name, supervisor_name, created_by, salesperson_person_id, department_unit_id, - group_unit_id, leader_person_id, supervisor_person_id) - values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) + group_unit_id, leader_person_id, supervisor_person_id,service_type, + collaborator_person_id,collaborator_name,collaboration_ratio,collaborator_department_unit_id,collaborator_department_name, + collaborator_group_unit_id,collaborator_group_name,collaborator_leader_person_id,collaborator_leader_name, + collaborator_supervisor_person_id,collaborator_supervisor_name) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31) returning id::text`, [orderId, decision.eventType, decision.deltaAmount, decision.next.currentRevenue, decision.next.countedAmount, accountingMonth(input.sourceReceivedOn), input.sourceReceivedOn, input.reason, organization.salespersonName, organization.departmentName, organization.groupName, organization.leaderName, organization.supervisorName, request.currentUser!.id, organization.personId, organization.departmentId, - organization.groupId, organization.leaderPersonId, organization.supervisorPersonId], + organization.groupId, organization.leaderPersonId, organization.supervisorPersonId,input.serviceType || null, + collaboratorOrganization?.personId ?? null,collaboratorOrganization?.salespersonName ?? null,input.collaborationRatio ?? null, + collaboratorOrganization?.departmentId ?? null,collaboratorOrganization?.departmentName ?? null, + collaboratorOrganization?.groupId ?? null,collaboratorOrganization?.groupName ?? null, + collaboratorOrganization?.leaderPersonId ?? null,collaboratorOrganization?.leaderName ?? null, + collaboratorOrganization?.supervisorPersonId ?? null,collaboratorOrganization?.supervisorName ?? null], ); await recordEventAnalysisDimensions(client,event.rows[0]!.id,{ businessRegionCode:input.businessRegionCode, @@ -1652,7 +1733,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl await client.query("begin"); const found = await client.query( `select id::text, qingflow_order_no, customer_name, customer_unit, business_region_source_text, business_region_code, - salesperson_person_id::text, salesperson_name, service_type, + salesperson_person_id::text, salesperson_name, service_type,collaborator_person_id::text,collaborator_name,collaboration_ratio::text, source_received_on::text, original_amount::text, current_revenue::text, counted_amount::text, lifecycle_state from performance_orders where id = $1 for update`, @@ -1703,19 +1784,32 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const eventAccountingMonth=correction?.periodMonth ?? accountingMonth(occurredOn); if (!correction) await assertAccountingPeriodOpen(client, eventAccountingMonth); const organization = await resolveOrganization(client, order.salesperson_person_id, occurredOn); + const collaboratorOrganization = order.collaborator_person_id + ? await resolveOrganization(client, order.collaborator_person_id, occurredOn) + : null; const inserted = await client.query<{ id: string }>( `insert into performance_events (order_id, event_type, delta_amount, resulting_current_revenue, resulting_counted_amount, accounting_month, occurred_on, reason, salesperson_name, department_name, group_name, leader_name, supervisor_name, created_by, salesperson_person_id, department_unit_id, - group_unit_id, leader_person_id, supervisor_person_id,occurred_at,idempotency_key,request_fingerprint) - values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + group_unit_id, leader_person_id, supervisor_person_id,service_type, + collaborator_person_id,collaborator_name,collaboration_ratio,collaborator_department_unit_id,collaborator_department_name, + collaborator_group_unit_id,collaborator_group_name,collaborator_leader_person_id,collaborator_leader_name, + collaborator_supervisor_person_id,collaborator_supervisor_name, + occurred_at,idempotency_key,request_fingerprint) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34) returning id::text`, [params.data.id, decision.eventType, decision.deltaAmount, decision.next.currentRevenue, decision.next.countedAmount, eventAccountingMonth, occurredOn, parsed.data.reason, organization.salespersonName, organization.departmentName, organization.groupName, organization.leaderName, organization.supervisorName, request.currentUser!.id, organization.personId, - organization.departmentId, organization.groupId, organization.leaderPersonId, organization.supervisorPersonId, + organization.departmentId, organization.groupId, organization.leaderPersonId, organization.supervisorPersonId,order.service_type, + collaboratorOrganization?.personId ?? null,collaboratorOrganization?.salespersonName ?? null,order.collaboration_ratio ? Number(order.collaboration_ratio) : null, + collaboratorOrganization?.departmentId ?? null,collaboratorOrganization?.departmentName ?? null, + collaboratorOrganization?.groupId ?? null,collaboratorOrganization?.groupName ?? null, + collaboratorOrganization?.leaderPersonId ?? null,collaboratorOrganization?.leaderName ?? null, + collaboratorOrganization?.supervisorPersonId ?? null,collaboratorOrganization?.supervisorName ?? null, operationTime.toISOString(),parsed.data.idempotencyKey,fingerprint], ); await recordEventAnalysisDimensions(client,inserted.rows[0]!.id,{ diff --git a/apps/api/src/services/import-job.ts b/apps/api/src/services/import-job.ts index 6d334a8..552e9f0 100644 --- a/apps/api/src/services/import-job.ts +++ b/apps/api/src/services/import-job.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import type { PoolClient } from "pg"; import type { Database } from "../db.js"; -import { postgresBigintIdSchema } from "../validation.js"; +import { isSignedMoney, postgresBigintIdSchema } from "../validation.js"; import { businessDate } from "../domain/business-time.js"; import { AccountingPeriodError, accountingMonth, consumeApprovedCorrection, lockApprovedCorrection } from "../modules/accounting-periods.js"; import type { OrganizationSnapshot } from "../modules/organization.js"; @@ -16,13 +16,18 @@ export type ImportSourceRow = Readonly<{ businessSequence?: number; correctionRequestId?: string; sourceRecordId?: string; + sourceMonth?: string; orderNo: string; occurredOn: string; customerName: string; customerUnit: string; businessRegionSourceText: string; salespersonSourceKey: string; + sourceDepartment?: string; + sourceGroup?: string; serviceType: string; + collaboratorSourceKey?: string; + collaborationRatio?: number; eventType: string; amount: number; reason: string; @@ -42,6 +47,8 @@ type NormalizedRow = Omit & Readonly<{ businessRegionCode: string; personId: string; organization: OrganizationSnapshot; + collaboratorPersonId?: string; + collaboratorOrganization?: OrganizationSnapshot; }>; type ExistingImportRecord = Readonly<{ @@ -107,7 +114,7 @@ type ImportConfigRow = { business_region_mapping: Record; expected_reconciliation: ImportReconciliationSummary | null; allow_legacy_source_key: boolean; - fixed_event_type: "legacy_adjustment" | null; + fixed_event_type: "initial" | "legacy_adjustment" | null; }; export class ImportJobError extends Error {} @@ -122,8 +129,9 @@ function sha256(value: Uint8Array | string): string { function sourcePayloadFingerprint(row: ImportSourceRow): string { return sha256(JSON.stringify([ - row.orderNo, row.occurredOn, row.businessSequence ?? null, row.correctionRequestId ?? null, row.customerName, row.customerUnit, row.businessRegionSourceText, - row.salespersonSourceKey, row.serviceType, row.eventType, row.amount, row.reason, + row.orderNo, row.occurredOn, row.sourceMonth ?? null, row.businessSequence ?? null, row.correctionRequestId ?? null, row.customerName, row.customerUnit, row.businessRegionSourceText, + row.salespersonSourceKey, row.sourceDepartment ?? null, row.sourceGroup ?? null, row.serviceType, + row.collaboratorSourceKey ?? null, row.collaborationRatio ?? null, row.eventType, row.amount, row.reason, ])); } @@ -198,6 +206,29 @@ async function loadOrganizationSnapshots( }])); } +async function loadPeopleBySourceIdentity( + database: Pick, + sourceIdentities: readonly string[], +): Promise> { + const requested = [...new Set(sourceIdentities.filter(Boolean))]; + if (!requested.length) return new Map(); + const result = await database.query<{ id: string; source_key: string; display_name: string }>( + "select id::text,source_key,display_name from people where source_key=any($1::text[]) or display_name=any($1::text[])", + [requested], + ); + const resolved = new Map(); + for (const identity of requested) { + const sourceKeyMatch = result.rows.find((person) => person.source_key === identity); + if (sourceKeyMatch) { + resolved.set(identity, sourceKeyMatch.id); + continue; + } + const displayNameMatches = result.rows.filter((person) => person.display_name === identity); + if (displayNameMatches.length === 1) resolved.set(identity, displayNameMatches[0]!.id); + } + return resolved; +} + async function loadExistingImportRecords(database: Pick, rows: readonly NormalizedRow[]): Promise { if (!rows.length) return []; const sourceKeys = rows.map((row) => row.sourceKey); @@ -456,13 +487,9 @@ function normalizeRow( if (!validDate(row.occurredOn) || row.occurredOn > today) { addIssue(issues, row, "OCCURRED_ON_INVALID", "发生日期无效或晚于当前业务日"); } - const centValue = row.amount * 100; - if (!Number.isFinite(row.amount) || Math.abs(row.amount) > 99_999_999_999.99 || Math.abs(centValue - Math.round(centValue)) > 1e-7) { + if (!isSignedMoney(row.amount)) { addIssue(issues, row, "AMOUNT_INVALID", "金额必须是有效的两位小数范围数字"); } - if ((row.eventType === "initial" || row.eventType === "revenue_change") && row.amount < 0) { - addIssue(issues, row, "INITIAL_AMOUNT_NEGATIVE", "首次订单金额不能为负数"); - } if (row.eventType === "first_include" && row.amount <= 0) addIssue(issues, row, "FIRST_INCLUDE_AMOUNT_INVALID", "首次计入金额必须大于零"); if ((row.eventType === "pause" || row.eventType === "restart") && row.amount !== 0) addIssue(issues, row, "STATE_EVENT_AMOUNT_INVALID", "暂停或重启事件的金额必须为 0"); if (row.eventType === "legacy_adjustment" && config.fixed_event_type !== "legacy_adjustment") { @@ -480,7 +507,31 @@ function normalizeRow( if (personId && validDate(row.occurredOn) && !organization) { addIssue(issues, row, "ORGANIZATION_NOT_RESOLVED", "发生日期找不到唯一有效组织任职及负责人"); } - if (!businessRegionCode || !personId || !organization || !eventType) return null; + if (row.sourceMonth !== undefined && validDate(row.occurredOn) && row.sourceMonth !== `${Number(row.occurredOn.slice(5, 7))}月`) { + addIssue(issues, row, "SOURCE_MONTH_MISMATCH", "收样月份必须与日期月份一致"); + } + if (organization && row.sourceDepartment !== undefined && row.sourceDepartment !== organization.departmentName) { + addIssue(issues, row, "SOURCE_DEPARTMENT_MISMATCH", "部门与日期当天的组织任职不一致"); + } + if (organization && row.sourceGroup !== undefined && row.sourceGroup !== organization.groupName) { + addIssue(issues, row, "SOURCE_GROUP_MISMATCH", "组别与日期当天的组织任职不一致"); + } + const hasCollaborator = Boolean(row.collaboratorSourceKey); + const hasRatio = row.collaborationRatio !== undefined; + if (hasCollaborator !== hasRatio) addIssue(issues, row, "COLLABORATION_INCOMPLETE", "协作人和协作比例必须同时填写或同时留空"); + if (hasRatio && (!Number.isFinite(row.collaborationRatio) || row.collaborationRatio! <= 0 || row.collaborationRatio! >= 1 || Math.abs(row.collaborationRatio! * 1_000_000 - Math.round(row.collaborationRatio! * 1_000_000)) > 1e-7)) { + addIssue(issues, row, "COLLABORATION_RATIO_INVALID", "协作比例必须大于 0、小于 1,最多保留六位小数"); + } + const collaboratorPersonId = hasCollaborator ? peopleBySourceKey.get(row.collaboratorSourceKey!) : undefined; + if (hasCollaborator && !collaboratorPersonId) addIssue(issues, row, "COLLABORATOR_NOT_FOUND", "协作人来源标识无法唯一解析"); + if (collaboratorPersonId && collaboratorPersonId === personId) addIssue(issues, row, "COLLABORATOR_EQUALS_PRIMARY", "协作人不能与业务员相同"); + const collaboratorOrganization = collaboratorPersonId && validDate(row.occurredOn) + ? organizations.get(organizationKey(collaboratorPersonId, row.occurredOn)) + : undefined; + if (collaboratorPersonId && validDate(row.occurredOn) && !collaboratorOrganization) { + addIssue(issues, row, "COLLABORATOR_ORGANIZATION_NOT_RESOLVED", "发生日期找不到协作人的唯一有效组织任职及负责人"); + } + if (!businessRegionCode || !personId || !organization || !eventType || (hasCollaborator && (!collaboratorPersonId || !collaboratorOrganization))) return null; const sourceKey = row.sourceRecordId ? `${config.config_key}:${row.sourceRecordId}` @@ -492,7 +543,10 @@ function normalizeRow( row.amount, row.reason, ])); - return { ...row, eventType, sourceKey, duplicateFingerprint, businessRegionCode, personId, organization }; + return { + ...row, eventType, sourceKey, duplicateFingerprint, businessRegionCode, personId, organization, + ...(collaboratorPersonId && collaboratorOrganization ? { collaboratorPersonId, collaboratorOrganization } : {}), + }; } function normalizeDimensionBackfillRow( @@ -514,8 +568,7 @@ function normalizeDimensionBackfillRow( if (!row.customerUnit || row.customerUnit.length > 300) addIssue(issues, row, "CUSTOMER_UNIT_INVALID", "客户单位必须是 1 至 300 个字符"); if (!row.businessRegionSourceText || row.businessRegionSourceText.length > 100) addIssue(issues, row, "BUSINESS_REGION_SOURCE_INVALID", "业务区域原文必须是 1 至 100 个字符"); if (row.reason.length > 500) addIssue(issues, row, "REASON_INVALID", "原因不能超过 500 个字符"); - const centValue = row.amount * 100; - if (!Number.isFinite(row.amount) || Math.abs(row.amount) > 99_999_999_999.99 || Math.abs(centValue - Math.round(centValue)) > 1e-7) { + if (!isSignedMoney(row.amount)) { addIssue(issues, row, "AMOUNT_INVALID", "金额必须是有效的两位小数范围数字"); } const businessRegionCode = config.business_region_mapping[row.businessRegionSourceText]; @@ -666,14 +719,16 @@ export async function preflightImportRows(database: Database, input: Readonly<{ const orderNumberForms = new Map(); const initialOrderNos = new Set(); const reconciliationSourceKeys = new Set(); - const sourceKeys = [...new Set(input.rows.map((row) => row.salespersonSourceKey))]; - const people = sourceKeys.length - ? await database.query<{ id: string; source_key: string }>("select id::text,source_key from people where source_key=any($1::text[])", [sourceKeys]) - : { rows: [] }; - const peopleBySourceKey = new Map(people.rows.map((person) => [person.source_key, person.id])); + const sourceKeys = [...new Set(input.rows.flatMap((row) => [row.salespersonSourceKey, row.collaboratorSourceKey ?? ""]))]; + const peopleBySourceKey = await loadPeopleBySourceIdentity(database, sourceKeys); const organizationPairs = input.rows.flatMap((row) => { const personId = peopleBySourceKey.get(row.salespersonSourceKey); - return personId && validDate(row.occurredOn) ? [{ personId, occurredOn: row.occurredOn }] : []; + const collaboratorPersonId = row.collaboratorSourceKey ? peopleBySourceKey.get(row.collaboratorSourceKey) : undefined; + if (!validDate(row.occurredOn)) return []; + return [ + ...(personId ? [{ personId, occurredOn: row.occurredOn }] : []), + ...(collaboratorPersonId ? [{ personId: collaboratorPersonId, occurredOn: row.occurredOn }] : []), + ]; }); const organizations = await loadOrganizationSnapshots(database, organizationPairs); const today = businessDate(new Date()); @@ -753,7 +808,8 @@ export async function preflightImportRows(database: Database, input: Readonly<{ if (normalized.length) { const existingFacts = await database.query>( `select qingflow_order_no,customer_name,customer_unit,business_region_source_text,business_region_code, - salesperson_person_id::text,service_type,source_received_on::text,original_amount::text, + salesperson_person_id::text,service_type,collaborator_person_id::text,collaboration_ratio::text, + source_received_on::text,original_amount::text, current_revenue::text,counted_amount::text,lifecycle_state, (select count(*)::int from performance_events where order_id=performance_orders.id and event_type<>'legacy_adjustment') non_legacy_event_count from performance_orders where qingflow_order_no=any($1::text[])`, @@ -1093,6 +1149,12 @@ function orderFactDifferences(existing: Record, row: Normalized if (existing.business_region_code !== row.businessRegionCode) differences.push("businessRegionCode"); if (String(existing.salesperson_person_id) !== row.personId) differences.push("salespersonPersonId"); if (String(existing.service_type ?? "") !== row.serviceType) differences.push("serviceType"); + if (row.eventType === "initial" || row.collaboratorPersonId !== undefined || row.collaborationRatio !== undefined) { + if (String(existing.collaborator_person_id ?? "") !== (row.collaboratorPersonId ?? "")) differences.push("collaboratorPersonId"); + if (existing.collaboration_ratio === null || existing.collaboration_ratio === undefined + ? row.collaborationRatio !== undefined + : Number(existing.collaboration_ratio) !== row.collaborationRatio) differences.push("collaborationRatio"); + } if (row.eventType === "initial") { if (String(existing.source_received_on).slice(0, 10) !== row.occurredOn) differences.push("sourceReceivedOn"); if (Number(existing.original_amount) !== row.amount) differences.push("originalAmount"); @@ -1109,6 +1171,8 @@ function batchOrderFactDifferences(left: NormalizedRow, right: NormalizedRow): s if (left.businessRegionCode !== right.businessRegionCode) differences.push("businessRegionCode"); if (left.personId !== right.personId) differences.push("salespersonPersonId"); if (left.serviceType !== right.serviceType) differences.push("serviceType"); + if (left.collaboratorPersonId !== right.collaboratorPersonId) differences.push("collaboratorPersonId"); + if (left.collaborationRatio !== right.collaborationRatio) differences.push("collaborationRatio"); return differences; } @@ -1126,15 +1190,25 @@ async function insertEvent( const inserted = await client.query<{ id: string }>( `insert into performance_events(order_id,event_type,delta_amount,resulting_current_revenue,resulting_counted_amount, accounting_month,occurred_on,reason,salesperson_name,department_name,group_name,leader_name,supervisor_name, - created_by,salesperson_person_id,department_unit_id,group_unit_id,leader_person_id,supervisor_person_id, + created_by,salesperson_person_id,department_unit_id,group_unit_id,leader_person_id,supervisor_person_id,service_type, + collaborator_person_id,collaborator_name,collaboration_ratio,collaborator_department_unit_id,collaborator_department_name, + collaborator_group_unit_id,collaborator_group_name,collaborator_leader_person_id,collaborator_leader_name, + collaborator_supervisor_person_id,collaborator_supervisor_name, import_batch_id,source_file_sha256,source_sheet,source_row_number,source_record_id,source_key,source_business_sequence) - select $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,b.source_sha256,$21,$22,$23,$24,$25 - from import_batches b where b.id=$20 returning id::text`, + select $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,b.source_sha256,$33,$34,$35,$36,$37 + from import_batches b where b.id=$32 returning id::text`, [orderId, eventType, deltaAmount, resultingCurrentRevenue, resultingCountedAmount, accountingMonth(row.occurredOn), row.occurredOn, row.reason, row.organization.salespersonName, row.organization.departmentName, row.organization.groupName, row.organization.leaderName, row.organization.supervisorName, actorUserId, row.organization.personId, row.organization.departmentId, row.organization.groupId, row.organization.leaderPersonId, - row.organization.supervisorPersonId, batchId, row.sheet, row.rowNumber, row.sourceRecordId ?? null, row.sourceKey, row.businessSequence ?? null], + row.organization.supervisorPersonId, row.serviceType || null, + row.collaboratorOrganization?.personId ?? null, row.collaboratorOrganization?.salespersonName ?? null, row.collaborationRatio ?? null, + row.collaboratorOrganization?.departmentId ?? null, row.collaboratorOrganization?.departmentName ?? null, + row.collaboratorOrganization?.groupId ?? null, row.collaboratorOrganization?.groupName ?? null, + row.collaboratorOrganization?.leaderPersonId ?? null, row.collaboratorOrganization?.leaderName ?? null, + row.collaboratorOrganization?.supervisorPersonId ?? null, row.collaboratorOrganization?.supervisorName ?? null, + batchId, row.sheet, row.rowNumber, row.sourceRecordId ?? null, row.sourceKey, row.businessSequence ?? null], ); const eventId=inserted.rows[0]!.id; await recordEventAnalysisDimensions(client,eventId,{ @@ -1354,12 +1428,22 @@ export async function confirmImportBatch(database: Database, batchId: string, ac const preflightRows = loaded.rows.map((item) => item.normalized_data); const batchRowIdsBySourceKey = new Map(loaded.rows.map((item) => [item.normalized_data.sourceKey, item.id])); ledgerAttempted = true; - const currentOrganizations = await loadOrganizationSnapshots(client, preflightRows.map((row) => ({ personId: row.personId, occurredOn: row.occurredOn }))); + const currentOrganizations = await loadOrganizationSnapshots(client, preflightRows.flatMap((row) => [ + { personId: row.personId, occurredOn: row.occurredOn }, + ...(row.collaboratorPersonId ? [{ personId: row.collaboratorPersonId, occurredOn: row.occurredOn }] : []), + ])); for (const row of preflightRows) { const current = currentOrganizations.get(organizationKey(row.personId, row.occurredOn)); if (!current || !sameOrganizationSnapshot(current, row.organization)) { throw new ImportJobError(`第 ${row.rowNumber} 行预检后的组织关系已变化,请重新预检`); } + const currentCollaborator = row.collaboratorPersonId + ? currentOrganizations.get(organizationKey(row.collaboratorPersonId, row.occurredOn)) + : undefined; + if (row.collaboratorPersonId && (!row.collaboratorOrganization || !currentCollaborator + || !sameOrganizationSnapshot(currentCollaborator, row.collaboratorOrganization))) { + throw new ImportJobError(`第 ${row.rowNumber} 行预检后的协作人组织关系已变化,请重新预检`); + } } const concurrentDuplicates = await loadExistingImportRecords(client, preflightRows); const existingBySourceKey = new Map(concurrentDuplicates.flatMap((item) => item.sourceKey ? [[item.sourceKey, item] as const] : [])); @@ -1403,6 +1487,7 @@ export async function confirmImportBatch(database: Database, batchId: string, ac performance_order.customer_name,performance_order.customer_unit, performance_order.business_region_source_text,performance_order.business_region_code, performance_order.salesperson_person_id::text,performance_order.service_type, + performance_order.collaborator_person_id::text,performance_order.collaboration_ratio::text, performance_order.source_received_on::text,performance_order.original_amount::text, (select count(*)::int from performance_events where order_id=performance_order.id and event_type<>'legacy_adjustment') non_legacy_event_count from performance_events event join performance_orders performance_order on performance_order.id=event.order_id @@ -1433,7 +1518,8 @@ export async function confirmImportBatch(database: Database, batchId: string, ac const existingOrders = orderNos.length ? await client.query>( `select id::text,qingflow_order_no,customer_name,customer_unit,business_region_source_text,business_region_code, - salesperson_person_id::text,service_type,source_received_on::text,original_amount::text, + salesperson_person_id::text,service_type,collaborator_person_id::text,collaboration_ratio::text, + source_received_on::text,original_amount::text, current_revenue::text,counted_amount::text,lifecycle_state, (select count(*)::int from performance_events where order_id=performance_orders.id) as event_count, (select count(*)::int from performance_events where order_id=performance_orders.id and event_type<>'legacy_adjustment') non_legacy_event_count @@ -1442,6 +1528,12 @@ export async function confirmImportBatch(database: Database, batchId: string, ac ) : { rows: [] }; const existingOrdersByOrderNo = new Map(existingOrders.rows.map((order) => [String(order.qingflow_order_no), order])); + const inheritedCollaboratorOrganizations = await loadOrganizationSnapshots(client, rows.flatMap((row) => { + const collaboratorPersonId = existingOrdersByOrderNo.get(row.orderNo)?.collaborator_person_id; + return collaboratorPersonId && !row.collaboratorPersonId + ? [{ personId: String(collaboratorPersonId), occurredOn: row.occurredOn }] + : []; + })); const sequenceHistory = await loadOrderSequenceHistory(client, [...normalOrderNos]); await assertAccountingPeriodsOpen(client, rows.flatMap((row) => row.correctionRequestId === undefined ? [accountingMonth(row.occurredOn)] : [])); @@ -1474,12 +1566,13 @@ export async function confirmImportBatch(database: Database, batchId: string, ac } else { const inserted = await client.query<{ id: string }>( `insert into performance_orders(qingflow_order_no,customer_name,customer_unit,business_region_source_text,business_region_code, - salesperson_person_id,salesperson_name,service_type,source_received_on,original_amount,current_revenue,counted_amount, - lifecycle_state,created_by,posted_at) - values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,0,$11,$12,now()) returning id::text`, + salesperson_person_id,salesperson_name,service_type,collaborator_person_id,collaborator_name,collaboration_ratio, + source_received_on,original_amount,current_revenue,counted_amount,lifecycle_state,created_by,posted_at) + values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,0,$14,$15,now()) returning id::text`, [orderNo, first.customerName, first.customerUnit, first.businessRegionSourceText, first.businessRegionCode, - first.organization.personId, first.organization.salespersonName, first.serviceType || null, first.occurredOn, - Math.max(0, first.amount), "draft", actorUserId], + first.organization.personId, first.organization.salespersonName, first.serviceType || null, + first.collaboratorPersonId ?? null, first.collaboratorOrganization?.salespersonName ?? null, first.collaborationRatio ?? null, + first.occurredOn, first.amount, "draft", actorUserId], ); orderId = inserted.rows[0]!.id; importedOrders += 1; @@ -1490,6 +1583,19 @@ export async function confirmImportBatch(database: Database, batchId: string, ac if (sequenceIssues.length) throw new ImportJobError(`确认时事件顺序已变化,请重新预检:${sequenceIssues[0]!.message}`); } for (const row of orderRows) { + let eventRow = row; + if (existing?.collaborator_person_id && !row.collaboratorPersonId && row.eventType !== "legacy_adjustment") { + const collaboratorPersonId = String(existing.collaborator_person_id); + const collaboratorOrganization = inheritedCollaboratorOrganizations.get(organizationKey(collaboratorPersonId, row.occurredOn)); + if (!collaboratorOrganization) throw new ImportJobError(`第 ${row.rowNumber} 行发生日期找不到协作人的唯一有效组织任职及负责人`); + eventRow = { + ...row, + collaboratorPersonId, + collaboratorSourceKey: collaboratorOrganization.salespersonName, + collaborationRatio: Number(existing.collaboration_ratio), + collaboratorOrganization, + }; + } let correction = null; if (row.correctionRequestId !== undefined) { correction = await lockApprovedCorrection(client, row.correctionRequestId, orderId, row.eventType, actorPersonId, now); @@ -1505,7 +1611,7 @@ export async function confirmImportBatch(database: Database, batchId: string, ac let eventId: string; if (row.eventType === "legacy_adjustment") { counted = Math.round((counted + row.amount) * 100) / 100; - eventId = await insertEvent(client, batchId, orderId, row, actorUserId, row.eventType, row.amount, Math.max(0, counted), counted); + eventId = await insertEvent(client, batchId, orderId, eventRow, actorUserId, row.eventType, row.amount, Math.max(0, counted), counted); if (!latestLegacyProjection || compareLegacyProjectionRows(latestLegacyProjection.row,row)<0) { latestLegacyProjection={row,eventId}; } @@ -1518,7 +1624,7 @@ export async function confirmImportBatch(database: Database, batchId: string, ac const reason = error instanceof PerformanceRuleError ? error.message : "事件链无效"; throw new ImportJobError(`第 ${row.rowNumber} 行事件链已变化,请重新预检:${reason}`); } - eventId = await insertEvent(client, batchId, orderId, row, actorUserId, decision.eventType, decision.deltaAmount, + eventId = await insertEvent(client, batchId, orderId, eventRow, actorUserId, decision.eventType, decision.deltaAmount, decision.next.currentRevenue, decision.next.countedAmount); state = decision.next; } diff --git a/apps/api/src/test-database.integration.test.ts b/apps/api/src/test-database.integration.test.ts index 94cce6f..a58cf40 100644 --- a/apps/api/src/test-database.integration.test.ts +++ b/apps/api/src/test-database.integration.test.ts @@ -85,6 +85,7 @@ test("干净隔离数据库可应用全部现有迁移", async () => { "023_immutable_confirmations_and_audit.sql", "024_auth_throttle_cleanup.sql", "025_enforce_performance_order_state_amounts.sql", + "026_sheet3_order_input.sql", ]); assert.ok(result.rows.every((row) => /^[a-f0-9]{64}$/.test(row.sha256))); await assert.rejects( diff --git a/apps/api/src/validation.test.ts b/apps/api/src/validation.test.ts index 3448dc2..fe8cd2a 100644 --- a/apps/api/src/validation.test.ts +++ b/apps/api/src/validation.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { nonnegativeMoneySchema } from "./validation.js"; +import { nonnegativeMoneySchema, signedMoneySchema } from "./validation.js"; test("金额只接受非负且最多两位小数的有限数字", () => { assert.equal(nonnegativeMoneySchema.safeParse(12.34).success, true); @@ -9,3 +9,10 @@ test("金额只接受非负且最多两位小数的有限数字", () => { assert.equal(nonnegativeMoneySchema.safeParse(value).success, false); } }); + +test("系统营业额允许以负数表示应收未收", () => { + assert.equal(signedMoneySchema.safeParse(-12.34).success, true); + assert.equal(signedMoneySchema.safeParse(-85_521_505_025.01).success, true); + assert.equal(signedMoneySchema.safeParse(36_582_488_170.77).success, true); + assert.equal(signedMoneySchema.safeParse(-0.001).success, false); +}); diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 258a39e..e6a87a3 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -4,12 +4,15 @@ export const postgresBigintIdSchema = z.string().refine( (value) => /^[1-9]\d*$/.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n, ); -export const nonnegativeMoneySchema = z.number().finite().min(0).max(99_999_999_999.99) - .refine((value) => { - const cents = value * 100; - const tolerance = Number.EPSILON * Math.max(1, Math.abs(cents)) * 4; - return Math.abs(cents - Math.round(cents)) <= tolerance; - }, "金额最多保留两位小数"); +export function isSignedMoney(value: number): boolean { + return Number.isFinite(value) + && Math.abs(value) <= 99_999_999_999.99 + && Math.round(value * 100) / 100 === value; +} + +export const signedMoneySchema = z.number().refine(isSignedMoney, "金额必须是有效的两位小数范围数字"); + +export const nonnegativeMoneySchema = signedMoneySchema.min(0); export const pageNumberSchema=z.coerce.number().int().min(1).max(1_000_000); export const pageSizeSchema=z.coerce.number().int().refine((value)=>[10,20,50,100].includes(value)); diff --git a/apps/web/e2e/analysis.spec.ts b/apps/web/e2e/analysis.spec.ts index a368f1d..b6f790a 100644 --- a/apps/web/e2e/analysis.spec.ts +++ b/apps/web/e2e/analysis.spec.ts @@ -98,7 +98,7 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 const provinces = analysis.getByRole("table", { name: "省份汇总" }); await expect(provinces.getByRole("row", { name: /江苏省.*102.*¥101,000,000,000,098\.99/ })).toBeVisible(); await expect(provinces.getByRole("row", { name: /浙江省.*1.*¥50\.00/ })).toBeVisible(); - await expect(provinces.getByRole("row", { name: /CN-TW.*1.*¥10\.00/ })).toBeVisible(); + await expect(provinces.getByRole("row", { name: /台湾省.*1.*¥10\.00/ })).toBeVisible(); await expect(provinces.getByText("外贸", { exact: true })).toHaveCount(0); await expect(analysis.getByText("外贸(EXT-TRADE)", { exact: true })).toBeVisible(); await expect(analysis.getByText("1 条事件 · -¥25.00", { exact: true })).toBeVisible(); diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts index 3f17740..2c8ad27 100644 --- a/apps/web/e2e/login.spec.ts +++ b/apps/web/e2e/login.spec.ts @@ -98,7 +98,7 @@ test("销售助理组长可在桌面端预检并确认合成历史分析维度 const sourceBytes=await readFile(importTemplate);const sourceHash=createHash("sha256").update(sourceBytes).digest("hex"); const client=new Client({connectionString:database.url});await client.connect(); try{ - const standard=await client.query<{sheet_name:string;expected_headers:unknown[];column_mapping:ImportLayout["columnMapping"]}>("select sheet_name,expected_headers,column_mapping from import_configs where config_key='standard-performance'"); + const standard=await client.query<{sheet_name:string;expected_headers:unknown[];column_mapping:ImportLayout["columnMapping"]}>("select sheet_name,expected_headers,column_mapping from import_configs where config_key='standard-performance' order by version desc limit 1"); const columnMapping={...standard.rows[0]!.column_mapping};delete columnMapping.sourceRecordId;delete columnMapping.eventType; const parsed=await parseImportWorkbook("SampleFlow标准业绩导入模板.xlsx",sourceBytes,{sheetName:standard.rows[0]!.sheet_name,expectedHeaders:standard.rows[0]!.expected_headers,columnMapping,personMapping:{},fixedEventType:"legacy_adjustment"}); const source=parsed[0]!; @@ -924,12 +924,11 @@ test("系统管理员通过页面办理组织异动并保留前后有效期", as await page.getByRole("button",{name:"录入新订单"}).click(); const createOrderDialog=page.getByRole("dialog",{name:"录入订单业绩"}); await page.getByLabel("订单编号").fill("ORG-TRANSFER-E2E-100"); - await page.getByLabel("收到日期").fill("2026-07-15"); - await page.getByLabel("客户名称").fill("组织异动客户"); + await page.getByLabel("日期",{exact:true}).fill("2026-07-15"); + await page.getByLabel("客户姓名").fill("组织异动客户"); await page.getByLabel("客户单位",{exact:true}).fill("组织异动测试单位"); - await page.getByLabel("来源区域原文").fill("外贸组织线索"); - await createOrderDialog.getByLabel("标准业务区域").selectOption("EXT-TRADE"); - await createOrderDialog.getByLabel("业务员").selectOption({label:"E2E 异动业务员"}); + await createOrderDialog.getByLabel("省份").selectOption("EXT-TRADE"); + await createOrderDialog.getByLabel("业务员",{exact:true}).selectOption({label:"E2E 异动业务员"}); await page.getByLabel("服务类型").fill("组织快照验收"); await page.getByLabel("营业额").fill("100"); await page.getByRole("button",{name:"确认入账"}).click(); @@ -1011,11 +1010,10 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByRole("button", { name: "录入新订单" }).click(); const createOrderDialog=page.getByRole("dialog",{name:"录入订单业绩"}); await page.getByLabel("订单编号").fill("CHAIN-E2E-110"); - await page.getByLabel("客户名称").fill("事件链客户"); + await page.getByLabel("客户姓名").fill("事件链客户"); await page.getByLabel("客户单位",{exact:true}).fill("事件链测试单位"); - await page.getByLabel("来源区域原文").fill("外贸事件线索"); - await createOrderDialog.getByLabel("标准业务区域").selectOption("EXT-TRADE"); - await createOrderDialog.getByLabel("业务员").selectOption({label:"E2E 账本业务员"}); + await createOrderDialog.getByLabel("省份").selectOption("EXT-TRADE"); + await createOrderDialog.getByLabel("业务员",{exact:true}).selectOption({label:"E2E 账本业务员"}); await page.getByLabel("服务类型").fill("浏览器验收"); await page.getByLabel("营业额").fill("110"); await page.getByRole("button", { name: "确认入账" }).click(); @@ -1030,7 +1028,7 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await expect(page.getByRole("heading",{name:"不可变事件链"})).toBeVisible(); await expect(page.getByLabel("事件发生日期")).toHaveCount(0); await expect(page.locator(".event-summary b")).toHaveText(["+¥110.00"]); - await expect(page.getByText("外贸 (EXT-TRADE) · 来源 外贸事件线索 · 客户单位 事件链测试单位")).toBeVisible(); + await expect(page.getByText("外贸 (EXT-TRADE) · 来源 外贸 · 客户单位 事件链测试单位")).toBeVisible(); await expect(adjustDialog.getByRole("button",{name:"修改营业额"})).toHaveAttribute("aria-pressed","true"); await page.getByLabel("调整后营业额").fill("100"); await page.getByLabel("原因(必填)").fill("浏览器改单为 100"); @@ -1182,7 +1180,7 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", await page.getByRole("button",{name:"执行更正"}).click(); const executionDialog=page.getByRole("dialog",{name:/执行更正 · CHAIN-E2E-110/}); await expect(executionDialog).toContainText("将追加不可变更正事件"); - await executionDialog.getByLabel("调整后营业额").fill("90"); + await executionDialog.getByLabel("调整后营业额").fill("-25"); await executionDialog.getByLabel("执行原因").fill("执行获批更正"); await executionDialog.getByRole("button",{name:"确认追加更正事件"}).click(); await expect(executionDialog).toBeHidden(); @@ -1190,7 +1188,7 @@ test("订单搜索与不可变事件链在浏览器和数据库中保持一致", const evidence=new Client({connectionString:database.url});await evidence.connect(); try{ const result=await evidence.query<{delta_amount:string;order_sequence:number}>(`select e.delta_amount::text,e.order_sequence from performance_events e join performance_orders o on o.id=e.order_id where o.qingflow_order_no='CHAIN-E2E-110' order by e.order_sequence`); - expect(result.rows.map((row)=>Number(row.delta_amount))).toEqual([110,-10,-100,100,-10]); + expect(result.rows.map((row)=>Number(row.delta_amount))).toEqual([110,-10,-100,100,-125]); expect(result.rows.map((row)=>row.order_sequence)).toEqual([1,2,3,4,5]); }finally{await evidence.end();} }); diff --git a/apps/web/e2e/money-inputs.spec.ts b/apps/web/e2e/money-inputs.spec.ts index 2f413fc..1c364e2 100644 --- a/apps/web/e2e/money-inputs.spec.ts +++ b/apps/web/e2e/money-inputs.spec.ts @@ -42,7 +42,7 @@ test("空金额不会变成零元,并保留各业务动作的零值边界", as })); await page.getByRole("button", { name: "订单业绩", exact: true }).click(); - await page.route("**/api/performance/people", (route) => route.fulfill({ + await page.route("**/api/performance/people*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ people: [{ id: "1", displayName: "E2E 金额边界" }] }), @@ -59,19 +59,18 @@ test("空金额不会变成零元,并保留各业务动作的零值边界", as await page.getByRole("button", { name: "录入新订单" }).click(); const dialog = page.getByRole("dialog", { name: "录入订单业绩" }); await dialog.getByLabel("订单编号").fill("MONEY-BOUNDARY-001"); - await dialog.getByLabel("收到日期").fill("2026-09-01"); - await dialog.getByLabel("客户名称").fill("金额边界客户"); + await dialog.getByLabel("日期", { exact: true }).fill("2026-09-01"); + await dialog.getByLabel("客户姓名").fill("金额边界客户"); await dialog.getByLabel("客户单位", { exact: true }).fill("金额边界单位"); - await dialog.getByLabel("来源区域原文").fill("外贸"); - await dialog.getByLabel("标准业务区域").selectOption("EXT-TRADE"); - await dialog.getByLabel("业务员").selectOption("1"); + await dialog.getByLabel("省份").selectOption("EXT-TRADE"); + await dialog.getByLabel("业务员", { exact: true }).selectOption("1"); const amount = dialog.getByLabel("营业额"); await dialog.getByRole("button", { name: "确认入账" }).click(); expect(requestCount).toBe(0); await expect(amount).toBeFocused(); await expect(amount).toHaveAttribute("aria-invalid", "true"); - await expect(dialog.getByText("请输入营业额。")).toBeVisible(); + await expect(dialog.getByText("请输入系统营业额。")).toBeVisible(); await amount.fill("0"); await dialog.getByRole("button", { name: "确认入账" }).click(); diff --git "a/apps/web/public/SampleFlow\346\240\207\345\207\206\344\270\232\347\273\251\345\257\274\345\205\245\346\250\241\346\235\277.xlsx" "b/apps/web/public/SampleFlow\346\240\207\345\207\206\344\270\232\347\273\251\345\257\274\345\205\245\346\250\241\346\235\277.xlsx" index 87c71a6815ba682e5c75ab97e4b7f8d071cbf60d..e076f4db6809ee47de20918025082999e463bf4a 100644 GIT binary patch literal 5521 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 literal 8900 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 diff --git a/apps/web/src/app-types.ts b/apps/web/src/app-types.ts index 30fa6fc..cdd9932 100644 --- a/apps/web/src/app-types.ts +++ b/apps/web/src/app-types.ts @@ -1,14 +1,14 @@ export type Capabilities = { viewPerformance:boolean; viewGoals:boolean; viewAudits:boolean; viewOrganization:boolean; viewApprovals:boolean; editPerformance:boolean; exportPerformance:boolean; exportGoals:boolean; manageAccounts:boolean; manageOrganization:boolean }; export type User = { id: string; personId:string; username: string; displayName: string; mustChangePassword: boolean; roles: string[]; capabilities:Capabilities }; export type AuthState = { status: "loading" } | { status: "guest" } | { status: "authenticated"; user: User }; -export type OrderLifecycle = "draft" | "active" | "paused" | "zero" | "historical_review_required"; -export type Order = { id: string; orderNo: string; customerName: string; customerUnit: string; salespersonName: string; serviceType: string | null; sourceReceivedOn: string; originalAmount: string; currentRevenue: string; countedAmount: string; lifecycleState: OrderLifecycle; postedAt: string; departmentName:string; groupName:string; leaderName:string|null; supervisorName:string|null }; +export type OrderLifecycle = "draft" | "active" | "paused" | "zero" | "receivable_pending" | "historical_review_required"; +export type Order = { id: string; orderNo: string; customerName: string; customerUnit: string; salespersonName: string; serviceType: string | null; sourceReceivedOn: string; originalAmount: string; currentRevenue: string; countedAmount: string; lifecycleState: OrderLifecycle; postedAt: string; departmentName:string; groupName:string; leaderName:string|null; supervisorName:string|null; businessRegionSourceText:string|null; businessRegionCode:string|null; collaboratorName:string|null; collaborationRatio:string|null; note:string|null }; export type OrderFilters = { search:string;month:string;status:string;salesperson:string;department:string;group:string;region:string;customerUnit:string }; -export type PerformanceEvent = { id:string; sequence:number; eventType:string; deltaAmount:string; resultingCurrentRevenue:string; resultingCountedAmount:string; resultingLifecycleState:OrderLifecycle|null; accountingMonth:string; occurredOn:string; occurredAt:string; reason:string|null; actorName:string|null; salespersonName:string; departmentName:string|null; groupName:string|null; leaderName:string|null; supervisorName:string|null; businessRegionCode:string|null; businessRegionSourceText:string|null; customerUnit:string|null }; +export type PerformanceEvent = { id:string; sequence:number; eventType:string; deltaAmount:string; resultingCurrentRevenue:string; resultingCountedAmount:string; resultingLifecycleState:OrderLifecycle|null; accountingMonth:string; occurredOn:string; occurredAt:string; reason:string|null; actorName:string|null; salespersonName:string; departmentName:string|null; groupName:string|null; leaderName:string|null; supervisorName:string|null; serviceType:string|null; collaboratorName:string|null; collaborationRatio:string|null; collaboratorDepartmentName:string|null; collaboratorGroupName:string|null; businessRegionCode:string|null; businessRegionSourceText:string|null; customerUnit:string|null }; export type AccountingPeriod={periodMonth:string;status:"open"|"closed";version:number;needsReclose:boolean;verifiedAt:string|null;verifiedBy:string|null;closedAt:string|null;closedBy:string|null}; export type AccountingCorrection={id:string;periodMonth:string;orderId:string;orderNo:string;eventType:string;occurredOn:string;reason:string;businessRegionCode:string|null;businessRegionSourceText:string|null;customerUnit:string|null;analysisDimensionEvidence:string|null;status:"pending"|"approved"|"rejected"|"consumed"|"revoked";requestedBy:string;reviewedBy:string|null;reviewNote:string|null;expiresAt:string|null}; export type HistoricalReview={id:string;orderId:string;orderNo:string;lifecycleState:"active"|"paused"|"zero";currentRevenue:string;conclusion:string;evidence:string;reason:string;status:"pending"|"approved"|"rejected";requestedBy:string;reviewedBy:string|null;reviewNote:string|null}; -export type PersonalAchievementEvent = {id:string;orderId:string;orderNo:string;customerName:string;eventType:string;deltaAmount:string;accountingMonth:string;occurredOn:string;sequence:number;reason:string|null;resultingCountedAmount:string;resultingLifecycleState:"active"|"paused"|"zero"|null;departmentName:string|null;groupName:string|null}; +export type PersonalAchievementEvent = {id:string;orderId:string;orderNo:string;customerName:string;eventType:string;deltaAmount:string;accountingMonth:string;occurredOn:string;sequence:number;reason:string|null;resultingCountedAmount:string;resultingLifecycleState:OrderLifecycle|null;departmentName:string|null;groupName:string|null}; export type PersonalAchievement = { goalId:string|null;periodMonth:string;targetAmount:string|null;actualAmount:string;gapAmount:string|null;achievementRate:string|null;timeProgressRate:string|null;progressVariance:string|null;calculationReason:"PERIOD_IN_FUTURE"|"TARGET_NOT_ACTIVE"|"TARGET_AMOUNT_NOT_POSITIVE"|"TARGET_SCOPE_AMBIGUOUS"|null;eventCount:number }; export type PersonalAchievementDrilldown = PersonalAchievement&{events:PersonalAchievementEvent[]}; export type GroupAchievement = PersonalAchievement&{groupId:string;groupName:string;memberCount:number}; @@ -27,7 +27,7 @@ export type AnalysisCustomer = {customerUnit:string;eventCount:number;totalAmoun export type PerformanceAnalysis = { month:string;ledger:AnalysisAmount;mapped:AnalysisAmount;pending:AnalysisAmount;reconciled:boolean;provinces:AnalysisProvince[];foreignTrade:{regionCode:"EXT-TRADE";regionName:string;eventCount:number;totalAmount:string};customers:Array }; export type AnalysisCustomersDrilldown = {level:"customers";regionCode:string;regionName:string;month:string;eventCount:number;totalAmount:string;customerCount:number;nextCursor:string|null;snapshot:string;page:number;pageSize:number;totalCount:number;customers:AnalysisCustomer[]}; export type AnalysisMonthsDrilldown = {level:"months";regionCode:string;regionName:string;customerUnit:string;year:string;eventCount:number;totalAmount:string;months:Array<{month:string;eventCount:number;totalAmount:string}>}; -export type AnalysisDrilldownEvent = {id:string;eventType:string;deltaAmount:string;resultingCurrentRevenue:string;resultingCountedAmount:string;resultingLifecycleState:"active"|"paused"|"zero"|null;accountingMonth:string;occurredOn:string;reason:string|null;salespersonName:string;departmentName:string|null;groupName:string|null;sequence:number;businessRegionCode:string;businessRegionSourceText:string;customerUnit:string}; +export type AnalysisDrilldownEvent = {id:string;eventType:string;deltaAmount:string;resultingCurrentRevenue:string;resultingCountedAmount:string;resultingLifecycleState:OrderLifecycle|null;accountingMonth:string;occurredOn:string;reason:string|null;salespersonName:string;departmentName:string|null;groupName:string|null;sequence:number;businessRegionCode:string;businessRegionSourceText:string;customerUnit:string}; export type AnalysisDrilldownOrder = {orderId:string;orderNo:string;customerName:string;eventCount:number;totalAmount:string;events:AnalysisDrilldownEvent[]}; export type AnalysisEventsDrilldown = {level:"events";regionCode:string;regionName:string;customerUnit:string;month:string;eventCount:number;totalAmount:string;nextCursor:string|null;snapshot:string;page:number;pageSize:number;totalCount:number;orders:AnalysisDrilldownOrder[]}; export type GoalLevel="sales_manager"|"department"|"group"|"personal"; diff --git a/apps/web/src/pages/orders-page.tsx b/apps/web/src/pages/orders-page.tsx index 00848ce..d603f87 100644 --- a/apps/web/src/pages/orders-page.tsx +++ b/apps/web/src/pages/orders-page.tsx @@ -80,17 +80,17 @@ export function OrdersPage({ user }: { user: User }) { {!canEdit ?
当前角色仅可查看。只有销售助理及销售助理组长可以录入或调整业绩。
: null}

订单台账

{loading?"正在查询…":`本页 ${orders.length} 笔订单`}
-
{event.preventDefault();if(!isComposing)commitFilters({...committedFilters,search:draftFilters.search},"push",false);}}>
+
{event.preventDefault();if(!isComposing)commitFilters({...committedFilters,search:draftFilters.search},"push",false);}}>
{event.preventDefault();commitFilters(draftFilters);}}>
- +
-
{!loading&&orders.length === 0 ? : orders.map((order) => )}
订单编号客户业务员当前营业额计入业绩状态操作
{emptyMessage}
{order.orderNo}{order.customerName}{order.salespersonName}{formatMoney(order.currentRevenue)}{formatMoney(order.countedAmount)}
+
{!loading&&orders.length === 0 ? : orders.map((order) => )}
收样月份日期订单编号客户姓名客户单位省份业务员部门组别系统营业额服务类型备注协作人 / 比例计入业绩状态操作
{emptyMessage}
{Number(order.sourceReceivedOn.slice(5,7))}月{order.sourceReceivedOn}{order.orderNo}{order.customerName}{order.customerUnit}{order.businessRegionSourceText??businessRegionName(order.businessRegionCode??"")}{order.salespersonName}{order.departmentName||"—"}{order.groupName||"—"}{order.lifecycleState==="receivable_pending"?应收未收 {formatMoney(Math.abs(Number(order.currentRevenue)))}:formatMoney(order.currentRevenue)}{order.serviceType||"—"}{order.note||"—"}{order.collaboratorName?`${order.collaboratorName} / ${Number(order.collaborationRatio)*100}%`:"—"}{formatMoney(order.countedAmount)}
{showCreate ? setShowCreate(false)} onSaved={async () => { setShowCreate(false); await refresh(); }} /> : null} @@ -99,7 +99,7 @@ export function OrdersPage({ user }: { user: User }) { ; } -type ImportConfig = { id:string; name:string; version:number; status:"draft"|"approved"|"retired"; sheetName:string; fixedEventType:"legacy_adjustment"|null }; +type ImportConfig = { id:string; name:string; version:number; status:"draft"|"approved"|"retired"; sheetName:string; fixedEventType:"initial"|"legacy_adjustment"|null }; type ImportIssue = { rowNumber:number; code:string; severity:"blocking"|"warning"|"info"; message:string }; type ImportReconciliationSummary = { rows:number; orders:number; events:number; totalAmount:number; monthly:Array<{month:string;events:number;totalAmount:number}> }; type BackfillAmount = { events:number; totalAmount:number }; @@ -136,12 +136,12 @@ const standardBusinessRegions = [ ["CN-HA","河南省"],["CN-HB","湖北省"],["CN-HN","湖南省"],["CN-GD","广东省"],["CN-GX","广西壮族自治区"], ["CN-HI","海南省"],["CN-CQ","重庆市"],["CN-SC","四川省"],["CN-GZ","贵州省"],["CN-YN","云南省"], ["CN-XZ","西藏自治区"],["CN-SN","陕西省"],["CN-GS","甘肃省"],["CN-QH","青海省"],["CN-NX","宁夏回族自治区"], - ["CN-XJ","新疆维吾尔自治区"], + ["CN-XJ","新疆维吾尔自治区"],["CN-TW","台湾省"], ["EXT-TRADE","外贸"], ] as const; -const initialOrder = { orderNo: "", customerName: "", customerUnit: "", businessRegionSourceText:"", businessRegionCode: "", salespersonPersonId: "", serviceType: "", sourceReceivedOn: businessDateToday(), amount: "", reason: "首次录入" }; +const initialOrder = { orderNo: "", customerName: "", customerUnit: "", businessRegionSourceText:"", businessRegionCode: "", salespersonPersonId: "", collaboratorPersonId:"", collaborationRatio:"", serviceType: "", sourceReceivedOn: businessDateToday(), amount: "", reason: "首次录入" }; function previousBusinessMonth():string{const [year,month]=businessDateToday().slice(0,7).split("-").map(Number);return new Date(Date.UTC(year!,month!-2,1)).toISOString().slice(0,7);} @@ -201,7 +201,7 @@ function LedgerGovernancePanel({user,onChanged}:{user:User;onChanged:()=>Promise {isLeader?

提交历史订单核对

setReview((current)=>({...current,orderId}))}/>setReview((current)=>({...current,currentRevenue}))}/>setReview((current)=>({...current,conclusion}))}/>setReview((current)=>({...current,evidence}))}/>setReview((current)=>({...current,reason}))}/>:null} {isHr?:null}

记账期间

{periods.length?{(pageItems)=>
    {pageItems.map((period)=>
  • {period.periodMonth.slice(0,7)}{period.status==="closed"?`已关闭 · 版本 ${period.version}`:"开放"}{period.needsReclose?" · 待重新关账":""}
  • )}
}
:

尚无期间治理记录。

}

更正申请

{corrections.length?{(pageItems)=>
    {pageItems.map((item)=>
  • {item.orderNo} · {eventTypeName(item.eventType)}{item.periodMonth.slice(0,7)} · {item.status} · 申请人 {item.requestedBy}{item.businessRegionCode?`${businessRegionName(item.businessRegionCode)}(${item.businessRegionCode}) · ${item.businessRegionSourceText} · ${item.customerUnit} · 证据:${item.analysisDimensionEvidence}`:"缺少发生时分析维度证据,需重新提交"}
    {isHr&&item.status==="pending"?<>:null}{isHr&&item.status==="approved"?:null}{isLeader&&item.status==="approved"?:null}
  • )}
}
:

暂无更正申请。

}

历史核对

{reviews.length?{(pageItems)=>
    {pageItems.map((item)=>
  • {item.orderNo} · {item.conclusion}{item.status} · 核对人 {item.requestedBy} · 依据 {item.evidence}{isHr&&item.status==="pending"?
    :null}
  • )}
}
:

暂无历史核对记录。

}
- {execution?setExecution(null)} preventClose={busy}>
{["revenue_change","first_include"].includes(execution.eventType)?:null}
:null} + {execution?setExecution(null)} preventClose={busy}>
{["revenue_change","first_include"].includes(execution.eventType)?:null}
:null} {confirmation&&confirmationView?setConfirmation(null)} preventClose={busy}>
对象
{confirmationView.subject}
动作
{confirmationView.action}
原因
{confirmationView.reason}
结果
{confirmationView.effect}
:null} ; } @@ -216,26 +216,46 @@ function OrderLookup({id,value,onChange,requiredLifecycle}:{id:string;value:stri function CreateOrder({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise }) { const [form, setForm] = useState(initialOrder); - const [people, setPeople] = useState>([]); + const [people, setPeople] = useState>([]); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); - useEffect(() => { apiFetch("/api/performance/people").then(async (response) => { - const data = await response.json() as { people?:Array<{ id:string; displayName:string }>; message?:string }; + useEffect(() => { const controller=new AbortController();apiFetch(`/api/performance/people?occurredOn=${encodeURIComponent(form.sourceReceivedOn)}`,{signal:controller.signal}).then(async (response) => { + const data = await response.json() as { people?:Array<{ id:string; displayName:string; departmentName:string; groupName:string }>; message?:string }; if (!response.ok) throw new Error(data.message ?? "业务员列表加载失败"); setPeople(data.people ?? []); - }).catch((reason) => setError(reason instanceof Error ? reason.message : "业务员列表加载失败")); }, []); + }).catch((reason) => {if(!(reason instanceof DOMException&&reason.name==="AbortError"))setError(reason instanceof Error ? reason.message : "业务员列表加载失败");});return()=>controller.abort(); }, [form.sourceReceivedOn]); function set(name: keyof typeof form, value: string) { setForm((current) => ({ ...current, [name]: value })); } async function submit(event: FormEvent) { event.preventDefault(); if (saving) return; setSaving(true); setError(""); try { - const response = await apiFetch("/api/performance/orders", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...form, amount: Number(form.amount) }) }); + const collaboration=form.collaboratorPersonId?{collaboratorPersonId:form.collaboratorPersonId,collaborationRatio:Number(form.collaborationRatio)}:{}; + const {collaboratorPersonId:_collaboratorPersonId,collaborationRatio:_collaborationRatio,...order}=form; + const response = await apiFetch("/api/performance/orders", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...order,...collaboration, amount: Number(form.amount) }) }); const data = await readResponseJson<{ message?: string }>(response,"订单入账响应无效,请重试。"); if (!response.ok) throw new Error(data.message ?? "订单入账失败"); await onSaved(); } catch (reason) { setError(reason instanceof Error ? reason.message : "订单入账失败"); } finally { setSaving(false); } } - return
set("orderNo",v)}/> set("sourceReceivedOn",v)}/> set("customerName",v)}/> set("customerUnit",v)}/>set("businessRegionSourceText",v)}/> set("serviceType",v)}/> set("amount",v)}/> set("reason",v)}/>
{error ?

{error}

: null}
; + const salesperson=people.find((person)=>person.id===form.salespersonPersonId); + const collaborator=people.find((person)=>person.id===form.collaboratorPersonId); + return
+

收样月份:{Number(form.sourceReceivedOn.slice(5,7))}月。系统营业额为负数时按“应收未收”入账,不作为退款。

+
+ set("orderNo",value)}/> + set("sourceReceivedOn",value)}/> + set("customerName",value)}/> + set("customerUnit",value)}/> + + + set("serviceType",value)}/> + set("amount",value)}/> + set("reason",value)}/> + + {form.collaboratorPersonId?:null} +
+ {error?

{error}

:null}
+
; } function AdjustOrder({ order, canEdit, onClose, onSaved }: { order: Order; canEdit: boolean; onClose: () => void; onSaved: () => Promise }) { @@ -268,7 +288,7 @@ function AdjustOrder({ order, canEdit, onClose, onSaved }: { order: Order; canEd await onSaved(); }catch{setError("网络异常,结果尚未确认;可安全重试,系统不会重复入账。");}finally{setSaving(false);} } - return

不可变事件链

{loading?"正在读取事件…":`${events.length} 条事件 · 按服务端账本序号排列`}

{!loading&&events.length?{(pageItems)=>
    {pageItems.map((item)=>
  1. {item.sequence}
    {eventTypeName(item.eventType)}{Number(item.deltaAmount)>0?"+":""}{formatMoney(item.deltaAmount)}{item.resultingLifecycleState?:原始状态未推断}
    业务日 / 记账月
    {item.occurredOn} / {item.accountingMonth.slice(0,7)}
    操作时间
    {formatOperationTime(item.occurredAt)}
    投影结果
    营业额 {formatMoney(item.resultingCurrentRevenue)} · 计入 {formatMoney(item.resultingCountedAmount)}
    原因 / 操作者
    {item.reason||"—"} / {item.actorName||"历史导入"}
    组织快照
    {[item.departmentName,item.groupName].filter(Boolean).join(" / ")||"—"} · 组长 {item.leaderName||"—"} · 主管 {item.supervisorName||"—"}
    分析维度快照
    {item.businessRegionCode?`${businessRegionName(item.businessRegionCode)} (${item.businessRegionCode}) · 来源 ${item.businessRegionSourceText} · 客户单位 ${item.customerUnit}`:"待补齐(无受控来源证据)"}
  2. )}
}
:!loading&&!error?

没有可显示的事件。

:null}
{error ?

{error}

: null}{!loading&&canEdit && allowed.length ?
{allowed.includes("revenue_change") ? : null}{allowed.includes("pause") ? : null}{allowed.includes("restart") ? : null}{allowed.includes("first_include") ? : null}

操作时间和记账月由服务器确定,组织快照按该业务日的有效任职自动解析。

{type === "revenue_change" || type === "first_include" ? : null}
: !loading?
{lifecycle==="historical_review_required"?"该历史订单需要先完成核对与人事批准,当前不能追加事件。":"当前订单状态没有可执行操作,或当前角色仅可查看。"}
:null}
; + return

不可变事件链

{loading?"正在读取事件…":`${events.length} 条事件 · 按服务端账本序号排列`}

{!loading&&events.length?{(pageItems)=>
    {pageItems.map((item)=>
  1. {item.sequence}
    {eventTypeName(item.eventType)}{Number(item.deltaAmount)>0?"+":""}{formatMoney(item.deltaAmount)}{item.resultingLifecycleState?:原始状态未推断}
    业务日 / 记账月
    {item.occurredOn} / {item.accountingMonth.slice(0,7)}
    操作时间
    {formatOperationTime(item.occurredAt)}
    投影结果
    营业额 {formatMoney(item.resultingCurrentRevenue)} · 计入 {formatMoney(item.resultingCountedAmount)}
    原因 / 操作者
    {item.reason||"—"} / {item.actorName||"历史导入"}
    组织快照
    {[item.departmentName,item.groupName].filter(Boolean).join(" / ")||"—"} · 组长 {item.leaderName||"—"} · 主管 {item.supervisorName||"—"}
    {item.collaboratorName?
    协作分配
    {item.collaboratorName} · {Number(item.collaborationRatio)*100}% · {[item.collaboratorDepartmentName,item.collaboratorGroupName].filter(Boolean).join(" / ")}
    :null}
    分析维度快照
    {item.businessRegionCode?`${businessRegionName(item.businessRegionCode)} (${item.businessRegionCode}) · 来源 ${item.businessRegionSourceText} · 客户单位 ${item.customerUnit}`:"待补齐(无受控来源证据)"}
  2. )}
}
:!loading&&!error?

没有可显示的事件。

:null}
{error ?

{error}

: null}{!loading&&canEdit && allowed.length ?
{allowed.includes("revenue_change") ? : null}{allowed.includes("pause") ? : null}{allowed.includes("restart") ? : null}{allowed.includes("first_include") ? : null}

操作时间和记账月由服务器确定;调整后营业额允许为负数,表示应收未收。协作比例沿用订单初始值。

{type === "revenue_change" || type === "first_include" ? : null}
: !loading?
{lifecycle==="historical_review_required"?"该历史订单需要先完成核对与人事批准,当前不能追加事件。":"当前订单状态没有可执行操作,或当前角色仅可查看。"}
:null}
; } diff --git a/apps/web/src/shared-ui.tsx b/apps/web/src/shared-ui.tsx index 5ef4afa..b874770 100644 --- a/apps/web/src/shared-ui.tsx +++ b/apps/web/src/shared-ui.tsx @@ -32,5 +32,5 @@ export function PaginatedCollection({items,label,resetKey,children}:{items:re export function Field({ label, value, onChange, type="text", required=true, min, max }: { label: string; value: string; onChange: (value: string) => void; type?: string; required?: boolean; min?: string; max?: string }) { const errorId=useId();const[error,setError]=useState("");return ; } export function Modal({ title, note, onClose, preventClose=false, children }: { title: string; note: string; onClose: () => void; preventClose?: boolean; children: ReactNode }) { const titleId=useId();const noteId=useId();const dialogRef=useRef(null);const closeRef=useRef(onClose);const requestClose=()=>{if(!preventClose)onClose();};closeRef.current=requestClose;useEffect(()=>{const prior=document.activeElement instanceof HTMLElement?document.activeElement:null;const root=document.getElementById("root");const priorOverflow=document.body.style.overflow;root?.setAttribute("inert","");root?.setAttribute("aria-hidden","true");document.body.style.overflow="hidden";const dialog=dialogRef.current;dialog?.focus();function keydown(event:KeyboardEvent){if(event.key==="Escape"){event.preventDefault();closeRef.current();return;}if(event.key!=="Tab"||!dialog)return;const controls=[...dialog.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[href],[tabindex]:not([tabindex="-1"])')].filter((item)=>!item.hasAttribute("hidden"));if(!controls.length){event.preventDefault();dialog.focus();return;}const first=controls[0]!;const last=controls.at(-1)!;if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}}document.addEventListener("keydown",keydown);return()=>{document.removeEventListener("keydown",keydown);root?.removeAttribute("inert");root?.removeAttribute("aria-hidden");document.body.style.overflow=priorOverflow;prior?.focus();};},[]);return createPortal(
{ if (event.target === event.currentTarget) requestClose(); }}>

{title}

{note}

{children}
,document.body); } -export function Status({ state }: { state: OrderLifecycle }) { const names:Record = { draft:"草稿", active:"正向计入", paused:"已暂停", zero:"零金额",historical_review_required:"待历史核对" }; return {names[state]}; } +export function Status({ state }: { state: OrderLifecycle }) { const names:Record = { draft:"草稿", active:"正向计入", paused:"已暂停", zero:"零金额",receivable_pending:"应收未收",historical_review_required:"待历史核对" }; return {names[state]}; } export function Metric({label,value,note,warning,negative,onClick,actionLabel}:{label:string;value:string;note:string;warning?:boolean;negative?:boolean;onClick?:(()=>void)|undefined;actionLabel?:string|undefined}) { return
{label}{onClick?:{value}}{note}
; } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 6bf408d..f509863 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -365,6 +365,7 @@ th { color: #607084; background: #fafbfc; font-weight: 600; } .status-active { color: #147465; background: #e3f5f1; } .status-paused { color: #a63232; background: #fdeaea; } .status-zero { color: #8a640c; background: #fff3d4; } +.status-receivable_pending { color: #9b2c2c; background: #ffebeb; } .status-draft { color: #596779; background: #edf0f4; } .status-historical_review_required { color: #8a4f0c; background: #fff0d6; } .onboarding-replay { width: 100%; min-height: 42px; margin-top: auto; padding: 0 16px; border: 1px solid #b9ccec; border-radius: 999px; color: #174d99; background: #fff; box-shadow: 0 8px 24px rgba(20, 50, 90, .14); display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer; font-size: 13px; font-weight: 700; } diff --git a/docs/adr/0005-use-immutable-performance-events.md b/docs/adr/0005-use-immutable-performance-events.md index d2eafe2..25b6925 100644 --- a/docs/adr/0005-use-immutable-performance-events.md +++ b/docs/adr/0005-use-immutable-performance-events.md @@ -16,7 +16,7 @@ status: accepted - 同一订单的事件必须按实际发生时间逐个入账,一个事件不能同时表达营业额变更与暂停或重启。 - 整单暂停金额取暂停事件发生时的订单当前营业额;例如 110 元先变更为 100 元,再暂停时产生 -100 元暂停事件。 - 营业额变更事件只记录新旧营业额的差额,不采用全额冲销再全额记入。 -- 只有当前计入业绩金额大于 0 的订单允许修改营业额;暂停订单以及当前计入金额为 0 或负数的订单必须拒绝该操作。 +- 当前正向计入订单和 ADR-0031 定义的“应收未收”负数订单允许修改营业额;暂停订单必须先重启,零金额订单继续使用首次计入事件。 - 暂停订单如需修改营业额,必须先重启至正向计入状态;修改完成后如仍需暂停,再单独执行暂停事件。 - 订单当前营业额为 0 时仍允许确认入账和查询,但不得计入业绩总额,也不得执行营业额修改。 - 零金额订单后来获得正营业额时,必须在同一业绩主记录下新增等于完整正营业额的首次计入事件,不得修改原零金额记录或创建新订单。 diff --git a/docs/adr/0006-exclude-collaboration-data.md b/docs/adr/0006-exclude-collaboration-data.md index faf7d77..4e6c540 100644 --- a/docs/adr/0006-exclude-collaboration-data.md +++ b/docs/adr/0006-exclude-collaboration-data.md @@ -1,5 +1,5 @@ --- -status: accepted +status: superseded by ADR-0031 --- # 新系统不保留协作人和协作比例 diff --git a/docs/adr/0031-use-sheet3-order-input-and-split-collaboration.md b/docs/adr/0031-use-sheet3-order-input-and-split-collaboration.md new file mode 100644 index 0000000..7a77663 --- /dev/null +++ b/docs/adr/0031-use-sheet3-order-input-and-split-collaboration.md @@ -0,0 +1,9 @@ +--- +status: accepted +--- + +# 采用“分子”工作表作为订单输入并拆分协作业绩 + +标准 Excel 模板和人工新订单统一采用 `原始数据1.xlsx` 的“分子”业务字段;标准批量导入只创建新订单,同一订单编号只能出现一行,订单编号同时作为首次入账的稳定来源标识。`系统营业额` 可以为负,负数表示应收未收并以原值进入账本,不得当作格式错误、退款或暂停;月份由日期校验,部门和组别由业务员在发生日的有效组织关系校验,空白分隔列只为兼容原工作表而忽略。 + +本决策同时取代 ADR-0006:订单可以有一名协作人和一个大于 0、小于 1 的协作比例。每笔事件按该比例将金额拆为主业务员份额和协作人份额,分币舍入差额归主业务员,两者之和始终等于公司事件金额;后续调整沿用订单协作比例,并分别固化两人的事件发生时组织快照。旧导入配置和无协作历史事件继续按主业务员 100% 读取,既有不可变事件不追溯改写。 diff --git a/docs/performance-import.md b/docs/performance-import.md index 48d2e29..74f6f95 100644 --- a/docs/performance-import.md +++ b/docs/performance-import.md @@ -6,7 +6,7 @@ 工作簿安全上限为:压缩文件 20 MiB、ZIP 条目 256 个、单条目解压后 32 MiB、解压后总计 64 MiB。超过任一上限会在读取业务内容前拒绝。 -人工逐条录入必须从固定的标准业务区域中选择;大陆省级区域使用 `CN-*` 代码,“外贸”使用独立的 `EXT-TRADE` 代码且不归入任何省份。Excel 中的来源区域原文通过人事批准的精确映射转换到同一标准代码,不能由客户端提交任意代码。 +人工逐条录入必须从固定的标准业务区域中选择;省级区域使用 `CN-*` 代码并包含台湾省 `CN-TW`,“外贸”使用独立的 `EXT-TRADE` 代码且不归入任何省份。Excel 中的来源区域原文通过人事批准的精确映射转换到同一标准代码,不能由客户端提交任意代码。 导入配置把完整表头、列映射、必填字段、业务区域映射和人员映射共同保存为不可变版本。销售助理组长可以修改本人尚未批准且未产生批次的草稿;配置一旦被批准或产生导入批次,只能创建新版本,不能覆盖旧定义。 @@ -31,7 +31,11 @@ 专用历史配置允许同一订单的业务员、客户单位、来源区域和服务类型随历史流水变化。系统在批次行保留每行原值,事件使用该行业务员和发生时组织快照,订单列表的当前投影取全量历史中按发生日期、来源行号排序后的最后一行;客户姓名冲突仍阻断,已有普通业务事件的订单不得使用该例外。普通配置继续要求订单基础事实完全一致。 -标准模板只接受 `initial`。其他获批格式可以明确使用 `revenue_change`、`pause`、`restart` 和 `first_include`,并按人工录入的同一规则预演事件链:`revenue_change` 的金额表示调整后的新营业额,`first_include` 表示首次计入金额,`pause`/`restart` 的金额必须为 0。每个配置版本固化允许事件类型;已产生批次后,配置标识、版本、字段定义和审批证据均不可修改。同一订单同一天有多条事件时,配置必须映射业务顺序列,来源值从 1 开始连续且唯一;跨批次必须接续已有来源顺序,且不能在已有业务事件之前插入历史事件。缺失、重复、断号或事件状态转换非法都会阻断整批。 +标准模板固定读取“分子”工作表,精确表头为:`收样月份`、`日期`、`订单编号(来源于轻流系统)`、`客户姓名`、`客户单位`、`省份`、`业务员`、`部门`、`组别`、`系统营业额`、`服务类型`、`备注`、空白分隔列、`协作人`、`协作比例`。空白分隔列被忽略,其余列不做相似名称猜测。收样月份必须等于日期月份;业务员、协作人可用稳定来源标识或唯一姓名解析,部门和组别必须与业务员在该日期的有效任职完全一致。 + +标准模板只接受 `initial`,一行代表一笔新订单,同一订单编号不能重复;订单编号同时作为首次入账的稳定来源记录标识。`系统营业额` 可以为负,负数原值入账并显示为“应收未收”,不解释为退款、暂停或格式错误。协作人和协作比例必须同时填写或同时留空,比例须大于 0、小于 1;主业务员份额为金额减协作份额,分币舍入差额归主业务员,公司总额始终等于原事件金额。后续系统内调整沿用订单协作比例。 + +其他获批格式可以明确使用 `revenue_change`、`pause`、`restart` 和 `first_include`,并按人工录入的同一规则预演事件链:`revenue_change` 的金额表示调整后的新营业额且允许为负,`first_include` 表示首次计入的正金额,`pause`/`restart` 的金额必须为 0。每个配置版本固化允许事件类型;已产生批次后,配置标识、版本、字段定义和审批证据均不可修改。同一订单同一天有多条事件时,配置必须映射业务顺序列,来源值从 1 开始连续且唯一;跨批次必须接续已有来源顺序,且不能在已有业务事件之前插入历史事件。缺失、重复、断号或事件状态转换非法都会阻断整批。 已关闭期间的调整行必须提供与订单、事件类型、发生日期和月份完全匹配的一次性更正授权标识。确认事务会锁定授权,并仅在事件成功入账后消费;开放期间不得携带该授权。 diff --git a/docs/v1-scope.md b/docs/v1-scope.md index 057a3f8..caffd08 100644 --- a/docs/v1-scope.md +++ b/docs/v1-scope.md @@ -16,7 +16,7 @@ ## 核心场景 -1. 销售助理从轻流逐条复制订单,校验后确认入账。 +1. 销售助理按“分子”Sheet3 字段逐条录入或批量导入新订单,校验后确认入账。 2. 已入账订单通过金额差额、整单暂停、重启或首次计入事件调整;同一订单编号保留完整事件链。 3. 上级下达目标,责任人在已认证会话中实名确认;销售经理总目标先经总经理审批,全部目标最后经人事审批。 4. 组织异动后产生的事件归属新组织,历史事件快照不变化。 @@ -27,6 +27,7 @@ - 系统账号登录、首次改密、会话与操作审计。 - 角色权限、账号启停、部门/小组及带生效日期的人员任职。 - 订单台账和不可变业绩事件。 +- Sheet3 标准模板、负数应收未收状态,以及协作人/协作比例的个人与组织业绩拆分。 - 目标下达、实名确认、总经理/人事审批、修改申请、下放差额展示。 - 业绩看板、事件/组织汇总、CSV 导出。 - 全部数据表格和可增长清单统一使用默认 20 条、可选 10/20/50/100 条的页码分页。 @@ -37,7 +38,6 @@ - 轻流 API 自动同步;1.0 仍由人工逐条复制。 - 2024、2025 年订单明细迁移。 -- 协作人、协作比例及协作事件。 - 外部导出文件的二次编辑回写。 - 公司网关、域名、HTTPS 证书、服务器备份策略的实际配置。 @@ -45,7 +45,8 @@ - 入账后不允许更新或删除业绩事件;更正只追加新事件。 - 营业额从 110 调整为 100 时仅追加 -10;调整与暂停是两个先后独立事件。 -- 只有当前正向计入的订单可修改营业额或暂停;暂停后按当前营业额全额扣减,重启时全额正向记入。 +- 当前正向计入的订单可修改营业额或暂停;应收未收订单可通过营业额调整转为零或正向。暂停后按当前营业额全额扣减,重启时全额正向记入。 +- 系统营业额为负表示应收未收,不表示退款或暂停。协作订单按固定比例拆分到主业务员和协作人,公司总额不重复计算;后续调整沿用该比例。 - 零金额订单允许录入但不计业绩,之后通过首次计入事件转为正向。 - 调整记在操作发生月,不重写已签认历史月。 - 个人业绩与团队管理业绩分开;目标可少分配或超分配,并显示差额,主管达成率仍按主管责任目标计算。 From 4b70dc58a1af0ff5d2dccc1ab840740f94ebcb9c Mon Sep 17 00:00:00 2001 From: Eclipseic1848 <237380389+Eclipseic1848@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:42:26 -0700 Subject: [PATCH 2/2] docs: refresh Sheet3 handoff --- handoff.md | 326 ++++++++++++++++++++++------------------------------- 1 file changed, 135 insertions(+), 191 deletions(-) diff --git a/handoff.md b/handoff.md index 5f68ce1..95cc74f 100644 --- a/handoff.md +++ b/handoff.md @@ -2,233 +2,177 @@ > 更新时间:2026-09-02(America/Los_Angeles) > -> 当前结论:**全仓复审新增的 6 个 P1 工程问题 #118—#123 已全部修复,经 PR #124、required check 和合并后 `main` quality gate 验证后进入 `main`,Issues 已自动关闭。当前版本没有剩余可由工程代理独立完成的 P1;剩余 P1 全部是人工 Gate:真实数据业务 UAT 与公司服务器验收。当前没有真实数据授权,也没有公司服务器,绝不能把自动化、合成数据或本机 Docker 结果冒充这些验收。** +> 核心结论:本轮工程目标由 [Issue #127](https://github.com/Eclipseic1848/SampleFlow/issues/127) / [PR #128](https://github.com/Eclipseic1848/SampleFlow/pull/128) 承载。代码和本地验收已完成;PR、CI、合并及 Issue 状态必须实时查询,不在本文缓存易失状态。没有导入真实工作簿,没有移动端,也没有公司服务器或外部发布。 ## 1. 我们在做什么 -SampleFlow 是销售业绩、目标、组织、账号和审计管理的桌面 Web 系统。技术栈:React 19、Vite 8、Fastify 5、TypeScript、PostgreSQL 16、Docker Compose、npm workspaces。 - -本阶段目标: - -1. 审查全仓代码和桌面 Web 用户体验; -2. 把真实发现的问题整理为 GitHub Issues; -3. 完成当前版本所有可由工程代理处理的 P1; -4. 经本地门禁、PR CI、合并后 `main` CI 取证; -5. 写清剩余人工 Gate、Roadmap、恢复步骤与禁区。 - -用户已明确的范围和授权: - -- 当前只做桌面 Web;没有移动端,#57 已取消。 -- 当前没有公司服务器,不执行或假装执行服务器验收。 -- 地图必须包含台湾省;前端不需要南海展示。 -- 前端必须使用真实公司 Logo,不恢复 “SF” 占位标。 -- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码。 -- 目标确认只取消手写签名;保留登录身份、目标版本、固定确认声明、时间和不可修改审计。 -- 常规实现、测试、提交、推送、PR、CI 绿后合并与 Issue 关闭可依据证据自行执行。 -- GitHub Release、Tag、真实外部发布、正式密钥/权限、真实或生产数据、不可逆操作仍是人工门禁。 - -## 2. 权威状态 - -### 2.1 Git 与 GitHub - -- 仓库:https://github.com/Eclipseic1848/SampleFlow -- 本地路径:`F:\new branch\SampleFlow` -- 默认分支:`main` -- UI/UX 审查交付:[PR #113](https://github.com/Eclipseic1848/SampleFlow/pull/113),已 squash merge。 -- 统一页码分页交付:[Issue #116](https://github.com/Eclipseic1848/SampleFlow/issues/116) / [PR #117](https://github.com/Eclipseic1848/SampleFlow/pull/117)。 -- 本轮复审整改:[#118](https://github.com/Eclipseic1848/SampleFlow/issues/118)—[#123](https://github.com/Eclipseic1848/SampleFlow/issues/123) / [PR #124](https://github.com/Eclipseic1848/SampleFlow/pull/124),已 squash merge。 -- PR #124 合并提交:`fbc82c17f648a38831a9ae0b3b8035ce2349ef23`。 -- PR #124 required check:run `33618823240`,SUCCESS,5分45秒。 -- PR #124 合并后 `main` quality gate:run `33619359468`,SUCCESS,5分33秒。 -- PR #113 合并提交:`a1505add0191cb2bd4176bf01c993c523569ae27` -- PR required check:run `33591249761`,SUCCESS,7分08秒。 -- 合并后 `main` quality gate:run `33591736403`,SUCCESS,5分29秒。 -- #101、#106—#112 已由 PR #113 自动关闭;#116 由 PR #117 自动关闭;#118—#123 由 PR #124 自动关闭。 -- 本轮没有创建 worktree 或额外项目目录。 -- 没有创建 Tag、GitHub Release,也没有部署到真实服务器。 - -新会话先实时复核,不把本文 SHA 当永久真相: +SampleFlow 是销售业绩、目标、组织、账号和审计管理的桌面 Web 系统。技术栈为 React 19、Vite 8、Fastify 5、TypeScript、PostgreSQL 16、Docker Compose 和 npm workspaces。 + +本轮目标是以真实业务工作簿 Sheet3“分子”的字段结构统一新订单 Excel 导入、下载模板和人工录入,并补齐: + +1. 日期、客户单位、省份、部门、组别、服务类型、备注等订单字段; +2. 负数系统营业额的“应收未收”业务状态; +3. 一名协作人和协作比例的个人、组别、部门业绩归属; +4. 完整订单表和导出; +5. 由真实事件驱动的省份分析,地图包含台湾省且不展示南海插图; +6. 迁移、权限、原子确认、幂等、测试、模板和文档。 + +用户已明确:只做桌面 Web;当前没有移动端和服务器。真实工作簿属于敏感业务数据,不得导入测试环境、提交 Git、上传 GitHub 或泄露到日志/截图/聊天。常规实现、测试、提交、推送、PR、CI 绿后合并与 Issue 关闭可按证据自行完成;GitHub Release、Tag、真实外部发布、正式密钥/权限、真实或生产数据和不可逆操作仍是人工门禁。 + +## 2. 本轮已完成 + +### 2.1 输入与导入 + +- 标准配置升级为 v2,只读取名为“分子”的工作表,并精确匹配:`收样月份`、`日期`、`订单编号(来源于轻流系统)`、`客户姓名`、`客户单位`、`省份`、`业务员`、`部门`、`组别`、`系统营业额`、`服务类型`、`备注`、空白列、`协作人`、`协作比例`。 +- 空白列只作兼容;不猜测相似表头。标准批次一行一订单,只接受 `initial`;订单编号同时是稳定来源标识。 +- 收样月份与日期月份必须一致;部门、组别必须匹配业务员发生日的有效任职;协作人也必须有唯一有效组织快照。 +- 业务员和协作人优先按稳定来源标识解析,仅在姓名唯一时允许按姓名解析。 +- 继续保留工作簿尺寸/ZIP/公式/宏/外部连接安全门禁、整批预检、原子确认、幂等重放、跨文件重复检测和行级错误汇总。 +- 下载模板已更新为合成空模板,不包含真实业务行。 + +### 2.2 负数应收未收 + +- `系统营业额 < 0` 合法,表示“应收未收”;初始金额、当前营业额、计入金额和事件差额按原值进入不可变账本。 +- 新增 `receivable_pending` 生命周期;可通过营业额调整变为负数、零或正数。暂停仍只适用于正向计入订单,首次计入仍只接受正数。 +- 普通调整和已关闭期间的获批更正都允许调整为负数。 +- 金额校验统一使用按分舍入等值判断,覆盖大额两位小数,不能恢复固定 `1e-7` 误差判断。 + +### 2.3 协作归属 + +- 一笔订单最多一名协作人,协作比例必须大于 0、小于 1,且协作人不能等于主业务员。 +- 协作份额按分舍入,主业务员取得余数;两者之和严格等于事件金额,公司营业额不放大。 +- 后续不可变事件沿用订单协作比例,并分别按事件发生日固化主业务员与协作人的组织快照。 +- 个人、小组、部门和正式报表使用归属视图;全公司、地区和客户分析仍按原始事件金额汇总,避免重复计算。 +- 权限范围允许协作人及其组织读取对应事件,但归属汇总只计入各自份额。 + +### 2.4 Web、列表、导出与地图 + +- 人工录入字段与 Sheet3 一致;省份使用受控选择器,部门/组别由日期和业务员自动显示、服务端核验。 +- 订单表显示客户姓名、客户单位、省份、业务员、部门、组别、系统营业额、服务类型、备注、协作人、协作比例、当前计入金额和状态。 +- CSV 导出同步上述字段;订单备注固定取首次入账备注,不再被后续调整原因覆盖。 +- 台湾省代码为 `CN-TW`,进入受控省份和地图数据;外贸独立为 `EXT-TRADE`;前端不展示南海插图。 +- 所有既有统一分页规则保持不变:默认 20 条,可选 10/20/50/100 条并可点击页码。 + +### 2.5 决策与文档 + +- 新增 ADR-0031,记录用户明确决定:Sheet3 为新订单输入权威结构、负数表示应收未收、协作业绩按比例拆分;该决定取代 ADR-0006 的“排除协作数据”。 +- README、CONTEXT、v1 范围、导入说明、事件 ADR 和模板已同步;旧导入配置和旧不可变事件不追溯改写。 +- 目标确认仍只取消手写签名;已认证身份、目标版本、固定声明、时间与不可修改审计继续保留。 + +## 3. 验收证据 + +本地最终验收: + +- API 全量测试:164/164 通过。 +- Web Playwright:一次全量 49/50;唯一失败为既有 200% 缩放用例的 `Shift+Tab` 瞬时焦点断言,同一 1024 场景通过,随后 1280 场景连续复跑 3/3 通过。本次关账更正为负数场景定向通过。最终远端结论以 PR #128 required check 为准。 +- TypeScript 类型检查:API、Web 通过。 +- 生产构建:API、Web 通过;Vite 共转换 1940 个模块。 +- 容器契约:11/11 通过。 +- 隔离容器运行门禁:空库首装、真实 025→026 升级、ready/smoke、备份、新库恢复、最小权限和恢复库登录通过;只清理本轮随机隔离资源。 +- `npm audit --omit=dev`:0 vulnerabilities。 +- `docker compose --env-file .env.example config --quiet`:通过。 +- Spec 审查发现的 3 项真实缺口(大额两位小数、关账更正负数、原始备注被覆盖)已修复并回归;Standards 审查未发现阻断项。 +- `git diff --check`:通过。 + +以上只证明仓库代码、合成数据、本机 Docker 和 CI 候选;不证明真实数据 UAT、公司服务器、生产部署或外部发布。 + +## 4. 当前状态与卡点 + +本轮工程交付:Issue #127 / PR #128。新会话必须先实时核对: ```powershell +Set-Location -LiteralPath 'F:\new branch\SampleFlow' git status --short --branch git fetch origin main git rev-parse HEAD git rev-parse origin/main -gh pr view 113 --repo Eclipseic1848/SampleFlow -gh pr view 117 --repo Eclipseic1848/SampleFlow -gh pr view 124 --repo Eclipseic1848/SampleFlow -gh issue view 123 --repo Eclipseic1848/SampleFlow -gh issue list --repo Eclipseic1848/SampleFlow --label "priority:P1" --state open -gh run list --repo Eclipseic1848/SampleFlow --workflow "SampleFlow quality gate" --limit 10 +gh pr view 128 --repo Eclipseic1848/SampleFlow --json state,mergeStateStatus,statusCheckRollup,mergedAt,mergeCommit,url +gh issue view 127 --repo Eclipseic1848/SampleFlow --json state,labels,url +gh issue list --repo Eclipseic1848/SampleFlow --state open --label 'priority:P1' +gh run list --repo Eclipseic1848/SampleFlow --workflow 'SampleFlow quality gate' --limit 10 docker compose -f docker-compose.dev.yml ps ``` -### 2.2 当前 P1 - -本轮工程 P1 已合并并关闭: +仍打开的 P1 人工门禁不得由代理用本机证据关闭: -| Issue | 最终结果 | -| --- | --- | -| #118 金额精度与账本状态不变量 | 统一 API 两位小数校验;首次计入先按分舍入;迁移 025 在数据库层约束状态与金额组合 | -| #119 读路径表级锁 | 删除账号、审计和分析查询中的 `LOCK TABLE ... SHARE`;回归证明读取不等待在途写入 | -| #120 超范围页总数 | 订单和审计用单语句 CTE 同时返回精确 `totalCount` 与空页 | -| #121 页码稳定快照 | 订单、审计、分析页码查询返回绑定用户与筛选的 cutoff 快照;URL 跨页、刷新和历史恢复复用 | -| #122 总览异步引导 | 总览数据或失败状态就绪后才自动开始,避免首次引导缺少异步目标步骤 | -| #123 分页合同文档 | `README.md`、`UX-CONTRACT.md` 与 P1 规格同步真实分页和快照语义 | - -以下 Issue 必须保持 `ready-for-human`,直到真实条件成立: - -| Issue | 当前事实 | 关闭条件 | +| Issue | 当前卡点 | 关闭条件 | | --- | --- | --- | -| #12 多维分析父项 | 工程能力完成,等待 #63 | 真实组织/区域映射、真实数据对账与业务 UAT 通过 | -| #63 真实数据能力验收与业务 UAT | 无真实数据访问授权、批准映射、验收环境和业务签字 | 在批准的隔离环境完成来源冻结、迁移、逐月/整体对账、幂等重放、角色 UAT 与签字 | -| #15 生产准备父项 | 仓库生产准备完成,等待 #64 | 公司服务器真实验收通过 | -| #64 公司服务器实际验收 | 当前没有服务器 | 域名/HTTPS、访问范围、正式秘密、备份、监控、RPO/RTO、升级回退与运维签字齐全 | -| #65 P1 完成审计与收口 | 依赖上述四项 | #12、#15、#63、#64 真实关闭后再关闭 | - -#65 最新工程证据评论: -https://github.com/Eclipseic1848/SampleFlow/issues/65#issuecomment-5504522089 - -## 3. 本阶段完成内容 - -### 3.1 UI/UX 审查 Issue 与修复 - -| Issue | 结果 | -| --- | --- | -| #106 P0 空金额 | 金额必填使用原生约束;空字符串不再被 `Number("")` 转成零元;保留各业务动作允许/禁止零值的原规则 | -| #107 多角色目标 | 目标层级按完整角色与权限集合展示,不再只取第一个角色 | -| #108 密码与账号安全 | 首次/已登录改密、重置密码、停用账号补齐高影响确认、pending、防重复提交和不确定结果保护 | -| #109 记账治理 | 可按订单定位不可变事件链;高影响更正/暂停/首次计入使用明确确认与服务端权威状态 | -| #110 缩放、键盘和引导 | 200% 缩放仍保留完整导航;补齐跳转主内容、焦点、表格可滚动区域和教程入口位置 | -| #111 真实表达 | 加载失败不伪装成空数据或零;负值图表保留真实零线;审计动作、实体与数据使用中文可读表达 | -| #101 搜索与历史 | 账号、订单、审计搜索/分页与 URL、刷新、前进后退一致;冷启动深链不会伪造“上一页” | -| #112 后台信息架构 | 创建账号明确“新建人员/绑定已有人员”和多角色;组织按部门/小组、当前/历史分组并支持搜索;文本域可垂直调整 | -| #116 统一页码分页 | 所有数据表格与可增长业务清单默认 20 条,可选 10/20/50/100 条;支持总数、当前页/总页数、可点击页码、上一页和下一页;服务端大列表返回 `page`/`pageSize`/`totalCount`,旧游标调用保持兼容 | -| #118—#123 全仓复审整改 | 金额边界和数据库不变量、无阻塞读取、超范围精确总数、稳定页码快照、异步引导与权威文档已由 PR #124 合并,Issues 全部关闭 | - -### 3.2 既有 P1 工程能力 - -以下能力在本阶段前后均已进入 `main`,不要重做: - -- 基于不可变事件的业绩账本、四层目标治理、组织责任范围、账号与角色权限、只读审计。 -- 台湾省地图、地区/客户/月/订单事件穿透和待补齐治理;前端不展示南海插图。 -- 真实公司 Logo:`apps/web/public/brand-logo.png`。 -- 角色/权限维度新手引导:8 个桌面页面、首次自动、完成后不打扰、始终可重播、跳过全部、键盘、焦点与 ARIA。 -- 数据库迁移哈希、bigint 字符串边界、认证与限速、CSRF/Origin、最小权限容器、备份恢复、结构化日志与指标。 -- 受保护下载、稳定游标、筛选 URL、写入不确定状态恢复和失败真相。 -- 统一分页控件与 URL 恢复:账号、订单、审计和分析穿透使用服务端页码,其余已加载业务集合使用共享客户端分页;筛选或每页条数变化回到第 1 页。 -- 当前迁移为 `001`—`025`;不要改写已应用迁移。 - -## 4. 验收证据 - -最终产品源码与测试变更的证据: - -- 本地 API:160/160。 -- 本地 Web Playwright:50/50;最后的快照重试微调另有订单 1/1、审计 1/1 定向通过。 -- CI 竞态修复相关用例:12/12。 -- 容器契约:11/11。 -- API/Web 类型检查和生产构建:通过。 -- 分页合同回归覆盖 10/20/50/100 条、页码直达、总数、筛选/URL 恢复、非法每页条数及游标/页码互斥。 -- 隔离 Compose:首装、真实升级、ready/smoke、代理大请求、备份、新库恢复、最小权限、恢复库登录全部通过。 -- 本轮隔离 Compose 明确验证迁移 024 到 025 的真实升级并通过全部运行验收,且只清理本轮 `sfsecurity*` 资源。 -- `npm audit --omit=dev --package-lock-only`:0 vulnerabilities。本机 Node 22/npm 10 对已安装树返回 `Invalid package tree`;项目要求 Node 24,最终以干净 CI 的 `npm ci` 后标准审计为准。 -- `docker compose --env-file .env.example config --quiet`:通过。 -- PR #113 第二轮 required check:SUCCESS。 -- 合并后 `main` quality gate:SUCCESS。 -- PR #117 required check 与合并后 `main` quality gate:SUCCESS;实时 run ID 用第 2.1 节命令复核。 -- PR #124 required check:run `33618823240`,SUCCESS;合并后 `main` run `33619359468`,SUCCESS。 - -首轮 PR CI 曾暴露三处 Linux 时序竞态,不是产品逻辑失败: +| #12 多维分析父项 | 等待 #63 | 真实数据对账和业务 UAT 通过 | +| #63 真实数据能力验收与业务 UAT | 缺真实数据授权、批准映射、隔离验收环境和业务签字 | 获授权后完成来源冻结、预检、逐月/整体对账、幂等重放、角色 UAT 与签字 | +| #15 生产准备父项 | 等待 #64 | 公司服务器真实验收通过 | +| #64 公司服务器实际验收 | 当前没有服务器 | 域名/HTTPS、秘密、备份、监控、RPO/RTO、升级回退与运维签字齐全 | +| #65 P1 完成审计与收口 | 依赖 #12、#15、#63、#64 | 所有真实人工门禁关闭后再做最终审计 | -1. 退出后立即二次登录时,非精确“账号”定位器会匹配账号管理页的多个 ARIA 名称;已改为精确登录字段。 -2. E2E fixture 已自动跳过引导,测试再手动点击会和 locator handler 竞争;已删除重复操作。 -3. 成功响应过快时瞬时 loading 文案不可稳定观测;改验最终成功态。 +没有外部发布授权。不得创建 Tag、GitHub Release 或真实部署。#57 移动端已取消,不要恢复。 -这些自动化只证明仓库代码、合成数据和隔离环境,不证明真实数据 UAT、公司服务器、生产部署或外部发布。 +## 5. 下一步计划 -## 5. 当前卡点 +### 5.1 当前 PR 尚未合并时 -没有剩余可由工程代理独立完成的 P1。当前只剩人工 Gate: +1. 查看 PR #128 required check;失败则基于日志定位并修复,不能盲目重跑掩盖失败。 +2. required check 全绿且可合并后 squash merge,让 `Closes #127` 自动关闭 Issue。 +3. 合并后确认 `main` quality gate;若失败,修复到绿。 +4. 不新建 worktree,不切到额外目录;继续使用当前 checkout。 -1. #63:缺真实数据授权、来源哈希、权威组织/区域映射、批准的隔离环境、业务验收人和签字。 -2. #64:缺公司服务器、域名/HTTPS、正式秘密、备份存储、监控、RPO/RTO、维护窗口和运维责任人。 -3. #12、#15、#65 必须等待上述人工 Gate,不能用自动化结果代替。 -4. 没有外部发布授权;不得创建 Tag、GitHub Release 或真实部署。 +### 5.2 真实数据条件齐全后 -## 6. 下一步计划 +1. 单独取得真实数据授权,冻结来源 SHA-256、字段/组织/区域映射、批准人、隔离目标库、备份和恢复路径。 +2. 真实工作簿只进入批准的隔离环境;先预检,不写正式账本。 +3. 由业务责任人确认差异,再执行确认、逐月/整体对账、幂等重放和恢复演练。 +4. 完成各角色桌面 Web UAT 与签字,关闭 #63,再关闭 #12。 -### 6.1 当真实数据条件齐全时 +### 5.3 公司服务器条件齐全后 -1. 先获得明确授权,冻结来源文件 SHA-256、字段映射、组织/区域映射、批准人、目标验收库、备份与恢复路径。 -2. 真实数据只进入批准的隔离环境;姓名、客户、订单、组织关系和工作簿不得进入公开 Issue、PR、日志、截图或聊天。 -3. 先预检,不写正式账本;由业务责任人确认差异。 -4. 在独立库完成确认、逐月/整体对账、幂等重放、待补齐治理和恢复演练。 -5. 各角色完成桌面 Web UAT 并签字;关闭 #63,再关闭 #12。 - -### 6.2 当公司服务器齐全时 - -1. 获得服务器、网络、域名、HTTPS、秘密、备份、监控、RPO/RTO、维护窗口与回退授权。 +1. 单独取得服务器、网络、域名、HTTPS、正式秘密、备份、监控、维护窗口和回退授权。 2. 按 `docs/deployment.md` 首装或升级;API 启动不得隐式迁移或初始化。 -3. 验证可信代理、Origin、正式账号、数据库最小权限、备份保留、新库恢复和监控告警。 -4. 实测升级失败回退和核心 smoke,由运维责任人签字;关闭 #64,再关闭 #15。 - -### 6.3 P1 最终收口 +3. 验证可信代理、Origin、最小权限、备份保留、新库恢复、告警和升级回退。 +4. 运维签字后关闭 #64,再关闭 #15。 -当 #12、#15、#63、#64 全部关闭: +### 5.4 P1 最终收口 -1. 从最新 `origin/main` 重跑 `npm.cmd run verify`。 -2. 重新执行 Spec / Standards 双轴审查。 -3. 更新并关闭 #65。 -4. 更新 Roadmap #18。 -5. 只有用户另行批准,才创建 Tag、GitHub Release 或真实部署。 +当 #12、#15、#63、#64 全部真实关闭后,从最新 `origin/main` 重跑仓库门禁和 Spec/Standards 双轴审查,更新并关闭 #65。只有用户另行批准,才可创建 Tag、GitHub Release 或真实部署。 -## 7. 总体 Roadmap 与版本计划 +## 6. 总体 Roadmap 与版本计划 | 阶段 | 状态 | 退出条件 | | --- | --- | --- | | P0 | 已完成 | 权限、组织、账本、目标、认证、导入和可重复验收进入 `main` | -| P1 工程波次 | 已完成 | 桌面 Web、UI/UX、引导、分析、后台、审计、安全、可观测性、备份恢复和隔离 Compose 全绿 | +| P1 工程波次 | 本轮候选为 PR #128 | PR/CI/合并状态实时核对;Issue #127 关闭 | | P1 人工 Gate | 等待 | #63 真实数据 UAT、#64 公司服务器及父项/总验收关闭 | -| v1.1-alpha | 工程基线可内部评估,未发布 | 不代表外部发布或生产资格 | +| v1.1-alpha | 工程基线可内部评估,未发布 | 不代表生产资格或外部发布 | | v1.1-rc | 未达到 | 真实数据 UAT、服务器、备份恢复、安全、业务与运维签字齐全 | | v1.1 production | 未达到 | 正式域名/HTTPS/凭据/数据/监控/回退生效并获发布授权 | -| P2 | 未开始 | #16、#17、#35 等后续范围单独立项;#101、#112 已在本轮提前完成并关闭 | +| P2 | 未开始 | 后续范围重新立项;不自动恢复移动端 | 继续保持模块化单体。没有真实吞吐、隔离或团队边界证据前,不引入微服务、消息队列、GraphQL、第二套数据库或第二套浏览器测试框架。 -## 8. 绝对不要再踩的坑 - -1. **不要创建额外 worktree 或项目目录。** 用户明确反对 TB22 式目录;只在现有 checkout 工作,除非用户重新明确要求。 -2. **不要丢弃用户改动。** 禁止 `reset --hard`、广泛 clean/checkout/add/stash;只暂存明确文件。 -3. **不要把真实数据叫示例数据。** 真实姓名、客户、订单、组织映射和工作簿按敏感生产数据边界处理。 -4. **不要改写已应用迁移。** 新结构只追加迁移;同时验证空库、旧库升级和失败回滚。 -5. **迁移哈希先统一 CRLF/LF。** 否则 Windows 与 Linux 会产生假篡改告警。 -6. **所有数据库 bigint ID 都是字符串。** 不得在 API、前端、导出或导入中用 `Number()` 转换。 -7. **`system_admin` 不是业务超级账号。** 系统管理与业务读取/导出继续分离。 -8. **不要恢复手写签名。** 使用已认证身份、目标版本、固定声明、时间和不可修改审计;普通日志不能替代目标确认。 -9. **不要换回错误地图或占位 Logo。** 台湾省必须显示,不需要南海插图;Logo 使用现有真实资源。 -10. **受保护下载不能页面直跳。** 继续复用认证 fetch,并正确区分 401、403 和业务错误。 -11. **游标必须绑定筛选与快照。** 冷深链没有真实浏览历史时不得伪造“上一页”。 -12. **失败不能显示成零或空。** 有旧数据时保留旧数据并提示刷新失败;首次失败只显示失败和重试。 -13. **E2E 不要重复处理引导。** 普通用例由 `full-stack.ts` 的 locator handler 跳过;引导专测自行启用。 -14. **E2E 登录字段使用精确名称。** “账号管理/搜索账号/账号分页”已存在,非精确 `getByLabel("账号")` 会产生歧义。 -15. **不要断言不可稳定观测的瞬时 loading。** 若 loading 本身是验收目标,先用受控延迟;否则验证最终成功/失败态。 -16. **不要批量清理 Docker。** 只清理已核对的项目资源;数据库作业脚本保持 LF,`start_all.bat` 保持 CRLF。 -17. **不要把 CI 绿色等同于真实 UAT、服务器或发布。** 四类证据分别取证、分别授权。 -18. **不要自动恢复移动端、进入 P2、迁移真实数据或部署服务器。** -19. **GitHub 写入结果不确定时先查远端。** 遇到 TLS/EOF 先查 Issue、PR、分支和 run,不要重复写入。 -20. **页码 API 与旧游标 API 不得混用。** 桌面 Web 使用 `page`/`pageSize`/`totalCount`;兼容调用可继续使用游标,但单个请求同时提交两种模式必须返回 400。 -21. **本机代理可能只让部分 HTTPS 客户端成功。** 2026-09-02 曾出现 GitHub API 可访问但 Git、Docker Hub 和 npm 间歇 TLS/EOF;写入失败先查远端,Git 必要时一次性指定本机代理、HTTP/1.1、TLS 1.2 与 OpenSSL,禁止关闭证书校验。 -22. **金额两位小数校验不能使用固定极小误差。** 接近 `99_999_999_999.99` 时 IEEE-754 乘 100 误差可超过 `1e-7`;必须保留按数值规模计算的容差及大金额回归用例。 -23. **页码快照不能靠表级共享锁。** 用不可变递增 cutoff 冻结集合;令牌必须绑定当前用户和查询条件,筛选、每页条数、主动刷新或失败重试时重建。 - -## 9. 新会话接手顺序 +## 7. 绝对不要再踩的坑 + +1. 不要创建额外 worktree 或项目目录;用户明确反对 TB22 式目录。 +2. 不要导入、复制、打印或提交 `原始数据1.xlsx` 的真实业务行;它不是示例数据。 +3. 标准模板必须精确使用“分子”Sheet3 表头和中间空白列;不要猜列名,也不要改成其他 Sheet。 +4. 负数系统营业额是“应收未收”,不是错误、退款或暂停;普通调整和获批关账更正都可变为负数。 +5. 金额两位小数校验不能用固定 `1e-7`;大额 IEEE-754 乘法误差会误拒合法值,继续复用 `isSignedMoney`。 +6. 订单“备注”是首次入账备注;后续事件的“原因”不得覆盖订单备注。 +7. 协作归属不能放大公司总额;个人/组织聚合用归属份额,公司/地区/客户聚合用原始事件金额。 +8. 后续事件必须继承订单协作比例并按事件日期重新固化双方组织快照,不能追溯改写旧事件。 +9. 台湾省必须显示且使用真实事件数据;不要恢复错误地图、南海插图或假数据。 +10. 不要改写已应用迁移 001—025;本轮结构只能由迁移 026 追加。迁移必须同时验证空库和 025→026 升级。 +11. 所有 PostgreSQL bigint ID 都以字符串跨 API/前端/导入/导出传递,不能用 `Number()`。 +12. `system_admin` 不是业务超级账号;系统管理和业务读写/导出继续分离。 +13. 不要恢复手写签名;登录身份和不可修改审计也不能被普通日志替代。 +14. 不要丢弃用户改动或批量清理环境;禁止 `reset --hard`、广泛 clean/checkout/add/stash,只暂存明确文件,只清理已核对的隔离 Docker 资源。 +15. CI 绿色不等于真实数据 UAT、服务器验收或发布;四类证据分别取证、分别授权。 +16. GitHub 写入遇到 TLS/EOF 或结果不确定时先查远端,不能重复创建 Issue、PR 或合并。 +17. 200% 缩放焦点用例曾有一次时序抖动;先定向重复验证并看 trace,不能直接删除断言或把失败当通过。 +18. 不要自动进入 P2、恢复移动端、迁移真实数据、部署服务器或发布版本。 + +## 8. 新会话接手顺序 1. 完整阅读本文件。 -2. 阅读 `AGENTS.md`、`CONTEXT.md`、`docs/agents/`、相关 `docs/adr/` 与 `docs/specs/`。 -3. 执行第 2.1 节的只读 Git/GitHub/Docker 复核。 -4. 保护现有 checkout;不要新建 worktree。 -5. 若真实数据或服务器条件仍未齐全,只报告人工 Gate,不伪造进展。 -6. 只有用户明确提供条件与授权后,才恢复 #63 或 #64。 +2. 阅读 `AGENTS.md`、`CONTEXT.md`、`docs/agents/`、ADR-0031 和相关规格。 +3. 执行第 4 节的只读 Git/GitHub/Docker 复核,以实时证据覆盖本文中的易变状态。 +4. 保护当前 checkout,不创建新 worktree。 +5. 若 PR #128 尚未收口,先完成其 CI/合并;若已完成且真实数据/服务器条件仍不存在,只报告人工门禁,不伪造进展。 -接手原则:**实时证据优先;工程、真实数据 UAT、服务器验收和外部发布分别取证、分别授权。** +接手原则:实时证据优先;工程、真实数据 UAT、服务器验收和外部发布分别取证、分别授权。