From d72b69cbeae6ec82da4240a6ac514acc84ae0992 Mon Sep 17 00:00:00 2001 From: Eclipseic1848 <237380389+Eclipseic1848@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:37:54 -0700 Subject: [PATCH] fix: restore province data and yearly analysis (#135 #136 #137) --- CONTEXT.md | 4 + README.md | 5 +- .../027_repair_legacy_import_dates.sql | 166 ++++++++++++++++ .../src/backup-restore.integration.test.ts | 2 +- apps/api/src/import-job.integration.test.ts | 34 +++- apps/api/src/modules/performance.ts | 57 +++--- .../performance-analysis.integration.test.ts | 20 ++ apps/api/src/services/import-job.ts | 31 ++- .../api/src/test-database.integration.test.ts | 180 +++++++++++++++++- apps/web/e2e/analysis.spec.ts | 18 +- apps/web/e2e/login.spec.ts | 12 ++ apps/web/playwright.config.ts | 2 +- apps/web/src/app-types.ts | 4 +- apps/web/src/pages/analysis-page.tsx | 40 ++-- apps/web/src/pages/orders-page.tsx | 2 +- apps/web/src/styles.css | 34 ++++ .../0032-repair-known-legacy-date-offset.md | 19 ++ docs/performance-import.md | 4 +- 18 files changed, 575 insertions(+), 59 deletions(-) create mode 100644 apps/api/migrations/027_repair_legacy_import_dates.sql create mode 100644 docs/adr/0032-repair-known-legacy-date-offset.md diff --git a/CONTEXT.md b/CONTEXT.md index c0e4e9d..08a90d8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -81,6 +81,10 @@ _Avoid_: 自动识别的暂停、推断事件类型 对升级前已经存在、但缺少受控批次和业务区域证据的历史调整事件,通过重新上传原始文件建立的不可变核对关系。核对保存新批次、原始文件、配置版本、来源行和本次确认人,按已批准映射补齐订单业务区域;既有事件金额、语义、来源键和批次字段保持不变。原始线下录入人无法证明时明确记为未知,本次确认人不得冒充原始操作人。 _Avoid_: 改写旧事件、猜测原始操作人、合成不存在的来源文件 +**旧迁移日期修复**: +针对已证实由早期迁移器时区序列化造成、且仅命中特定权威来源哈希的统一早一天缺陷,在逐项核对来源位置、事件事实、整体/逐月基线并确认没有关账或后续业务事件后,通过显式数据库迁移一次性恢复来源日期、记账月、订单到样日期和源空备注。修复前后值必须进入独立不可变证据表,任一基线不符整批回滚;这只是修正转录错误,不是修改业务事实或一般历史更正入口。 +_Avoid_: 普通账本改写、模糊来源匹配、无备份修复、生产自动修复 + **历史待核订单**: 历史导入后无法仅凭原始流水可靠确定后续可操作状态的订单。历史净额为正的订单可标记为 `active`,仅有一条零金额行的订单可标记为 `zero`;多行相抵为零或净额为负的订单必须保持待核并锁定后续业务操作。其原始事件和金额继续进入历史账本与来源对账,销售助理组长记录核对结论、依据和原因,经人事部批准后才可解除限制;核对不得覆盖或重新解释历史事件和金额。 _Avoid_: 自动暂停订单、异常即删除 diff --git a/README.md b/README.md index ae0e341..dd3f5e1 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它 - 系统账号、首次改密、会话安全和角色权限矩阵。 - 部门、小组、人员身份和带有效期的组织任职。 -- 以“分子”Sheet3 字段为准的订单台账与只追加、不覆盖的业绩事件链;负数系统营业额明确标记为“应收未收”。 +- 以“分子”Sheet3 字段为准的订单台账与只追加、不覆盖的业绩事件链;关键识别/操作列固定,中间字段可横向滚动;负数系统营业额明确标记为“应收未收”。 - 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。 - 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。 +- 省份与客户单位分析支持按月、按年汇总和穿透,未取得来源证据的维度明确显示为待补齐。 - 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。 - 人工录入与受控 `.xlsx` 导入;标准模板覆盖日期、客户单位、省份、组织、服务类型、备注及协作分配,预检、确认、回滚和幂等证据分离。 - PostgreSQL、React、Fastify 和 Docker Compose 组成的模块化单体。 @@ -22,7 +23,7 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它 - GitHub Issue #1—#9 的 P0 工作已关闭;P1 当前状态以 `handoff.md` 与 Roadmap #18 为准。 - `main` 受保护,Pull Request 必须通过 `Typecheck, test, build and audit`。 -- 真实历史工作簿已在隔离临时数据库完成功能核对;原文件和行级业务数据不在仓库中。 +- 经明确授权后,本地开发库已完成真实历史工作簿的来源核对、4,701 条分析维度补齐和旧迁移日期偏移修复;原文件、行级业务数据和备份不在仓库中,这不代表业务 UAT 或生产迁移完成。 - P1 产品化与人工验收由 [Roadmap #18](https://github.com/Eclipseic1848/SampleFlow/issues/18) 跟踪;当前产品仅验收 1024px/1280px 桌面 Web,移动端 #57 已取消;未完成项只剩真实数据 UAT 与公司服务器验收。 - 当前事实、数据边界和接手步骤以 [`handoff.md`](handoff.md) 为准。 diff --git a/apps/api/migrations/027_repair_legacy_import_dates.sql b/apps/api/migrations/027_repair_legacy_import_dates.sql new file mode 100644 index 0000000..e35e861 --- /dev/null +++ b/apps/api/migrations/027_repair_legacy_import_dates.sql @@ -0,0 +1,166 @@ +create table legacy_event_date_repair_evidence ( + event_id bigint primary key references performance_events(id), + source_file_sha256 text not null, + source_sheet text not null, + source_row_number bigint not null, + previous_occurred_on date not null, + corrected_occurred_on date not null, + previous_accounting_month date not null, + corrected_accounting_month date not null, + previous_reason text not null, + corrected_reason text not null, + repaired_at timestamptz not null default now(), + check(corrected_occurred_on=previous_occurred_on+1), + check(corrected_accounting_month=date_trunc('month',corrected_occurred_on)::date), + check(corrected_reason=case when previous_reason='历史明细迁移' then '' else previous_reason end), + unique(source_file_sha256,source_sheet,source_row_number) +); + +create table legacy_order_date_repair_evidence ( + order_id bigint primary key references performance_orders(id), + source_file_sha256 text not null, + previous_source_received_on date not null, + corrected_source_received_on date not null, + repaired_at timestamptz not null default now(), + check(corrected_source_received_on=previous_source_received_on+1) +); + +create trigger legacy_event_date_repair_evidence_immutable_update +before update on legacy_event_date_repair_evidence +for each row execute function reject_import_batch_row_mutation(); + +create trigger legacy_event_date_repair_evidence_immutable_delete +before delete on legacy_event_date_repair_evidence +for each row execute function reject_import_batch_row_mutation(); + +create trigger legacy_order_date_repair_evidence_immutable_update +before update on legacy_order_date_repair_evidence +for each row execute function reject_import_batch_row_mutation(); + +create trigger legacy_order_date_repair_evidence_immutable_delete +before delete on legacy_order_date_repair_evidence +for each row execute function reject_import_batch_row_mutation(); + +do $$ +declare + source_hash constant text := '926aad3d8c59cc356094eb1abc0ca1fcb3392eae5867f2b7c0e2bb50bb5c01cf'; + event_count bigint; + order_count bigint; + total_amount numeric(14,2); + first_date date; + last_date date; + legacy_default_reason_count bigint; + reviewed_row_count bigint; +begin + select count(*),count(distinct event.order_id),coalesce(sum(event.delta_amount),0), + min(event.occurred_on),max(event.occurred_on), + count(*) filter(where event.reason='历史明细迁移') + into event_count,order_count,total_amount,first_date,last_date,legacy_default_reason_count + from legacy_event_source_evidence evidence + join performance_events event on event.id=evidence.event_id + where evidence.source_file_sha256=source_hash; + + if event_count=0 then return; end if; + if event_count<>4701 or order_count<>2850 or total_amount<>14675659.07 + or first_date<>date '2026-01-03' or last_date<>date '2026-08-25' + or legacy_default_reason_count<>2711 then + raise exception '旧历史日期修复基线不一致,拒绝自动修复'; + end if; + if exists( + with expected(period_month,event_count,total_amount) as (values + (date '2026-01-01',635::bigint,2314819.55::numeric), + (date '2026-02-01',455::bigint,1252546.10::numeric), + (date '2026-03-01',560::bigint,1346159.95::numeric), + (date '2026-04-01',520::bigint,1989517.64::numeric), + (date '2026-05-01',474::bigint,1499121.10::numeric), + (date '2026-06-01',631::bigint,2234990.59::numeric), + (date '2026-07-01',778::bigint,2487624.14::numeric), + (date '2026-08-01',648::bigint,1550880.00::numeric) + ), actual as ( + select date_trunc('month',event.occurred_on+1)::date period_month, + count(*)::bigint event_count,sum(event.delta_amount)::numeric total_amount + from legacy_event_source_evidence evidence + join performance_events event on event.id=evidence.event_id + where evidence.source_file_sha256=source_hash + group by date_trunc('month',event.occurred_on+1)::date + ) + select 1 from expected full join actual using(period_month) + where expected.event_count is distinct from actual.event_count + or expected.total_amount is distinct from actual.total_amount + ) then + raise exception '旧历史日期修复逐月基线不一致,拒绝自动修复'; + end if; + select count(*) into reviewed_row_count + from legacy_event_source_evidence evidence + join performance_events event on event.id=evidence.event_id + join legacy_event_analysis_dimension_backfills receipt + on receipt.event_id=event.id and receipt.source_file_sha256=source_hash and receipt.result='applied' + join import_batches batch + on batch.id=receipt.batch_id and batch.source_sha256=source_hash + and batch.purpose='dimension_backfill' and batch.status='imported' + join import_batch_rows source_row + on source_row.id=receipt.batch_row_id and source_row.batch_id=receipt.batch_id + and source_row.source_sheet=evidence.source_sheet + and source_row.source_row_number=evidence.source_row_number + where evidence.source_file_sha256=source_hash + and (source_row.normalized_data->>'occurredOn')::date=event.occurred_on+1 + and coalesce(source_row.normalized_data->>'reason','')= + case when event.reason='历史明细迁移' then '' else event.reason end; + if reviewed_row_count<>event_count then + raise exception '旧历史日期修复缺少逐行已审核来源证据,拒绝自动修复'; + end if; + if exists(select 1 from accounting_period_closures) + or exists(select 1 from accounting_periods where status='closed') then + raise exception '旧历史日期修复前已有关闭期间,必须另行走更正治理'; + end if; + if exists( + select 1 + from performance_events later + where later.event_type<>'legacy_adjustment' + and later.order_id in ( + select event.order_id + from legacy_event_source_evidence evidence + join performance_events event on event.id=evidence.event_id + where evidence.source_file_sha256=source_hash + ) + ) then + raise exception '旧历史订单已有后续业务事件,拒绝自动修复日期'; + end if; +end; +$$; + +insert into legacy_event_date_repair_evidence( + event_id,source_file_sha256,source_sheet,source_row_number, + previous_occurred_on,corrected_occurred_on,previous_accounting_month,corrected_accounting_month, + previous_reason,corrected_reason +) +select event.id,evidence.source_file_sha256,evidence.source_sheet,evidence.source_row_number, + event.occurred_on,event.occurred_on+1,event.accounting_month,date_trunc('month',event.occurred_on+1)::date, + event.reason,case when event.reason='历史明细迁移' then '' else event.reason end +from legacy_event_source_evidence evidence +join performance_events event on event.id=evidence.event_id +where evidence.source_file_sha256='926aad3d8c59cc356094eb1abc0ca1fcb3392eae5867f2b7c0e2bb50bb5c01cf'; + +insert into legacy_order_date_repair_evidence( + order_id,source_file_sha256,previous_source_received_on,corrected_source_received_on +) +select distinct orders.id,evidence.source_file_sha256,orders.source_received_on,orders.source_received_on+1 +from legacy_event_source_evidence evidence +join performance_events event on event.id=evidence.event_id +join performance_orders orders on orders.id=event.order_id +where evidence.source_file_sha256='926aad3d8c59cc356094eb1abc0ca1fcb3392eae5867f2b7c0e2bb50bb5c01cf'; + +alter table performance_events disable trigger performance_events_immutable_update; + +update performance_events event set + occurred_on=evidence.corrected_occurred_on, + accounting_month=evidence.corrected_accounting_month, + reason=evidence.corrected_reason +from legacy_event_date_repair_evidence evidence +where evidence.event_id=event.id; + +alter table performance_events enable trigger performance_events_immutable_update; + +update performance_orders orders set source_received_on=evidence.corrected_source_received_on +from legacy_order_date_repair_evidence evidence +where evidence.order_id=orders.id; diff --git a/apps/api/src/backup-restore.integration.test.ts b/apps/api/src/backup-restore.integration.test.ts index d1f6fc3..ac5cf46 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\|26\|[a-f0-9]{32}$/m); + assert.match(sourceSummary.stdout, /^schema_migrations\|27\|[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/import-job.integration.test.ts b/apps/api/src/import-job.integration.test.ts index f07d8de..56677f4 100644 --- a/apps/api/src/import-job.integration.test.ts +++ b/apps/api/src/import-job.integration.test.ts @@ -532,7 +532,7 @@ test("重新上传原始历史文件时关联既有事件并补齐区域但不 }); }); -test("历史分析维度补齐只写快照且预检、重放和冲突保持安全", async () => { +test("历史分析维度补齐写入事件快照和订单当前投影且预检、重放和冲突保持安全", async () => { await withMigratedTestDatabase(async (database) => { const context = await fixture(database.url); try { @@ -551,9 +551,9 @@ test("历史分析维度补齐只写快照且预检、重放和冲突保持安 const event = await context.pool.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,source_row_number,salesperson_person_id,order_sequence) - values($1,'legacy_adjustment',100,100,100,'2026-03-01','2026-03-05','首次转录','业务员甲','销售一部','一组',2,$2,1) + values($1,'legacy_adjustment',100,100,100,'2026-03-01','2026-03-04','历史明细迁移','业务员甲','销售一部','一组',2,null,1) returning id::text`, - [order.rows[0]!.id, context.salespersonPersonId], + [order.rows[0]!.id], ); await context.pool.query( `insert into legacy_event_source_evidence(event_id,source_file_sha256,source_sheet,source_row_number,source_key) @@ -561,7 +561,24 @@ test("历史分析维度补齐只写快照且预检、重放和冲突保持安 [event.rows[0]!.id, sourceHash, `legacy:${sourceHash}:分子:2`], ); await context.pool.query("commit"); - const sourceRow = row({ sheet:"分子", sourceRecordId:undefined, eventType:"legacy_adjustment", customerUnit:"单位甲", businessRegionSourceText:"江苏省" }); + const sourceRow = row({ sheet:"分子", sourceRecordId:undefined, eventType:"legacy_adjustment", customerUnit:"单位甲", businessRegionSourceText:"江苏省", salespersonSourceKey:"业务员甲", reason:"" }); + + const wrongLegacyDate = await preflightDimensionBackfillRows(context.pool, { + actorUserId:context.actorUserId, configId, sourceFileName:"合成历史-错误日期.xlsx", sourceBytes, + rows:[{ ...sourceRow, occurredOn:"2026-03-06" }], + }); + assert.equal(wrongLegacyDate.status, "blocked"); + assert.ok(wrongLegacyDate.issues.some((issue) => issue.code === "SOURCE_RECORD_CONFLICT")); + + const unapprovedLegacyOffset = await preflightDimensionBackfillRows(context.pool, { + actorUserId:context.actorUserId, configId, sourceFileName:"合成历史-未批准日期偏移.xlsx", sourceBytes, rows:[sourceRow], + }); + assert.equal(unapprovedLegacyOffset.status, "blocked"); + assert.ok(unapprovedLegacyOffset.issues.some((issue) => issue.code === "SOURCE_RECORD_CONFLICT")); + await context.pool.query("begin"); + await context.pool.query("set local session_replication_role=replica"); + await context.pool.query("update performance_events set occurred_on='2026-03-05',reason='' where id=$1", [event.rows[0]!.id]); + await context.pool.query("commit"); const preflight = await preflightDimensionBackfillRows(context.pool, { actorUserId:context.actorUserId, configId, sourceFileName:"合成历史.xlsx", sourceBytes, rows:[sourceRow], @@ -588,7 +605,9 @@ test("历史分析维度补齐只写快照且预检、重放和冲突保持安 `select dimensions.business_region_code,dimensions.business_region_source_text,dimensions.customer_unit, receipt.source_file_sha256,receipt.confirmed_by::text,receipt.result, event.event_type,event.delta_amount::text,event.reason,evidence.source_key, - performance_order.customer_unit order_customer_unit,performance_order.service_type order_service_type + performance_order.customer_unit order_customer_unit,performance_order.service_type order_service_type, + performance_order.business_region_source_text order_region_source_text, + performance_order.business_region_code order_region_code from performance_event_analysis_dimensions dimensions join legacy_event_analysis_dimension_backfills receipt on receipt.event_id=dimensions.event_id join performance_events event on event.id=dimensions.event_id @@ -598,8 +617,9 @@ test("历史分析维度补齐只写快照且预检、重放和冲突保持安 assert.deepEqual(written.rows[0], { business_region_code:"CN-JS", business_region_source_text:"江苏省", customer_unit:"单位甲", source_file_sha256:sourceHash, confirmed_by:context.actorUserId, result:"applied", - event_type:"legacy_adjustment", delta_amount:"100.00", reason:"首次转录", source_key:`legacy:${sourceHash}:分子:2`, - order_customer_unit:"旧单位", order_service_type:"旧服务", + event_type:"legacy_adjustment", delta_amount:"100.00", reason:"", source_key:`legacy:${sourceHash}:分子:2`, + order_customer_unit:"单位甲", order_service_type:"旧服务", + order_region_source_text:"江苏省", order_region_code:"CN-JS", }); await assert.rejects( context.pool.query("update legacy_event_analysis_dimension_backfills set result='applied' where event_id=$1", [event.rows[0]!.id]), diff --git a/apps/api/src/modules/performance.ts b/apps/api/src/modules/performance.ts index 373c964..8545929 100644 --- a/apps/api/src/modules/performance.ts +++ b/apps/api/src/modules/performance.ts @@ -35,14 +35,19 @@ const dashboardQuerySchema = z.object({ }); const analysisProvinceSchema = z.string().refine((value) => value.startsWith("CN-") && standardBusinessRegionName(value) !== undefined); const analysisMonthSchema = z.string().regex(/^[1-9]\d{3}-(0[1-9]|1[0-2])$/); +const analysisYearSchema = z.string().regex(/^[1-9]\d{3}$/); +const performanceAnalysisQuerySchema = z.strictObject({ + month: analysisMonthSchema.optional(), + year: analysisYearSchema.optional(), +}).refine((value) => !(value.month && value.year)); const paginationQueryFields={page:pageNumberSchema.optional(),pageSize:pageSizeSchema.optional()}; const analysisDrilldownQuerySchema = z.discriminatedUnion("level", [ - z.strictObject({ level: z.literal("customers"), regionCode: analysisProvinceSchema, month: analysisMonthSchema, cursor: z.string().min(1).max(2048).optional(), snapshot: z.string().min(1).max(2048).optional(), ...paginationQueryFields }), + z.strictObject({ level: z.literal("customers"), regionCode: analysisProvinceSchema, month: analysisMonthSchema.optional(), year: analysisYearSchema.optional(), cursor: z.string().min(1).max(2048).optional(), snapshot: z.string().min(1).max(2048).optional(), ...paginationQueryFields }), z.strictObject({ level: z.literal("months"), regionCode: analysisProvinceSchema, customerUnit: z.string().trim().min(1).max(300), - year: z.string().regex(/^[1-9]\d{3}$/), + year: analysisYearSchema, }), z.strictObject({ level: z.literal("events"), @@ -53,7 +58,7 @@ const analysisDrilldownQuerySchema = z.discriminatedUnion("level", [ snapshot: z.string().min(1).max(2048).optional(), ...paginationQueryFields, }), -]); +]).refine((value) => value.level !== "customers" || Boolean(value.month) !== Boolean(value.year)); const groupAchievementQuerySchema = dashboardQuerySchema.extend({ groupId: postgresBigintIdSchema, }); @@ -1012,9 +1017,12 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl app.get("/api/performance/analysis", async (request, reply) => { if (!request.currentUser) return reply.code(401).send({ message: "尚未登录" }); - const parsed = dashboardQuerySchema.safeParse(request.query); - if (!parsed.success) return reply.code(400).send({ code: "MONTH_INVALID", message: "月份格式无效" }); - const month = parsed.data.month ?? businessDate(clock()).slice(0, 7); + const parsed = performanceAnalysisQuerySchema.safeParse(request.query); + if (!parsed.success) return reply.code(400).send({ code: "ANALYSIS_PERIOD_INVALID", message: "分析月份或年份格式无效" }); + const month = parsed.data.month ?? (parsed.data.year ? null : businessDate(clock()).slice(0, 7)); + const year = parsed.data.year ?? null; + const periodStart = month ? `${month}-01` : `${year}-01-01`; + const periodInterval = month ? "1 month" : "1 year"; const client = await db.connect(); try { await client.query("begin transaction isolation level repeatable read read only"); @@ -1024,15 +1032,15 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl return reply.code(403).send({ message: "当前角色没有业务查看权限" }); } const result = await client.query( - `with monthly_events as materialized ( - select id from performance_events where accounting_month=$1::date + `with period_events as materialized ( + select id from performance_events where accounting_month >= $1::date and accounting_month < $1::date + $2::interval ), scoped_credits as materialized ( select credit.event_id,credit.attributed_amount from performance_event_attributions credit - where ${performanceAttributionScopeSql("credit", 2)} + where ${performanceAttributionScopeSql("credit", 3)} ), 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 + from scoped_credits join period_events on period_events.id=scoped_credits.event_id group by scoped_credits.event_id ), scoped_analysis as materialized ( @@ -1073,7 +1081,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl order by case aggregates.kind when 'ledger' then 1 when 'mapped' then 2 when 'pending' then 3 when 'province' then 4 when 'foreign_trade' then 5 else 6 end, aggregates.total_amount desc,aggregates.region_code,aggregates.customer_unit`, - [`${month}-01`, ...performanceScopeValues(access)], + [periodStart, periodInterval, ...performanceScopeValues(access)], ); await client.query("commit"); const summary = (kind: PerformanceAnalysisRow["kind"]) => { @@ -1083,7 +1091,8 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const regions = result.rows.filter((row) => row.kind === "province"); const foreignTrade = result.rows.find((row) => row.kind === "foreign_trade")!; return { - month, + periodType: year ? "year" : "month", + ...(year ? { year } : { month }), ledger: summary("ledger"), mapped: summary("mapped"), pending: summary("pending"), @@ -1149,22 +1158,24 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl } await client.query("begin transaction isolation level repeatable read read only"); if (parsed.data.level === "customers") { + const periodStart = parsed.data.month ? `${parsed.data.month}-01` : `${parsed.data.year}-01-01`; + const periodInterval = parsed.data.month ? "1 month" : "1 year"; 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)} + select coalesce($11::bigint,max(dimension_sequence)) as sequence from performance_event_analysis_dimensions + ), period_events as materialized ( + select id from performance_events where accounting_month >= $1::date and accounting_month < $1::date + $2::interval + ), scoped_amounts as materialized (${scopedEventAmountsSql(4)} ), raw_scoped_analysis as materialized ( select e.id as event_id,scoped_amounts.delta_amount,dimensions.customer_unit - from monthly_events e + from period_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 dimensions.business_region_code=$2 + where dimensions.business_region_code=$3 and dimensions.dimension_sequence<=dimension_cutoff.sequence ), cutoff as ( - select coalesce($9::bigint,max(event_id)) as event_id from raw_scoped_analysis + select coalesce($10::bigint,max(event_id)) as event_id from raw_scoped_analysis ), scoped_analysis as materialized ( select raw.delta_amount,raw.customer_unit from raw_scoped_analysis raw cross join cutoff where raw.event_id<=cutoff.event_id @@ -1175,9 +1186,9 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl select count(*)::bigint as event_count,coalesce(sum(delta_amount),0.00) as total_amount from scoped_analysis ), page as ( select customer_unit,event_count,total_amount from customers - where ($7::numeric is null or total_amount<$7::numeric or (total_amount=$7::numeric and customer_unit>$8)) + where ($8::numeric is null or total_amount<$8::numeric or (total_amount=$8::numeric and customer_unit>$9)) order by total_amount desc,customer_unit - limit $11 offset $12 + limit $12 offset $13 ) select summary.event_count::text,summary.total_amount::text, (select count(*)::text from customers) as customer_count, @@ -1191,7 +1202,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl filter(where page.customer_unit is not null),'[]'::jsonb) as customers from summary cross join cutoff cross join dimension_cutoff left join page on true group by summary.event_count,summary.total_amount,cutoff.event_id,dimension_cutoff.sequence`, - [`${parsed.data.month}-01`, parsed.data.regionCode, ...performanceScopeValues(access), customerCursor?.totalAmount ?? null, customerCursor?.customerUnit ?? null, customerCursor?.cutoffEventId ?? snapshot?.cutoffEventId ?? null, customerCursor?.cutoffDimensionSequence ?? snapshot?.cutoffDimensionSequence ?? null, numbered?pageSize:ANALYSIS_CUSTOMER_PAGE_SIZE + 1, numbered?(page-1)*pageSize:0], + [periodStart, periodInterval, parsed.data.regionCode, ...performanceScopeValues(access), customerCursor?.totalAmount ?? null, customerCursor?.customerUnit ?? null, customerCursor?.cutoffEventId ?? snapshot?.cutoffEventId ?? null, customerCursor?.cutoffDimensionSequence ?? snapshot?.cutoffDimensionSequence ?? null, numbered?pageSize:ANALYSIS_CUSTOMER_PAGE_SIZE + 1, numbered?(page-1)*pageSize:0], ); await client.query("commit"); const row = result.rows[0]!; @@ -1202,7 +1213,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl level: "customers", regionCode: parsed.data.regionCode, regionName: standardBusinessRegionName(parsed.data.regionCode)!, - month: parsed.data.month, + ...(parsed.data.month ? { month: parsed.data.month } : { year: parsed.data.year }), eventCount: Number(row.event_count), totalAmount: row.total_amount, customerCount: Number(row.customer_count), diff --git a/apps/api/src/performance-analysis.integration.test.ts b/apps/api/src/performance-analysis.integration.test.ts index cb1543d..cd6bc9e 100644 --- a/apps/api/src/performance-analysis.integration.test.ts +++ b/apps/api/src/performance-analysis.integration.test.ts @@ -172,6 +172,23 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 assert.ok(analysisReadCount <= 4, `分析请求数据库读取应不超过 4 次,实际 ${analysisReadCount} 次`); const smallReadCount = analysisReadCount; const capturedAnalysisQuery = requireCapturedQuery(analysisQuery); + const yearly = await app.inject({ method: "GET", url: "/api/performance/analysis?year=2026", headers: { cookie: leaderCookie } }); + assert.equal(yearly.statusCode, 200, yearly.body); + assert.deepEqual(yearly.json().ledger, { eventCount: 6, totalAmount: "162.00" }); + assert.deepEqual(yearly.json().mapped, { eventCount: 5, totalAmount: "132.00" }); + assert.deepEqual(yearly.json().pending, { eventCount: 1, totalAmount: "30.00" }); + assert.deepEqual(yearly.json().provinces, [ + { regionCode: "CN-JS", regionName: "江苏省", eventCount: 3, totalAmount: "107.00" }, + { regionCode: "CN-ZJ", regionName: "浙江省", eventCount: 1, totalAmount: "50.00" }, + ]); + const yearlyCustomers = await app.inject({ + method: "GET", + url: "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&year=2026", + headers: { cookie: leaderCookie }, + }); + assert.equal(yearlyCustomers.statusCode, 200, yearlyCustomers.body); + assert.equal(yearlyCustomers.json().eventCount, 3); + assert.equal(yearlyCustomers.json().totalAmount, "107.00"); assert.doesNotMatch(capturedAnalysisQuery.statement, /performance_orders/i); const smallExplain = await setup.query<{ "QUERY PLAN": Array<{ Plan: Record }> }>( `explain (analyze,format json) ${capturedAnalysisQuery.statement}`, @@ -565,10 +582,13 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 assert.equal(unauthenticated.statusCode, 401, unauthenticated.body); const invalid = await app.inject({ method: "GET", url: "/api/performance/analysis?month=2026-13", headers: { cookie: leaderCookie } }); assert.equal(invalid.statusCode, 400, invalid.body); + const ambiguousPeriod = await app.inject({ method: "GET", url: "/api/performance/analysis?month=2026-08&year=2026", headers: { cookie: leaderCookie } }); + assert.equal(ambiguousPeriod.statusCode, 400, ambiguousPeriod.body); for (const url of [ "/api/performance/analysis/drilldown?level=unknown®ionCode=CN-JS&month=2026-08", "/api/performance/analysis/drilldown?level=customers®ionCode=CN-UNKNOWN&month=2026-08", "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08&extra=true", + "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08&year=2026", "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08&cursor=invalid%25", "/api/performance/analysis/drilldown?level=months®ionCode=CN-JS&customerUnit=%20%20&year=2026", ]) { diff --git a/apps/api/src/services/import-job.ts b/apps/api/src/services/import-job.ts index 552e9f0..e0f4b48 100644 --- a/apps/api/src/services/import-job.ts +++ b/apps/api/src/services/import-job.ts @@ -598,7 +598,7 @@ async function loadDimensionBackfillTargets( `select evidence.event_id::text,evidence.source_file_sha256,evidence.source_sheet, evidence.source_row_number::text,evidence.source_key,event.event_type, performance_order.qingflow_order_no order_no,event.occurred_on::text, - salesperson.source_key salesperson_source_key,event.delta_amount::text,event.reason, + coalesce(salesperson.source_key,event.salesperson_name) salesperson_source_key,event.delta_amount::text,event.reason, dimensions.business_region_code,dimensions.business_region_source_text,dimensions.customer_unit, receipt.source_file_sha256 receipt_source_sha256 from legacy_event_source_evidence evidence @@ -633,12 +633,16 @@ function dimensionBackfillEvaluation( && target.source_sheet === row.sheet && Number(target.source_row_number) === row.rowNumber && target.source_key === row.sourceKey; + const knownLegacyOffset = sourceHash === "926aad3d8c59cc356094eb1abc0ca1fcb3392eae5867f2b7c0e2bb50bb5c01cf"; + const storedDate = String(target.occurred_on).slice(0, 10); + const legacyStoredDate = new Date(Date.parse(`${row.occurredOn}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10); + const reasonMatches = (target.reason ?? "") === row.reason || (knownLegacyOffset && !row.reason && target.reason === "历史明细迁移"); const eventMatches = target.event_type === "legacy_adjustment" && target.order_no === row.orderNo - && String(target.occurred_on).slice(0, 10) === row.occurredOn + && (storedDate === row.occurredOn || (knownLegacyOffset && storedDate === legacyStoredDate)) && target.salesperson_source_key === row.salespersonSourceKey && Math.round(Number(target.delta_amount) * 100) === Math.round(row.amount * 100) - && (target.reason ?? "") === row.reason; + && reasonMatches; if (!sourceMatches || !eventMatches) { addIssue(issues, row, "SOURCE_RECORD_CONFLICT", "受控来源位置已存在,但事件事实与本次来源行不一致"); continue; @@ -1344,6 +1348,27 @@ export async function confirmDimensionBackfillBatch( from jsonb_to_recordset($1::jsonb) item(event_id bigint,batch_row_id bigint)`, [JSON.stringify(values), batchId, sourceHash, actorUserId, confirmedAt], ); + await client.query( + `with affected_orders as ( + select distinct event.order_id + from jsonb_to_recordset($1::jsonb) item(event_id bigint) + join performance_events event on event.id=item.event_id + ), latest_events as ( + select distinct on (event.order_id) event.id,event.order_id + from performance_events event join affected_orders affected on affected.order_id=event.order_id + where event.event_type='legacy_adjustment' + order by event.order_id,event.occurred_on desc,event.source_row_number desc nulls last, + event.source_sheet desc nulls last,event.id desc + ) + update performance_orders orders + set customer_unit=dimensions.customer_unit, + business_region_source_text=dimensions.business_region_source_text, + business_region_code=dimensions.business_region_code + from latest_events latest + join performance_event_analysis_dimensions dimensions on dimensions.event_id=latest.id + where orders.id=latest.order_id`, + [JSON.stringify(values)], + ); } const result = toApply.length ? "applied" : "replayed"; const confirmation = { result, applied:toApply.length, alreadyMapped, pending:unresolvedPending, pendingAmount:unresolvedPendingAmount }; diff --git a/apps/api/src/test-database.integration.test.ts b/apps/api/src/test-database.integration.test.ts index a58cf40..7fa85b1 100644 --- a/apps/api/src/test-database.integration.test.ts +++ b/apps/api/src/test-database.integration.test.ts @@ -86,6 +86,7 @@ test("干净隔离数据库可应用全部现有迁移", async () => { "024_auth_throttle_cleanup.sql", "025_enforce_performance_order_state_amounts.sql", "026_sheet3_order_input.sql", + "027_repair_legacy_import_dates.sql", ]); assert.ok(result.rows.every((row) => /^[a-f0-9]{64}$/.test(row.sha256))); await assert.rejects( @@ -396,7 +397,7 @@ test("已有不可变业绩事件的数据库可升级并保持事件不可变", test("旧历史导入事件升级后保持不变并由独立证据表保存复合来源键", async () => { await withTestDatabase(async (database) => { - const sourceHash = "926aad3d8c59cc356094eb1abc0ca1fcb3392eae5867f2b7c0e2bb50bb5c01cf"; + const sourceHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const client = new Client({ connectionString: database.url }); await client.connect(); try { @@ -466,6 +467,183 @@ test("旧历史导入事件升级后保持不变并由独立证据表保存复 }); }); +test("权威旧历史日期只在完整基线吻合时留下证据并受控修复", async () => { + await withTestDatabase(async (database) => { + const sourceHash = "926aad3d8c59cc356094eb1abc0ca1fcb3392eae5867f2b7c0e2bb50bb5c01cf"; + const repairOnlyMigrations = await mkdtemp(path.join(tmpdir(), "sampleflow-date-repair-")); + const client = new Client({ connectionString: database.url }); + await client.connect(); + try { + await client.query("create table schema_migrations(name text primary key,applied_at timestamptz not null default now())"); + const previousMigrations = (await readdir(migrationsRoot)).filter((name) => name.endsWith(".sql") && name < "027_").sort(); + for (const name of previousMigrations) { + const sql = await readFile(`${migrationsRoot}${name}`, "utf8"); + await client.query(sql); + await client.query("insert into schema_migrations(name) values($1)", [name]); + await writeFile(path.join(repairOnlyMigrations, name), sql, "utf8"); + } + await writeFile( + path.join(repairOnlyMigrations, "027_repair_legacy_import_dates.sql"), + await readFile(`${migrationsRoot}027_repair_legacy_import_dates.sql`, "utf8"), + "utf8", + ); + await client.query( + `insert into performance_orders(qingflow_order_no,customer_name,customer_unit,salesperson_name,source_received_on, + original_amount,current_revenue,counted_amount,lifecycle_state,posted_at) + select 'LEGACY-DATE-'||n,'旧客户','旧单位','旧业务员','2026-01-03',1,1,1,'active',now() + from generate_series(1,2850) n`, + ); + await client.query( + "insert into legacy_import_runs(source_file,source_sha256,source_rows,imported_orders,imported_events) values('原始数据1.xlsx',$1,4701,2850,4701)", + [sourceHash], + ); + await client.query("begin"); + await client.query("set local session_replication_role=replica"); + await client.query( + `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,source_row_number,order_sequence) + values(1,'legacy_adjustment',14675659.07,14675659.07,14675659.07,'2026-01-01','2026-01-03','历史明细迁移','旧业务员','旧部门','旧小组',2,1)`, + ); + await client.query( + "insert into legacy_event_source_evidence(event_id,source_file_sha256,source_sheet,source_row_number,source_key) select id,$1,'分子',2,'legacy:'||$1||':分子:2' from performance_events", + [sourceHash], + ); + await client.query("commit"); + await assert.rejects( + execFileAsync(process.execPath, ["--import", "tsx", "src/cli/migrate.ts"], { + cwd: apiRoot, + env: { ...process.env, DATABASE_URL: database.url, NODE_ENV: "test", TEST_MIGRATIONS_DIR: repairOnlyMigrations }, + encoding: "utf8", + }), + /旧历史日期修复基线不一致/, + ); + await client.query("begin"); + await client.query("set local session_replication_role=replica"); + await client.query( + `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,source_row_number,order_sequence) + select case when n<=2850 then n else n-2850 end,'legacy_adjustment',0,1,1, + date_trunc('month',case when n=4701 then date '2026-08-25' else date '2026-01-04' end)::date, + case when n=4701 then date '2026-08-25' else date '2026-01-04' end, + case when n<=2711 then '历史明细迁移' else '原始备注' end, + '旧业务员','旧部门','旧小组',n+1,case when n<=2850 then 1 else 2 end + from generate_series(2,4701) n`, + ); + await client.query( + `insert into legacy_event_source_evidence(event_id,source_file_sha256,source_sheet,source_row_number,source_key) + select id,$1,'分子',source_row_number,'legacy:'||$1||':分子:'||source_row_number + from performance_events where id>1`, + [sourceHash], + ); + await client.query("commit"); + await assert.rejects( + execFileAsync(process.execPath, ["--import", "tsx", "src/cli/migrate.ts"], { + cwd: apiRoot, + env: { ...process.env, DATABASE_URL: database.url, NODE_ENV: "test", TEST_MIGRATIONS_DIR: repairOnlyMigrations }, + encoding: "utf8", + }), + /旧历史日期修复逐月基线不一致/, + ); + await client.query("begin"); + await client.query("set local session_replication_role=replica"); + await client.query( + `with numbered as ( + select id,row_number() over(order by source_row_number)::int n from performance_events + ), corrected as ( + select id,n, + case when n=1 then date '2026-01-03' + when n<=635 then date '2026-01-14' + when n<=1090 then date '2026-02-14' + when n<=1650 then date '2026-03-14' + when n<=2170 then date '2026-04-14' + when n<=2644 then date '2026-05-14' + when n<=3275 then date '2026-06-14' + when n<=4053 then date '2026-07-14' + when n=4701 then date '2026-08-25' + else date '2026-08-14' end occurred_on, + case n when 1 then 2314819.55 when 636 then 1252546.10 when 1091 then 1346159.95 + when 1651 then 1989517.64 when 2171 then 1499121.10 when 2645 then 2234990.59 + when 3276 then 2487624.14 when 4054 then 1550880.00 else 0 end amount + from numbered + ) + update performance_events event set occurred_on=corrected.occurred_on, + accounting_month=date_trunc('month',corrected.occurred_on)::date, + delta_amount=corrected.amount,resulting_current_revenue=corrected.amount,resulting_counted_amount=corrected.amount, + reason=case when corrected.n<=2711 then '历史明细迁移' else '原始备注' end + from corrected where corrected.id=event.id`, + ); + const actor = await client.query<{id:string}>( + "insert into users(username,display_name,password_hash,password_salt) values('date-repair-reviewer','日期修复审核人','hash','salt') returning id::text", + ); + const batch = await client.query<{id:string}>( + `insert into import_batches(config_id,source_file_name,source_sha256,source_bytes,status,uploaded_by,confirmed_by, + row_count,order_count,event_count,total_amount,purpose,confirmed_at,reconciliation_summary) + select min(id),'原始数据1.xlsx',$1,''::bytea,'imported',$2,$2,4701,2850,4701,14675659.07,'dimension_backfill',now(),'{}'::jsonb + from import_configs returning id::text`, + [sourceHash,actor.rows[0]!.id], + ); + await client.query( + `insert into import_batch_rows(batch_id,source_sheet,source_row_number,source_key,duplicate_fingerprint,normalized_data) + select $1,evidence.source_sheet,evidence.source_row_number,evidence.source_key,evidence.source_key, + jsonb_build_object('occurredOn',(event.occurred_on+1)::text,'reason', + case when event.reason='历史明细迁移' then '' else event.reason end) + from legacy_event_source_evidence evidence join performance_events event on event.id=evidence.event_id + where evidence.source_file_sha256=$2`, + [batch.rows[0]!.id,sourceHash], + ); + await client.query( + `insert into performance_event_analysis_dimensions(event_id,business_region_code,business_region_source_text,customer_unit) + select event_id,'CN-JS','江苏省','旧单位' from legacy_event_source_evidence where source_file_sha256=$1`, + [sourceHash], + ); + await client.query( + `insert into legacy_event_analysis_dimension_backfills(event_id,batch_id,batch_row_id,source_file_sha256,confirmed_by,result) + select evidence.event_id,$1,row.id,$2,$3,'applied' + from legacy_event_source_evidence evidence join import_batch_rows row + on row.batch_id=$1 and row.source_sheet=evidence.source_sheet and row.source_row_number=evidence.source_row_number + where evidence.source_file_sha256=$2`, + [batch.rows[0]!.id,sourceHash,actor.rows[0]!.id], + ); + await client.query("commit"); + } finally { + await client.end(); + await rm(repairOnlyMigrations, { recursive: true, force: true }); + } + + await execFileAsync(process.execPath, ["--import", "tsx", "src/cli/migrate.ts"], { + cwd: apiRoot, + env: { ...process.env, DATABASE_URL: database.url, NODE_ENV: "test" }, + encoding: "utf8", + }); + + const verify = new Client({ connectionString: database.url }); + await verify.connect(); + try { + const result = await verify.query<{ + events: number; eventEvidence: number; orderEvidence: number; firstDate: string; lastDate: string; + wrongAccountingMonths: number; blankReasons: number; firstOrderDate: string; + }>( + `select (select count(*)::int from performance_events) events, + (select count(*)::int from legacy_event_date_repair_evidence) "eventEvidence", + (select count(*)::int from legacy_order_date_repair_evidence) "orderEvidence", + (select min(occurred_on)::text from performance_events) "firstDate", + (select max(occurred_on)::text from performance_events) "lastDate", + (select count(*)::int from performance_events where accounting_month<>date_trunc('month',occurred_on)::date) "wrongAccountingMonths", + (select count(*)::int from performance_events where reason='') "blankReasons", + (select min(source_received_on)::text from performance_orders) "firstOrderDate"`, + ); + assert.deepEqual(result.rows[0], { + events:4701, eventEvidence:4701, orderEvidence:2850, firstDate:"2026-01-04", lastDate:"2026-08-26", + wrongAccountingMonths:0, blankReasons:2711, firstOrderDate:"2026-01-04", + }); + await assert.rejects(verify.query("update performance_events set reason='篡改' where id=1"), /已入账业绩事件不可更新或删除/); + await assert.rejects(verify.query("delete from legacy_event_date_repair_evidence where event_id=1"), /导入批次行证据不可更新或删除/); + } finally { + await verify.end(); + } + }); +}); + test("既有启用组织在负责人治理前可升级并安全转为停用",async()=>{ await withTestDatabase(async(database)=>{ const client=new Client({connectionString:database.url}); diff --git a/apps/web/e2e/analysis.spec.ts b/apps/web/e2e/analysis.spec.ts index dc56f20..bb450ce 100644 --- a/apps/web/e2e/analysis.spec.ts +++ b/apps/web/e2e/analysis.spec.ts @@ -108,14 +108,30 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await expect(customers.getByRole("row", { name: /江苏省.*大额客户.*101.*¥100,999,999,999,998\.99/ })).toBeVisible(); await expect(customers.getByRole("row", { name: /外贸.*客户单位乙.*1.*-¥25\.00/ })).toBeVisible(); + await analysis.getByLabel("分析范围").selectOption("year"); + await expect(analysis.getByLabel("分析年份")).toHaveValue("2026"); + await analysis.getByRole("button", { name: "查看", exact: true }).click(); + await expect(analysis.locator(".metric").filter({ hasText: "授权范围总账" }).getByText("¥101,000,000,000,170.99", { exact: true })).toBeVisible(); + await expect(provinces.getByRole("row", { name: /江苏省.*103.*¥101,000,000,000,105\.99/ })).toBeVisible(); + expect(Object.fromEntries(new URL(page.url()).searchParams)).toMatchObject({ analysisRange: "year", analysisYear: "2026" }); + expect(new URL(page.url()).searchParams.has("analysisMonth")).toBe(false); + await provinces.getByRole("button", { name: "江苏省" }).click(); + const yearlyDrilldown=analysis.getByRole("region", { name: "分析穿透" }); + await expect(yearlyDrilldown.getByRole("row", { name: /客户单位甲.*2.*¥107\.00/ })).toBeVisible(); + await yearlyDrilldown.getByRole("button", { name: "查看客户单位甲月份趋势" }).click(); + await expect(yearlyDrilldown.getByText("2026年金额与上级客户行完全对平。", { exact: true })).toBeVisible(); + await analysis.getByLabel("分析范围").selectOption("month"); + await analysis.getByLabel("分析月份").fill("2026-08"); + await expect(provinces.getByRole("row", { name: /江苏省.*102.*¥101,000,000,000,098\.99/ })).toBeVisible(); + const map = analysis.getByRole("group", { name: "中国省份业绩地图" }); + await expect(map.locator('[data-region-code="CN-TW"]')).toBeVisible(); const mapRegionCodes = await map.locator("[data-region-code]").evaluateAll((elements) => elements.map((element) => element.getAttribute("data-region-code")).sort()); expect(mapRegionCodes).toEqual([ "CN-AH", "CN-BJ", "CN-CQ", "CN-FJ", "CN-GD", "CN-GS", "CN-GX", "CN-GZ", "CN-HA", "CN-HB", "CN-HE", "CN-HI", "CN-HK", "CN-HL", "CN-HN", "CN-JL", "CN-JS", "CN-JX", "CN-LN", "CN-MO", "CN-NM", "CN-NX", "CN-QH", "CN-SC", "CN-SD", "CN-SH", "CN-SN", "CN-SX", "CN-TJ", "CN-TW", "CN-XJ", "CN-XZ", "CN-YN", "CN-ZJ", ]); - await expect(map.locator('[data-region-code="CN-TW"]')).toBeVisible(); await expect(map.locator(".analysis-map-boundary")).toHaveCount(0); const southernEdge = await map.locator("[data-region-code]").evaluateAll((elements) => Math.max(...elements.map((element) => { const box = (element as SVGGraphicsElement).getBBox(); return box.y + box.height; }))); expect(southernEdge).toBeLessThanOrEqual(608.01); diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts index 2c8ad27..f830d67 100644 --- a/apps/web/e2e/login.spec.ts +++ b/apps/web/e2e/login.spec.ts @@ -182,6 +182,18 @@ test("订单台账支持每页条数与可点击页码", async ({ database, page const ledger = page.locator("section.orders-card").filter({ has: page.getByRole("heading", { name: "订单台账" }) }); await expect(ledger.getByText("本页 20 笔订单", { exact: true })).toBeVisible(); await expect(ledger.getByText("E2E-CURSOR-0101", { exact: true })).toBeVisible(); + await expect(ledger.getByText("待补齐", { exact: true }).first()).toBeVisible(); + const scroller=ledger.locator(".order-ledger-scroll"); + const headers=ledger.getByRole("table", { name: "订单台账" }).getByRole("columnheader"); + const beforeScroll=await headers.evaluateAll((cells)=>cells.map((cell)=>cell.getBoundingClientRect().x)); + expect(await headers.nth(0).evaluate((cell)=>getComputedStyle(cell).position)).toBe("sticky"); + expect(await headers.nth(13).evaluate((cell)=>getComputedStyle(cell).position)).toBe("sticky"); + await scroller.evaluate((element)=>{element.scrollLeft=element.scrollWidth-element.clientWidth;}); + await expect.poll(()=>scroller.evaluate((element)=>element.scrollLeft)).toBeGreaterThan(0); + const afterScroll=await headers.evaluateAll((cells)=>cells.map((cell)=>cell.getBoundingClientRect().x)); + for(const index of [0,1,2,13,14,15])expect(Math.abs(afterScroll[index]!-beforeScroll[index]!)).toBeLessThanOrEqual(1); + expect(afterScroll[3]!).toBeLessThan(beforeScroll[3]!-100); + expect(afterScroll[2]!+(await headers.nth(2).evaluate((cell)=>cell.getBoundingClientRect().width))).toBeLessThanOrEqual(afterScroll[13]!+1); const pageSize=ledger.getByLabel("订单每页条数"); expect(await pageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); await pageSize.selectOption("50"); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 37fa133..34bfcf6 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ }, { name: "desktop-1024", - grep: /业务员默认查看当前月个人目标并穿透正负业绩事件|订单组合筛选由 URL 恢复并区分空集、失败和无权限|业绩分析页显示事件快照地区、外贸、客户单位和待补齐对账|系统管理员搜索分页账号并审计固定角色组合变更|桌面关键页面无横向溢出且操作可达|200% 桌面缩放仍保留导航、键盘焦点和无遮挡操作|支持焦点约束、键盘退出、跳过全部和无障碍/, + grep: /业务员默认查看当前月个人目标并穿透正负业绩事件|订单台账支持每页条数与可点击页码|订单组合筛选由 URL 恢复并区分空集、失败和无权限|业绩分析页显示事件快照地区、外贸、客户单位和待补齐对账|系统管理员搜索分页账号并审计固定角色组合变更|桌面关键页面无横向溢出且操作可达|200% 桌面缩放仍保留导航、键盘焦点和无遮挡操作|支持焦点约束、键盘退出、跳过全部和无障碍/, use: { viewport: { width: 1024, height: 800 } }, }, ], diff --git a/apps/web/src/app-types.ts b/apps/web/src/app-types.ts index cdd9932..6c434a1 100644 --- a/apps/web/src/app-types.ts +++ b/apps/web/src/app-types.ts @@ -24,8 +24,8 @@ export type DashboardData = { month: string; metrics: { total: string; eventCoun export type AnalysisAmount = { eventCount:number;totalAmount:string }; export type AnalysisProvince = {regionCode:string;regionName:string;eventCount:number;totalAmount:string}; export type AnalysisCustomer = {customerUnit:string;eventCount:number;totalAmount:string}; -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 PerformanceAnalysis = { periodType:"month"|"year";month?:string;year?: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;year?: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: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[]}; diff --git a/apps/web/src/pages/analysis-page.tsx b/apps/web/src/pages/analysis-page.tsx index 8b40244..27d57b8 100644 --- a/apps/web/src/pages/analysis-page.tsx +++ b/apps/web/src/pages/analysis-page.tsx @@ -13,23 +13,30 @@ const chinaMapRegionCodes:Record={ "540000":"CN-XZ","610000":"CN-SN","620000":"CN-GS","630000":"CN-QH","640000":"CN-NX", "650000":"CN-XJ","710000":"CN-TW","810000":"CN-HK","820000":"CN-MO", }; -function readAnalysisUrlState(){const params=new URLSearchParams(window.location.search);const month=params.get("analysisMonth");return{month:month&&/^\d{4}-(0[1-9]|1[0-2])$/.test(month)?month:null,region:params.get("analysisRegion"),customer:params.get("analysisCustomer"),eventMonth:params.get("analysisEventMonth"),customerPage:parsePageNumber(params.get("analysisCustomerPage")),customerPageSize:parsePageSize(params.get("analysisCustomerPageSize")),customerSnapshot:params.get("analysisCustomerSnapshot"),eventPage:parsePageNumber(params.get("analysisEventPage")),eventPageSize:parsePageSize(params.get("analysisEventPageSize")),eventSnapshot:params.get("analysisEventSnapshot")};} -function writeAnalysisUrlState(values:{month?:string;region?:string|null;customer?:string|null;eventMonth?:string|null;customerPage?:number|null;customerPageSize?:PageSize|null;customerSnapshot?:string|null;eventPage?:number|null;eventPageSize?:PageSize|null;eventSnapshot?:string|null},mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const [key,value] of Object.entries({analysisMonth:values.month,analysisRegion:values.region,analysisCustomer:values.customer,analysisEventMonth:values.eventMonth,analysisCustomerPage:values.customerPage,analysisCustomerPageSize:values.customerPageSize,analysisCustomerSnapshot:values.customerSnapshot,analysisEventPage:values.eventPage,analysisEventPageSize:values.eventPageSize,analysisEventSnapshot:values.eventSnapshot}))if(value===null)params.delete(key);else if(value!==undefined)params.set(key,String(value));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} +type AnalysisRange="month"|"year"; +function readAnalysisUrlState(){const params=new URLSearchParams(window.location.search);const month=params.get("analysisMonth");const year=params.get("analysisYear");return{range:params.get("analysisRange")==="year"?"year" as const:"month" as const,month:month&&/^\d{4}-(0[1-9]|1[0-2])$/.test(month)?month:null,year:year&&/^[1-9]\d{3}$/.test(year)?year:null,region:params.get("analysisRegion"),customer:params.get("analysisCustomer"),eventMonth:params.get("analysisEventMonth"),customerPage:parsePageNumber(params.get("analysisCustomerPage")),customerPageSize:parsePageSize(params.get("analysisCustomerPageSize")),customerSnapshot:params.get("analysisCustomerSnapshot"),eventPage:parsePageNumber(params.get("analysisEventPage")),eventPageSize:parsePageSize(params.get("analysisEventPageSize")),eventSnapshot:params.get("analysisEventSnapshot")};} +function writeAnalysisUrlState(values:{range?:AnalysisRange;month?:string|null;year?:string|null;region?:string|null;customer?:string|null;eventMonth?:string|null;customerPage?:number|null;customerPageSize?:PageSize|null;customerSnapshot?:string|null;eventPage?:number|null;eventPageSize?:PageSize|null;eventSnapshot?:string|null},mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const [key,value] of Object.entries({analysisRange:values.range,analysisMonth:values.month,analysisYear:values.year,analysisRegion:values.region,analysisCustomer:values.customer,analysisEventMonth:values.eventMonth,analysisCustomerPage:values.customerPage,analysisCustomerPageSize:values.customerPageSize,analysisCustomerSnapshot:values.customerSnapshot,analysisEventPage:values.eventPage,analysisEventPageSize:values.eventPageSize,analysisEventSnapshot:values.eventSnapshot}))if(value===null)params.delete(key);else if(value!==undefined)params.set(key,String(value));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} export function AnalysisPage(){ const initial=useRef(readAnalysisUrlState()).current; + const currentMonth=businessDateToday().slice(0,7); + const[range,setRange]=useState(initial.range); const[month,setMonth]=useState(initial.month??businessDateToday().slice(0,7)); + const[year,setYear]=useState(initial.year??currentMonth.slice(0,4)); + const[yearDraft,setYearDraft]=useState(initial.year??currentMonth.slice(0,4)); const[data,setData]=useState(null); const[error,setError]=useState(""); const[revision,setRevision]=useState(0); const[selectedProvince,setSelectedProvince]=useState(null); - useEffect(()=>{const restore=()=>{const state=readAnalysisUrlState();setMonth(state.month??businessDateToday().slice(0,7));setSelectedProvince(null);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); - useEffect(()=>{const controller=new AbortController();setData(null);setError("");apiFetch(`/api/performance/analysis?month=${month}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"分析服务响应无效");if(!response.ok)throw new Error(result.message??"分析加载失败");setData(result);const region=readAnalysisUrlState().region;setSelectedProvince(result.provinces.find((province)=>province.regionCode===region)??null);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setError(failure instanceof Error?failure.message:"分析加载失败");});return()=>controller.abort();},[month,revision]); + const period=range==="month"?month:year; + const periodText=range==="month"?`${month.replace("-","年")}月`:`${year}年`; + useEffect(()=>{const restore=()=>{const state=readAnalysisUrlState();const restoredMonth=state.month??businessDateToday().slice(0,7);const restoredYear=state.year??restoredMonth.slice(0,4);setRange(state.range);setMonth(restoredMonth);setYear(restoredYear);setYearDraft(restoredYear);setSelectedProvince(null);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); + useEffect(()=>{const controller=new AbortController();setData(null);setError("");apiFetch(`/api/performance/analysis?${range}=${period}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"分析服务响应无效");if(!response.ok)throw new Error(result.message??"分析加载失败");setData(result);const region=readAnalysisUrlState().region;setSelectedProvince(result.provinces.find((province)=>province.regionCode===region)??null);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setError(failure instanceof Error?failure.message:"分析加载失败");});return()=>controller.abort();},[range,period,revision]); function chooseProvince(province:AnalysisProvince){writeAnalysisUrlState({region:province.regionCode,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,customerSnapshot:null,eventPage:null,eventPageSize:null,eventSnapshot:null});setSelectedProvince(province);} - return

地区与客户单位分析

只按事件发生时的不可变分析维度快照汇总,不使用订单当前资料

+ return

地区与客户单位分析

只按事件发生时的不可变分析维度快照汇总,不使用订单当前资料

{event.preventDefault();if(!/^[1-9]\d{3}$/.test(yearDraft))return;writeAnalysisUrlState({range:"year",month:null,year:yearDraft,region:null,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,customerSnapshot:null,eventPage:null,eventPageSize:null,eventSnapshot:null});setSelectedProvince(null);setYear(yearDraft);}}>{range==="month"?:<>}
{error?

{error}

:null} {!data&&!error?

正在读取地区与客户单位分析…

:null} - {data?<>

{data.reconciled?"已映射金额 + 待补齐金额与授权范围总账完全对平。":"分析维度对账失败,请停止使用当前汇总。"}

省份汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
省份事件金额
{item.eventCount}{formatMoney(item.totalAmount)}
本月没有已映射省份事件。
}
外贸(EXT-TRADE)独立区域,不进入省份统计
{data.foreignTrade.eventCount} 条事件 · {formatMoney(data.foreignTrade.totalAmount)}

客户单位汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
区域客户单位事件金额
{item.regionName}{item.customerUnit}{item.eventCount}{formatMoney(item.totalAmount)}
本月没有已映射客户单位事件。
}
{selectedProvince?:null}:null} + {data?<>

{data.reconciled?"已映射金额 + 待补齐金额与授权范围总账完全对平。":"分析维度对账失败,请停止使用当前汇总。"}

省份汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
省份事件金额
{item.eventCount}{formatMoney(item.totalAmount)}
{periodText}没有已映射省份事件。
}
外贸(EXT-TRADE)独立区域,不进入省份统计
{data.foreignTrade.eventCount} 条事件 · {formatMoney(data.foreignTrade.totalAmount)}

客户单位汇总

{(pageItems)=>
{pageItems.length?pageItems.map((item)=>):}
区域客户单位事件金额
{item.regionName}{item.customerUnit}{item.eventCount}{formatMoney(item.totalAmount)}
{periodText}没有已映射客户单位事件。
}
{selectedProvince?:null}:null}
; } @@ -50,7 +57,7 @@ function ChinaProvinceMap({provinces,selectedRegionCode,onSelect}:{provinces:Ana })}:

{mapError||"正在读取中国省级地图…"}

}

颜色深浅只表示相对规模;金额以右侧省份汇总为准。

; } -function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:string}){ +function AnalysisDrilldown({province,range,period}:{province:AnalysisProvince;range:AnalysisRange;period:string}){ const initial=useRef(readAnalysisUrlState()).current; const[customers,setCustomers]=useState(null); const[customersError,setCustomersError]=useState(""); @@ -72,17 +79,17 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str useEffect(()=>{ const controller=new AbortController();setCustomers(null);setSelectedCustomer(null);setMonths(null);setSelectedMonth(null);setEvents(null);setCustomersError(""); - const params=new URLSearchParams({level:"customers",regionCode:province.regionCode,month,page:String(customerPage),pageSize:String(customerPageSize)});if(customerSnapshotRef.current)params.set("snapshot",customerSnapshotRef.current); + const params=new URLSearchParams({level:"customers",regionCode:province.regionCode,[range]:period,page:String(customerPage),pageSize:String(customerPageSize)});if(customerSnapshotRef.current)params.set("snapshot",customerSnapshotRef.current); apiFetch(`/api/performance/analysis/drilldown?${params}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"省份客户响应无效");if(!response.ok)throw new Error(result.message??"省份客户加载失败");customerSnapshotRef.current=result.snapshot;writeAnalysisUrlState({customerSnapshot:result.snapshot},"replace");const lastPage=Math.max(1,Math.ceil(result.totalCount/customerPageSize));if(customerPage>lastPage){writeAnalysisUrlState({customerPage:lastPage},"replace");setCustomerPage(lastPage);return;}setCustomers(result);const customer=readAnalysisUrlState().customer;if(customer)setSelectedCustomer(result.customers.find((item)=>item.customerUnit===customer)??null);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setCustomersError(failure instanceof Error?failure.message:"省份客户加载失败");}); return()=>controller.abort(); - },[province.regionCode,month,customerPage,customerPageSize,customersRevision]); + },[province.regionCode,range,period,customerPage,customerPageSize,customersRevision]); useEffect(()=>{ setMonths(null);setMonthsError("");setSelectedMonth(null);setEvents(null);if(!selectedCustomer)return; - const controller=new AbortController();const params=new URLSearchParams({level:"months",regionCode:province.regionCode,customerUnit:selectedCustomer.customerUnit,year:month.slice(0,4)}); + const controller=new AbortController();const params=new URLSearchParams({level:"months",regionCode:province.regionCode,customerUnit:selectedCustomer.customerUnit,year:range==="year"?period:period.slice(0,4)}); apiFetch(`/api/performance/analysis/drilldown?${params}`,{signal:controller.signal}).then(async(response)=>{const result=await readResponseJson(response,"客户月份响应无效");if(!response.ok)throw new Error(result.message??"客户月份加载失败");setMonths(result);const eventMonth=readAnalysisUrlState().eventMonth;if(eventMonth)setSelectedMonth(result.months.find((item)=>item.month===eventMonth)??null);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setMonthsError(failure instanceof Error?failure.message:"客户月份加载失败");}); return()=>controller.abort(); - },[province.regionCode,selectedCustomer,month,monthsRevision]); + },[province.regionCode,selectedCustomer,range,period,monthsRevision]); useEffect(()=>{ setEvents(null);setEventsError("");if(!selectedCustomer||!selectedMonth)return; @@ -93,9 +100,10 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str const customerMatched=customers&&formatMoney(customers.totalAmount)===formatMoney(province.totalAmount)&&customers.eventCount===province.eventCount; const monthRows=new Map(months?.months.map((item)=>[item.month,item])??[]); - const filledMonths=Array.from({length:12},(_,index)=>{const value=`${month.slice(0,4)}-${String(index+1).padStart(2,"0")}`;return monthRows.get(value)??{month:value,eventCount:0,totalAmount:"0.00"};}); - const parentMonth=months&&selectedCustomer?monthRows.get(month):null; - const monthMatched=parentMonth&&selectedCustomer&&formatMoney(parentMonth.totalAmount)===formatMoney(selectedCustomer.totalAmount)&&parentMonth.eventCount===selectedCustomer.eventCount; + const analysisYear=range==="year"?period:period.slice(0,4); + const filledMonths=Array.from({length:12},(_,index)=>{const value=`${analysisYear}-${String(index+1).padStart(2,"0")}`;return monthRows.get(value)??{month:value,eventCount:0,totalAmount:"0.00"};}); + const parentPeriod=months&&selectedCustomer?(range==="year"?months:monthRows.get(period)):null; + const monthMatched=parentPeriod&&selectedCustomer&&formatMoney(parentPeriod.totalAmount)===formatMoney(selectedCustomer.totalAmount)&&parentPeriod.eventCount===selectedCustomer.eventCount; const eventsMatched=events&&selectedMonth&&formatMoney(events.totalAmount)===formatMoney(selectedMonth.totalAmount)&&events.eventCount===selectedMonth.eventCount; const allEvents=events?.orders.flatMap((order)=>order.events)??[]; function changeCustomerPage(page:number){writeAnalysisUrlState({customer:null,eventMonth:null,customerPage:page,customerSnapshot:customerSnapshotRef.current,eventPage:null,eventPageSize:null,eventSnapshot:null});setCustomerPage(page);} @@ -105,8 +113,8 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str return

{province.regionName}客户单位

{province.eventCount} 条事件 · 省份汇总 {formatMoney(province.totalAmount)}

{customersError?

{customersError}

:null} {!customers&&!customersError?

正在读取{province.regionName}客户单位…

:null} - {customers?<>

{customerMatched?`服务端客户事件数与金额 ${formatMoney(customers.totalAmount)} 均与省份汇总完全对平。`:"客户合计与省份汇总不一致,请停止使用当前穿透结果。"}

{customers.customers.length?customers.customers.map((customer)=>):}
客户单位事件金额穿透
{customer.customerUnit}{customer.eventCount}{formatMoney(customer.totalAmount)}
该省份本月没有客户单位事件。
:null} - {selectedCustomer?

{selectedCustomer.customerUnit}月度趋势

{month.slice(0,4)} 年 · 客户年度净额 {months?formatMoney(months.totalAmount):"读取中"}

{monthsError?

{monthsError}

:null}{!months&&!monthsError?

正在读取{selectedCustomer.customerUnit}月度趋势…

:null}{months?<>

{monthMatched?`${month.replace("-","年")}月金额与上级客户行完全对平。`:`${month.replace("-","年")}月金额与上级客户行不一致,请停止使用当前穿透结果。`}

{filledMonths.map((item)=>
)}
:null}
:null} + {customers?<>

{customerMatched?`服务端客户事件数与金额 ${formatMoney(customers.totalAmount)} 均与省份汇总完全对平。`:"客户合计与省份汇总不一致,请停止使用当前穿透结果。"}

{customers.customers.length?customers.customers.map((customer)=>):}
客户单位事件金额穿透
{customer.customerUnit}{customer.eventCount}{formatMoney(customer.totalAmount)}
该省份{range==="year"?"本年":"本月"}没有客户单位事件。
:null} + {selectedCustomer?

{selectedCustomer.customerUnit}月度趋势

{analysisYear} 年 · 客户年度净额 {months?formatMoney(months.totalAmount):"读取中"}

{monthsError?

{monthsError}

:null}{!months&&!monthsError?

正在读取{selectedCustomer.customerUnit}月度趋势…

:null}{months?<>

{monthMatched?`${range==="year"?`${analysisYear}年`:`${period.replace("-","年")}月`}金额与上级客户行完全对平。`:`${range==="year"?`${analysisYear}年`:`${period.replace("-","年")}月`}金额与上级客户行不一致,请停止使用当前穿透结果。`}

{filledMonths.map((item)=>
)}
:null}
:null} {selectedMonth&&selectedCustomer?

{Number(selectedMonth.month.slice(0,4))}年{Number(selectedMonth.month.slice(5))}月订单与事件

{selectedCustomer.customerUnit} · 上级月份净额 {formatMoney(selectedMonth.totalAmount)}

{eventsError?

{eventsError}

:null}{!events&&!eventsError?

正在读取订单与不可变事件…

:null}{events?<>

{eventsMatched?`服务端全部订单事件合计 ${formatMoney(events.totalAmount)} 与上级月份完全对平。`:"订单事件合计与上级月份不一致,请停止使用当前穿透结果。"}

本页 {allEvents.length} / 共 {events.eventCount} 条事件

{events.orders.length?events.orders.map((order)=>):}
订单客户事件净额
{order.orderNo}{order.customerName}{order.eventCount}{formatMoney(order.totalAmount)}
该月份没有订单事件。
{allEvents.length?
{allEvents.map((event)=>)}
序号事件金额业务日 / 记账月发生时分析维度责任归属状态 / 原因
第 {event.sequence} 条{eventTypeName(event.eventType)}{formatMoney(event.deltaAmount)}{event.occurredOn} / {event.accountingMonth}{province.regionName} / {event.customerUnit}{event.businessRegionSourceText}{event.salespersonName}{[event.departmentName,event.groupName].filter(Boolean).join(" / ")||"—"}{event.resultingLifecycleState?:"原始状态未推断"}{event.reason??"—"}
:null}:null}
:null}
; } diff --git a/apps/web/src/pages/orders-page.tsx b/apps/web/src/pages/orders-page.tsx index d603f87..a03773f 100644 --- a/apps/web/src/pages/orders-page.tsx +++ b/apps/web/src/pages/orders-page.tsx @@ -90,7 +90,7 @@ export function OrdersPage({ user }: { user: User }) {
-
{!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)}
+
{Array.from({length:16},(_,index)=>)}{!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} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7ccb09c..911def8 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -277,6 +277,10 @@ form button:disabled { cursor: wait; opacity: .72; } .analysis-header label { width: 180px; margin: 0; } .analysis-header label span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; } .analysis-header input { height: 40px; } +.analysis-period-controls { margin: 0; display: flex; align-items: end; gap: 8px; } +.analysis-period-controls label:first-child { width: 126px; } +.analysis-period-controls select { width: 100%; height: 40px; padding: 0 10px; border: 1px solid var(--line); border-radius: 6px; color: var(--navy); background: #fff; } +.analysis-period-controls button { width: auto; height: 40px; margin: 0; padding: 0 15px; border: 1px solid var(--blue); border-radius: 6px; color: #fff; background: var(--blue); } .analysis-metrics { margin-top: 0; grid-template-columns: repeat(3,1fr); border-right: 0; border-left: 0; } .analysis-reconciliation { margin: 0; padding: 12px 22px; color: #147465; background: #eaf7f4; font-size: 13px; } .analysis-reconciliation-error { color: #a63232; background: #fdeaea; } @@ -353,6 +357,31 @@ th { color: #607084; background: #fafbfc; font-weight: 600; } .account-search-field > .account-search-clear:hover { color: var(--navy); background: #eef2f6; } .icon-action { width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 6px; color: #56657a; background: #fff; display: grid; place-items: center; cursor: pointer; } .orders-table-wrap { overflow-x: auto; } +.order-ledger-table { --month-width: 80px; --date-width: 112px; --number-width: 160px; --counted-width: 124px; --status-width: 106px; --actions-width: 118px; min-width: 2008px; border-collapse: separate; border-spacing: 0; table-layout: fixed; } +.order-ledger-table th, .order-ledger-table td { padding: 12px 14px; overflow: hidden; text-overflow: ellipsis; } +.order-ledger-table col:nth-child(1) { width: var(--month-width); } +.order-ledger-table col:nth-child(2) { width: var(--date-width); } +.order-ledger-table col:nth-child(3) { width: var(--number-width); } +.order-ledger-table col:nth-child(4) { width: 108px; } +.order-ledger-table col:nth-child(5) { width: 180px; } +.order-ledger-table col:nth-child(6) { width: 92px; } +.order-ledger-table col:nth-child(7) { width: 100px; } +.order-ledger-table col:nth-child(8), .order-ledger-table col:nth-child(9), .order-ledger-table col:nth-child(11) { width: 112px; } +.order-ledger-table col:nth-child(10) { width: 132px; } +.order-ledger-table col:nth-child(12) { width: 220px; } +.order-ledger-table col:nth-child(13) { width: 140px; } +.order-ledger-table col:nth-child(14) { width: var(--counted-width); } +.order-ledger-table col:nth-child(15) { width: var(--status-width); } +.order-ledger-table col:nth-child(16) { width: var(--actions-width); } +.order-ledger-table .order-key-month, .order-ledger-table .order-key-date, .order-ledger-table .order-key-number, .order-ledger-table .order-key-counted, .order-ledger-table .order-key-status, .order-ledger-table .order-key-actions { position: sticky; z-index: 1; background: #fff; } +.order-ledger-table thead .order-key-month, .order-ledger-table thead .order-key-date, .order-ledger-table thead .order-key-number, .order-ledger-table thead .order-key-counted, .order-ledger-table thead .order-key-status, .order-ledger-table thead .order-key-actions { z-index: 2; background: #fafbfc; } +.order-ledger-table .order-key-month { left: 0; } +.order-ledger-table .order-key-date { left: var(--month-width); } +.order-ledger-table .order-key-number { left: calc(var(--month-width) + var(--date-width)); box-shadow: 8px 0 10px -10px #64758a; } +.order-ledger-table .order-key-actions { right: 0; } +.order-ledger-table .order-key-status { right: var(--actions-width); } +.order-ledger-table .order-key-counted { right: calc(var(--actions-width) + var(--status-width)); box-shadow: -8px 0 10px -10px #64758a; } +.data-pending { display: inline-flex; padding: 2px 7px; border-radius: 999px; color: #6b7280; background: #eef1f4; font-size: 11px; } .audit-table { min-width: 1180px; } .audit-data { min-width: 220px; max-width: 340px; color: #3f5065; overflow-wrap: anywhere; white-space: normal; } .permission-matrix-table { min-width: 1080px; white-space: normal; } @@ -605,6 +634,7 @@ th { color: #607084; background: #fafbfc; font-weight: 600; } .metric:last-child { border-bottom: 0; } .dashboard-grid { grid-template-columns: 1fr; } .analysis-header { align-items: flex-start; } + .analysis-period-controls { flex-wrap: wrap; } .analysis-grid { grid-template-columns: 1fr; } .analysis-card + .analysis-card { border-left: 0; } .analysis-map-layout { grid-template-columns: 1fr; } @@ -615,6 +645,10 @@ th { color: #607084; background: #fafbfc; font-weight: 600; } .events-panel { max-width: 100%; } } +@media (max-width: 700px) { + .order-ledger-table .order-key-month, .order-ledger-table .order-key-date, .order-ledger-table .order-key-number, .order-ledger-table .order-key-counted, .order-ledger-table .order-key-status, .order-ledger-table .order-key-actions { position: static; box-shadow: none; } +} + @media (max-width: 480px) { .dashboard > header { display: grid; } .primary-action { width: 100%; } diff --git a/docs/adr/0032-repair-known-legacy-date-offset.md b/docs/adr/0032-repair-known-legacy-date-offset.md new file mode 100644 index 0000000..6c485d4 --- /dev/null +++ b/docs/adr/0032-repair-known-legacy-date-offset.md @@ -0,0 +1,19 @@ +# ADR-0032:受控修复已知旧迁移日期偏移 + +## 状态 + +已接受。 + +## 决策 + +早期一次性迁移器把 `read-excel-file` 返回的 JavaScript `Date` 直接交给 PostgreSQL,受运行时区影响,将 `原始数据1.xlsx` 的 4,701 条日期统一保存为前一天;源空备注同时被替换成“历史明细迁移”。权威工作簿、不可变来源哈希、工作表和来源行号已经证明这是转录缺陷,而不是业务更正。 + +迁移 `027_repair_legacy_import_dates.sql` 是 ADR-0005、ADR-0021 和 ADR-0022 的一次性窄例外。它仅在已知来源哈希、4,701 条事件、2,850 笔订单、14,675,659.07 元、8 个月的数量与金额、旧日期范围和 2,711 条旧默认备注全部一致时运行。每个目标事件还必须逐行关联到同一来源哈希的不可变批次行和维度补齐收据,批次行日期必须比库内日期晚一天;同时不得存在已关闭期间或相关订单后续业务事件。迁移先把每条事件和订单的修复前后值写入不可变证据表,再将事件日期加一天、重新推导记账月、将旧默认备注恢复为空,并修正订单到样日期;任一检查或写入失败时由迁移事务整批回滚。 + +该修复不修改金额、人员、组织、订单号、事件类型、来源位置或来源哈希。修复完成后,原有事件不可更新/删除触发器继续有效;后续 Excel 导入仍使用字符串日期并遵守普通预检、确认和关账治理。生产数据库执行结构迁移与业务修复仍需单独人工授权。 + +## 后果 + +- 月度和年度分析重新与权威工作簿逐月、整体对账。 +- 数据库保留可审计、可恢复的修复前后证据,而不是静默覆盖错误值。 +- 任何其他文件、数量不完整、金额漂移、已有关账或后续事件的数据库都会拒绝自动修复,必须另行设计受控更正方案。 diff --git a/docs/performance-import.md b/docs/performance-import.md index 74f6f95..4fe96f7 100644 --- a/docs/performance-import.md +++ b/docs/performance-import.md @@ -39,7 +39,9 @@ 已关闭期间的调整行必须提供与订单、事件类型、发生日期和月份完全匹配的一次性更正授权标识。确认事务会锁定授权,并仅在事件成功入账后消费;开放期间不得携带该授权。 -升级前已经存在、但没有受控批次或业务区域证据的 `legacy_adjustment`,必须重新上传原始文件核对。系统以既有来源键和行内容精确匹配,只新增不可变核对关联并按批准映射补齐订单业务区域,不修改旧事件。核对记录当前确认人;无法从来源证明的原始线下操作人明确保留为“未知”。重复核对同一事件时幂等跳过。 +升级前已经存在、但没有受控批次或业务区域证据的 `legacy_adjustment`,必须重新上传原始文件核对。系统以既有来源键和行内容精确匹配,只新增不可变核对关联并按批准映射补齐订单业务区域及客户单位投影,不修改旧事件。旧事件缺少稳定人员 ID 时,只允许 Excel 业务员姓名与事件姓名快照精确一致,不做模糊匹配。核对记录当前确认人;无法从来源证明的原始线下操作人明确保留为“未知”。重复核对同一事件时幂等跳过。 + +早期一次性迁移器曾把权威工作簿日期统一落成前一天,并用“历史明细迁移”代替源空备注。仅对该权威文件的固定 SHA-256 允许这两项兼容匹配,其他来源必须与已有事件日期和备注精确一致。迁移 `027_repair_legacy_import_dates.sql` 仅在已知来源哈希、4,701 条事件、2,850 笔订单、整体与逐月金额/数量、日期范围和旧默认备注数量全部吻合,且 4,701 行均有同一来源的不可变审核行与补齐收据、没有关账或后续业务事件时执行;它先保存不可变的事件/订单修复前后证据,再恢复来源日期、记账月、到样日期和空备注。任一基线不符整批回滚,修复后普通事件不可变约束继续生效。该窄例外不授权修改金额、人员、订单号、事件类型或其他历史数据,生产执行仍需单独批准。详见 ADR-0032。 只有销售助理组长可确认无阻断批次;警告必须逐项确认。确认在一个数据库事务中完成,任一冲突会整批回滚。人事部批准导入配置,不逐批审批业绩数据。