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
11 changes: 10 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,21 @@ _Avoid_: 自动暂停订单、异常即删除
_Avoid_: 原始金额、业绩净额

**正向计入订单**:
当前计入业绩金额大于 0 的订单。只有正向计入订单允许修改营业额;暂停订单或当前计入业绩金额不大于 0 的订单不能修改营业额
当前计入业绩金额大于 0 的订单。正向计入和应收未收订单允许修改营业额;暂停或零金额订单不能修改营业额
_Avoid_: 数据为正、正常数据

**零金额订单**:
订单当前营业额为 0 的业绩主记录。零金额订单允许录入和查询,但不计入业绩总额,也不具备营业额修改资格。
_Avoid_: 无效订单、赠送订单

**应收未收订单**:
首次录入的系统营业额为负数、用负值表示尚未收回金额的业绩主记录。负数按原值进入账本并单独标识,不解释为退款、暂停或导入错误;应收状态解除时通过不可变营业额调整事件记录。
_Avoid_: 负数错误、退款订单、历史待核订单

**协作业绩分配**:
一笔订单事件在主业务员与一名协作人之间按固定比例拆分的个人及组织业绩归属。协作人份额按分舍入,主业务员取得剩余金额,两者合计始终等于公司事件金额;后续调整沿用订单协作比例并按事件发生时组织关系分别固化。
_Avoid_: 重复计算、额外奖励、公司营业额加成

**首次计入事件**:
零金额订单首次获得正营业额时,在同一业绩主记录下新增的正向事件。该事件以当时的完整正营业额计入操作发生月,使订单转为正向计入订单。
_Avoid_: 零金额修改、新建订单
Expand Down Expand Up @@ -177,6 +185,7 @@ _Avoid_: 数字订单号、自动清洗编号、人工合并
**Excel 批量导入**:
业绩数据维护角色将轻流或未来线下工作簿中的多行订单和历史调整受控转录到 SampleFlow 的入口。它与人工录入使用相同的服务端校验、权限、订单唯一性、组织解析和确认入账规则。v1 仅接收未加密 `.xlsx` 固定值,不执行公式、宏或外部连接;账号、权限、目标、目标确认和审批结果不得导入。事件必须携带权威来源发生日期,系统自动推导记账月;已关闭期间须先取得相应重开授权。
标准模板仅用于首次入账;其他获批格式可导入营业额修改、暂停、重启和首次计入。其金额含义与人工操作一致:营业额修改填写调整后的新营业额,首次计入填写计入金额,暂停和重启填写 0;同一订单同一天多条事件必须提供从 1 开始连续且唯一的业务顺序。
标准模板采用“分子”工作表字段:收样月份、日期、订单编号、客户姓名、客户单位、省份、业务员、部门、组别、系统营业额、服务类型、备注、协作人和协作比例;中间空白列只为兼容原工作表而忽略。标准批次每个订单编号只能有一行首次入账,订单编号同时作为稳定来源标识;系统营业额允许负数并表示应收未收。收样月份必须与日期一致,部门和组别必须与业务员在该日期的有效组织关系一致。
专用历史配置必须在人事批准前固化整体和逐月的来源行数、订单数、事件数及金额对账基准;预检实际值不一致时整批阻断,不能仅凭整体合计放行缺月或截断文件。
_Avoid_: Excel 覆盖更新、直接灌库

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它

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

## 项目状态
Expand Down Expand Up @@ -67,7 +67,9 @@ npm.cmd run verify

## Excel 导入与数据边界

在“订单业绩 → Excel 导入”下载标准模板。上传后先预检,只有获授权角色显式确认才写入正式账本。专用历史导入还要求来源哈希、逐月基线、组织映射和重复检测。
在“订单业绩 → Excel 导入”下载标准模板。标准模板只读取名为“分子”的工作表,表头与原始 Sheet3 业务字段一致;一行代表一笔新订单,订单编号同时作为稳定来源标识。上传后先预检,只有获授权角色显式确认才写入正式账本。专用历史导入仍使用独立配置,并要求来源哈希、逐月基线、组织映射和重复检测。

`系统营业额` 允许为负,负数表示应收未收。填写协作人时必须同时填写 0—1 之间的协作比例;个人、组别和部门业绩按比例拆分,公司总额不重复增加。完整规则见 [`docs/performance-import.md`](docs/performance-import.md)。

不要提交、上传到 Issue/PR 或写入日志:

Expand Down
100 changes: 100 additions & 0 deletions apps/api/migrations/026_sheet3_order_input.sql
Original file line number Diff line number Diff line change
@@ -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()
);
4 changes: 2 additions & 2 deletions apps/api/src/authorization.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
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\|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);
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/domain/business-regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
30 changes: 29 additions & 1 deletion apps/api/src/domain/performance-import-xlsx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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 销售组");
});
Loading