Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ _Avoid_: 自动识别的暂停、推断事件类型
对升级前已经存在、但缺少受控批次和业务区域证据的历史调整事件,通过重新上传原始文件建立的不可变核对关系。核对保存新批次、原始文件、配置版本、来源行和本次确认人,按已批准映射补齐订单业务区域;既有事件金额、语义、来源键和批次字段保持不变。原始线下录入人无法证明时明确记为未知,本次确认人不得冒充原始操作人。
_Avoid_: 改写旧事件、猜测原始操作人、合成不存在的来源文件

**旧迁移日期修复**:
针对已证实由早期迁移器时区序列化造成、且仅命中特定权威来源哈希的统一早一天缺陷,在逐项核对来源位置、事件事实、整体/逐月基线并确认没有关账或后续业务事件后,通过显式数据库迁移一次性恢复来源日期、记账月、订单到样日期和源空备注。修复前后值必须进入独立不可变证据表,任一基线不符整批回滚;这只是修正转录错误,不是修改业务事实或一般历史更正入口。
_Avoid_: 普通账本改写、模糊来源匹配、无备份修复、生产自动修复

**历史待核订单**:
历史导入后无法仅凭原始流水可靠确定后续可操作状态的订单。历史净额为正的订单可标记为 `active`,仅有一条零金额行的订单可标记为 `zero`;多行相抵为零或净额为负的订单必须保持待核并锁定后续业务操作。其原始事件和金额继续进入历史账本与来源对账,销售助理组长记录核对结论、依据和原因,经人事部批准后才可解除限制;核对不得覆盖或重新解释历史事件和金额。
_Avoid_: 自动暂停订单、异常即删除
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它

- 系统账号、首次改密、会话安全和角色权限矩阵。
- 部门、小组、人员身份和带有效期的组织任职。
- 以“分子”Sheet3 字段为准的订单台账与只追加、不覆盖的业绩事件链;负数系统营业额明确标记为“应收未收”。
- 以“分子”Sheet3 字段为准的订单台账与只追加、不覆盖的业绩事件链;关键识别/操作列固定,中间字段可横向滚动;负数系统营业额明确标记为“应收未收”。
- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。
- 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。
- 省份与客户单位分析支持按月、按年汇总和穿透,未取得来源证据的维度明确显示为待补齐。
- 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。
- 人工录入与受控 `.xlsx` 导入;标准模板覆盖日期、客户单位、省份、组织、服务类型、备注及协作分配,预检、确认、回滚和幂等证据分离。
- PostgreSQL、React、Fastify 和 Docker Compose 组成的模块化单体。
Expand All @@ -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) 为准。

Expand Down
166 changes: 166 additions & 0 deletions apps/api/migrations/027_repair_legacy_import_dates.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion apps/api/src/backup-restore.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
34 changes: 27 additions & 7 deletions apps/api/src/import-job.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ test("重新上传原始历史文件时关联既有事件并补齐区域但不
});
});

test("历史分析维度补齐只写快照且预检、重放和冲突保持安全", async () => {
test("历史分析维度补齐写入事件快照和订单当前投影且预检、重放和冲突保持安全", async () => {
await withMigratedTestDatabase(async (database) => {
const context = await fixture(database.url);
try {
Expand All @@ -551,17 +551,34 @@ 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)
values($1,$2,'分子',2,$3)`,
[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],
Expand All @@ -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
Expand All @@ -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]),
Expand Down
Loading