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
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ components:

### Navigation and data display

侧栏保持现有深蓝语汇。台账使用原生表格、明确列名和横向滚动;订单编号是识别列。事件详情按服务端稳定序号展示发生日、操作时刻、差额、投影状态、原因、操作者和组织快照;历史原始行明确标注状态未推断。记账治理工作台以核对、关账、更正和历史审查三类并列区域呈现,不把职责分离隐藏在通用操作中
侧栏保持现有深蓝语汇。订单台账优先展示订单、客户、区域、负责人、金额与状态,次要订单资料进入“查看 / 调整”详情;订单编号保持识别列,窄屏仍允许横向滚动。事件详情按服务端稳定序号展示发生日、操作时刻、差额、投影状态、原因、操作者和组织快照;历史原始行明确标注状态未推断。记账治理工作台以独立摘要区隔核对、关账、更正和历史审查,并按任务展开表单,不改变职责分离

### Forms and overlays

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它
- 系统账号、首次改密、会话安全和角色权限矩阵。
- 部门、小组、人员身份和带有效期的组织任职。
- 以“分子”Sheet3 字段为准的订单台账与只追加、不覆盖的业绩事件链;关键识别/操作列固定,中间字段可横向滚动;负数系统营业额明确标记为“应收未收”。
- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。
- 所有数据表格和可增长业务清单统一分页:默认 10 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。
- 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。
- 省份与客户单位分析支持按月、按年汇总和穿透,未取得来源证据的维度明确显示为待补齐。
- 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。
Expand Down
7 changes: 4 additions & 3 deletions UX-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
- Exploratory lists: 省份、总览指标等有限集合也复用统一分页控件,不另建局部分页交互。
- URL state: 页面、已提交筛选、页码、每页条数和服务端不透明快照令牌写入查询参数;History API 必须支持刷新及前进/后退恢复。
- Stable snapshot: 订单、审计和分析穿透的首次页码查询返回绑定用户、筛选和 cutoff 的快照;后续翻页复用,筛选、每页条数或主动刷新时清空并重新创建。旧游标 API 仅保留兼容。
- Page size: 默认 20 条,可选 10/20/50/100 条;显示精确总数、总页数、当前页、上一页/下一页和可点击页码。
- Page size: 默认 10 条,可选 10/20/50/100 条;显示精确总数、总页数、当前页、上一页/下一页和可点击页码。
- Empty/no-results/error/loading treatment: 分别说明“暂无数据”“没有匹配结果”“加载失败可刷新”;后台刷新保留既有结果。
- Back/scroll restoration: 筛选、分页和快照由 URL 恢复;页面保留自然文档滚动。
- Selection scope: 当前不提供批量选择。
Expand All @@ -84,9 +84,10 @@
- Route document title policy: 八个主要页面使用稳定 URL,并采用“页面 — SampleFlow”。
- Route error / 403 page behavior: API 403 显示明确权限边界,不把无权限伪装为空数据。
- Breadcrumb/tab/route-state policy: 当前侧栏驱动稳定 URL;筛选、分页和穿透状态按页面合同写入查询参数。
- Analysis drilldown overlay: 省份列表和地图以同一共享 `Modal` 打开省份客户明细;“查看月份”和订单事件在该弹窗内继续钻取,不嵌套第二个弹窗;关闭恢复原省份触发点,刷新及前进/后退仍由 URL 恢复。
- Sidebar/drawer/bottom-sheet transformation: 当前产品只验收桌面 Web;不把移动端抽屉或底部操作区作为发布门禁。
- Responsive table strategy: 保留原生表格和横向滚动,不静默丢列
- Truncation/full-value access: 关键订单号、原因和事件值允许换行或进入详情查看。
- Responsive table strategy: 保留原生表格和横向滚动;订单台账只在主表展示高频字段,完整订单资料由同一行的“查看 / 调整”详情持续可达
- Truncation/full-value access: 关键订单号、原因和事件值允许换行或进入详情查看;从订单台账收纳的服务类型、备注、协作分配和责任快照必须在详情中完整呈现
- Focus restoration and sticky-obstruction policy: 模态关闭回到触发点;焦点不得被侧栏或模态标题遮挡。

## Overlays and feedback
Expand Down
13 changes: 13 additions & 0 deletions apps/api/migrations/028_allow_receivable_historical_reviews.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
alter table historical_order_reviews
drop constraint historical_order_reviews_proposed_lifecycle_state_check,
drop constraint historical_order_reviews_proposed_current_revenue_check,
drop constraint historical_review_state_revenue;

alter table historical_order_reviews
add constraint historical_review_lifecycle_state_check
check (proposed_lifecycle_state in ('active','paused','zero','receivable_pending')),
add constraint historical_review_state_revenue check (
(proposed_lifecycle_state='zero' and proposed_current_revenue=0)
or (proposed_lifecycle_state in ('active','paused') and proposed_current_revenue>0)
or (proposed_lifecycle_state='receivable_pending' and proposed_current_revenue<0)
);
5 changes: 3 additions & 2 deletions apps/api/src/audit-query.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤",
const inFlightId = inFlight.rows[0]!.id;
const firstPageResult = await Promise.race([
app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test", headers: { cookie } }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1_000)),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 10_000)),
]);
await inFlightClient.query("commit");
await inFlightClient.end();
Expand All @@ -246,8 +246,9 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤",
} finally {
await concurrentClient.end();
}
const numberedFirst = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=1&pageSize=10", headers: { cookie } });
const numberedFirst = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=1", headers: { cookie } });
assert.equal(numberedFirst.statusCode, 200, numberedFirst.body);
assert.equal(numberedFirst.json().pageSize,10);
const numberedSnapshot=numberedFirst.json<{snapshot:string}>().snapshot;
assert.ok(numberedSnapshot);
const postSnapshotClient=new Client({connectionString:database.url});
Expand Down
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 @@ -1259,7 +1259,7 @@ test("账号管理使用稳定搜索分页并审计固定角色组合变更", as
);
await setup.query("insert into user_roles(user_id,role_code,assigned_by) values($1,'salesperson',$2)", [late.rows[0]!.id, scenario.users.admin]);

const numbered = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&page=2&pageSize=10`, headers: adminHeaders });
const numbered = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&page=2`, headers: adminHeaders });
assert.equal(numbered.statusCode, 200, numbered.body);
assert.equal(numbered.json().page, 2);
assert.equal(numbered.json().pageSize, 10);
Expand Down Expand Up @@ -1451,7 +1451,7 @@ test("订单台账用固定快照稳定遍历并保持有界查询次数", async
assert.equal(first.body.previousCursor, null);
assert.ok(first.body.nextCursor);

const numbered = await app.inject({ method: "GET", url: "/api/performance/orders?search=CURSOR-FIX-&page=2&pageSize=10", headers: { cookie: leaderCookie } });
const numbered = await app.inject({ method: "GET", url: "/api/performance/orders?search=CURSOR-FIX-&page=2", headers: { cookie: leaderCookie } });
assert.equal(numbered.statusCode, 200, numbered.body);
assert.equal(numbered.json().page, 2);
assert.equal(numbered.json().pageSize, 10);
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\|27\|[a-f0-9]{32}$/m);
assert.match(sourceSummary.stdout, /^schema_migrations\|28\|[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
111 changes: 111 additions & 0 deletions apps/api/src/ledger-governance.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,55 @@ test("更正申请被拒绝、撤销或批准超过二十四小时后均不能
});
});

test("记账治理列表默认每页十条且后续页可访问",async()=>{
await withMigratedTestDatabase(async(database)=>{
const scenario=await seedLedgerScenario(database.url);
const client=new Client({connectionString:database.url});await client.connect();
try{
const requester=await client.query<{user_id:string}>("select user_id::text from people where id=$1",[scenario.assistantLeaderPersonId]);
const order=await client.query<{id:string}>(
`insert into performance_orders
(qingflow_order_no,customer_name,customer_unit,salesperson_person_id,salesperson_name,source_received_on,
original_amount,current_revenue,counted_amount,lifecycle_state,posted_at)
values('GOVERNANCE-PAGE','分页客户','分页单位',$1,'账本业务员','2025-01-01',1,1,1,'active',now()) returning id::text`,
[scenario.memberPersonId],
);
await client.query("insert into accounting_periods(period_month) select month::date from generate_series('2025-01-01'::date,'2025-12-01'::date,'1 month') month");
await client.query(
`insert into accounting_correction_requests
(period_month,order_id,event_type,occurred_on,reason,business_region_code,business_region_source_text,
customer_unit,analysis_dimension_evidence,requested_by_user_id,requested_by_person_id,requested_at,status)
select '2025-01-01',$1,'revenue_change','2025-01-15','分页更正 '||value,'CN-AH','安徽分页凭证',
'分页单位','分页证据',$2,$3,now()-value*interval '1 minute','rejected'
from generate_series(1,12) as series(value)`,
[order.rows[0]!.id,requester.rows[0]!.user_id,scenario.assistantLeaderPersonId],
);
await client.query(
`insert into historical_order_reviews
(order_id,proposed_lifecycle_state,proposed_current_revenue,conclusion,evidence,reason,
requested_by_user_id,requested_by_person_id,requested_at,status)
select $1,'active',1,'分页核对 '||value,'分页证据','分页原因',$2,$3,now()-value*interval '1 minute','rejected'
from generate_series(1,12) as series(value)`,
[order.rows[0]!.id,requester.rows[0]!.user_id,scenario.assistantLeaderPersonId],
);
}finally{await client.end();}

await withTestApi(database.url,async(app)=>{
const headers=await writeHeaders(app,"ledger_assistant_leader");
for(const [path,key] of [["/api/accounting-periods","periods"],["/api/accounting-corrections","corrections"],["/api/historical-order-reviews","reviews"]] as const){
const first=await app.inject({method:"GET",url:path,headers:{cookie:headers.cookie}});
assert.equal(first.statusCode,200,first.body);
assert.equal(first.json().page,1);assert.equal(first.json().pageSize,10);assert.equal(first.json().totalCount,12);assert.equal(first.json()[key].length,10);
const second=await app.inject({method:"GET",url:`${path}?page=2&pageSize=10`,headers:{cookie:headers.cookie}});
assert.equal(second.statusCode,200,second.body);
assert.equal(second.json().page,2);assert.equal(second.json()[key].length,2);
const invalid=await app.inject({method:"GET",url:`${path}?pageSize=15`,headers:{cookie:headers.cookie}});
assert.equal(invalid.statusCode,400,invalid.body);
}
});
});
});

test("历史待核订单保留原始事件语义并经组长核对和人事批准后解锁",async()=>{
await withMigratedTestDatabase(async(database)=>{
const scenario=await seedLedgerScenario(database.url);
Expand Down Expand Up @@ -452,3 +501,65 @@ test("历史待核订单保留原始事件语义并经组长核对和人事批
},{clock:()=>now});
});
});

test("协作历史订单可核对为负数应收未收且保留双方归属",async()=>{
await withMigratedTestDatabase(async(database)=>{
const scenario=await seedLedgerScenario(database.url);
const client=new Client({connectionString:database.url});await client.connect();
let orderId="";
try{
const order=await client.query<{id:string}>(
`insert into performance_orders
(qingflow_order_no,customer_name,customer_unit,business_region_source_text,business_region_code,
salesperson_person_id,salesperson_name,collaborator_person_id,collaborator_name,collaboration_ratio,
source_received_on,original_amount,current_revenue,counted_amount,lifecycle_state,posted_at)
values('LEGACY-RECEIVABLE-COLLAB','历史协作客户','协作单位','安徽原文','CN-AH',
$1,'账本业务员',$2,'账本协作人',0.25,'2026-08-01',-50,-50,-50,'historical_review_required',now())
returning id::text`,[scenario.memberPersonId,scenario.collaboratorPersonId],
);
orderId=order.rows[0]!.id;
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,leader_name,supervisor_name,source_row_number,
salesperson_person_id,department_unit_id,group_unit_id,leader_person_id,supervisor_person_id,order_sequence)
values($1,'legacy_adjustment',-50,-50,-50,'2026-08-01','2026-08-01','原始负向行',
'账本业务员','账本部门','账本小组','账本组长','账本主管',91001,$2,$3,$4,$5,$6,1)`,
[orderId,scenario.memberPersonId,scenario.departmentId,scenario.groupId,scenario.leaderPersonId,scenario.supervisorPersonId],
);
}finally{await client.end();}

await withTestApi(database.url,async(app)=>{
const leader=await writeHeaders(app,"ledger_assistant_leader");
const hr=await writeHeaders(app,"ledger_hr");
const requested=await app.inject({
method:"POST",url:"/api/historical-order-reviews",headers:leader,
payload:{orderId,lifecycleState:"receivable_pending",currentRevenue:-80,conclusion:"核对为应收未收",evidence:"原始订单及应收凭证",reason:"确认仍有八十元未收"},
});
assert.equal(requested.statusCode,201,requested.body);
const reviewId=requested.json().id as string;
const approved=await app.inject({method:"POST",url:`/api/historical-order-reviews/${reviewId}/approve`,headers:hr,payload:{note:"证据完整,同意解析"}});
assert.equal(approved.statusCode,200,approved.body);

const after=await app.inject({method:"GET",url:`/api/performance/orders/${orderId}/events`,headers:{cookie:leader.cookie}});
assert.equal(after.statusCode,200,after.body);
assert.equal(after.json().lifecycleState,"receivable_pending");
assert.deepEqual(after.json().allowedActions,["revenue_change"]);
const resolution=after.json().events.at(-1);
assert.equal(resolution.deltaAmount,"-30.00");
assert.equal(resolution.collaboratorName,"账本协作人");
assert.equal(resolution.collaborationRatio,"0.250000");

const verify=new Client({connectionString:database.url});await verify.connect();
try{
const order=await verify.query<{current_revenue:string;counted_amount:string;lifecycle_state:string}>("select current_revenue::text,counted_amount::text,lifecycle_state from performance_orders where id=$1",[orderId]);
assert.deepEqual(order.rows[0],{current_revenue:"-80.00",counted_amount:"-80.00",lifecycle_state:"receivable_pending"});
const attributions=await verify.query<{attribution_role:string;amount:string}>(
`select attribution_role,attributed_amount::text as amount from performance_event_attributions attribution
join historical_order_reviews review on review.resolution_event_id=attribution.event_id where review.id=$1 order by attribution_role`,[reviewId],
);
assert.deepEqual(attributions.rows,[{attribution_role:"collaborator",amount:"-7.50"},{attribution_role:"primary",amount:"-22.50"}]);
}finally{await verify.end();}
},{clock:()=>new Date("2026-09-12T02:00:00.000Z")});
});
});
Loading