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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它
- 系统账号、首次改密、会话安全和角色权限矩阵。
- 部门、小组、人员身份和带有效期的组织任职。
- 订单台账与只追加、不覆盖的业绩事件链。
- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码。
- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定
- 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。
- 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。
- 人工录入与受控 `.xlsx` 导入;预检、逐月核对、确认、回滚和幂等证据分离。
Expand Down
11 changes: 6 additions & 5 deletions UX-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@

## Dataset navigation

- Admin tables: 使用服务端边界;订单页 P0 仅提供最大 100 条的服务端精确/模糊定位,完整分页属于 Issue #11。
- Exploratory lists: 当前无。
- URL state: 订单已提交搜索写入 `orderSearch` 查询参数;无路由库时使用 History API,并响应浏览器前进/后退。
- Page size: 订单 P0 临时上限 100;不得声称完整分页。
- Service-backed lists: 订单、账号、审计和分析穿透由服务端分页并返回精确 `totalCount`;其他已完整加载的数据集合复用 `PaginatedCollection` 客户端分页。
- Exploratory lists: 省份、总览指标等有限集合也复用统一分页控件,不另建局部分页交互。
- URL state: 页面、已提交筛选、页码、每页条数和服务端不透明快照令牌写入查询参数;History API 必须支持刷新及前进/后退恢复。
- Stable snapshot: 订单、审计和分析穿透的首次页码查询返回绑定用户、筛选和 cutoff 的快照;后续翻页复用,筛选、每页条数或主动刷新时清空并重新创建。旧游标 API 仅保留兼容。
- Page size: 默认 20 条,可选 10/20/50/100 条;显示精确总数、总页数、当前页、上一页/下一页和可点击页码。
- Empty/no-results/error/loading treatment: 分别说明“暂无数据”“没有匹配结果”“加载失败可刷新”;后台刷新保留既有结果。
- Back/scroll restoration: 搜索由 URL 恢复;页面保留自然文档滚动。
- Back/scroll restoration: 筛选、分页和快照由 URL 恢复;页面保留自然文档滚动。
- Selection scope: 当前不提供批量选择。

## Flow ledger
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
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)
);
43 changes: 32 additions & 11 deletions apps/api/src/audit-query.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,19 +221,19 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤",
[scenario.users.alice, scenario.orders[0]],
);
const inFlightId = inFlight.rows[0]!.id;
let firstPageResolved = false;
const firstPagePromise = app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test", headers: { cookie } })
.then((response) => { firstPageResolved = true; return response; });
await new Promise((resolve) => setTimeout(resolve, 50));
const resolvedBeforeCommit = firstPageResolved;
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)),
]);
await inFlightClient.query("commit");
await inFlightClient.end();
const firstPage = await firstPagePromise;
assert.equal(resolvedBeforeCommit, false, "首屏应等待已开始的审计写入提交后再冻结快照");
assert.ok(firstPageResult, "审计读取不得等待在途写入提交");
const firstPage=firstPageResult;
assert.equal(firstPage.statusCode, 200, firstPage.body);
const firstData = firstPage.json<{ audits: AuditRow[]; nextCursor: string | null }>();
assert.equal(firstData.audits.length, 50);
assert.ok(firstData.nextCursor);
assert.equal(firstData.audits.some((row) => row.id === inFlightId), false);
const concurrentClient = new Client({ connectionString: database.url });
await concurrentClient.connect();
let concurrentId: string;
Expand All @@ -246,23 +246,44 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤",
} finally {
await concurrentClient.end();
}
const numbered = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=6&pageSize=10", headers: { cookie } });
const numberedFirst = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=1&pageSize=10", headers: { cookie } });
assert.equal(numberedFirst.statusCode, 200, numberedFirst.body);
const numberedSnapshot=numberedFirst.json<{snapshot:string}>().snapshot;
assert.ok(numberedSnapshot);
const postSnapshotClient=new Client({connectionString:database.url});
await postSnapshotClient.connect();
let afterSnapshot:string;
try {
const inserted=await postSnapshotClient.query<{id:string}>(
"insert into audit_logs(actor_user_id,action,entity_type,entity_id) values($1,'performance.cursor_test','performance_order',$2) returning id::text",
[scenario.users.alice,scenario.orders[0]],
);
afterSnapshot=inserted.rows[0]!.id;
} finally {
await postSnapshotClient.end();
}
const numbered = await app.inject({ method: "GET", url: `/api/audits?action=performance.cursor_test&page=6&pageSize=10&snapshot=${encodeURIComponent(numberedSnapshot)}`, headers: { cookie } });
assert.equal(numbered.statusCode, 200, numbered.body);
assert.equal(numbered.json().page, 6);
assert.equal(numbered.json().pageSize, 10);
assert.equal(numbered.json().totalCount, 53);
assert.equal(numbered.json().audits.length, 3);
assert.equal(numbered.json<{audits:AuditRow[]}>().audits.some((row)=>row.id===afterSnapshot),false);
const outOfRange = await app.inject({ method:"GET",url:`/api/audits?action=performance.cursor_test&page=999&pageSize=10&snapshot=${encodeURIComponent(numberedSnapshot)}`,headers:{cookie} });
assert.equal(outOfRange.statusCode,200,outOfRange.body);
assert.equal(outOfRange.json().totalCount,53);
assert.deepEqual(outOfRange.json().audits,[]);
const mixedPagination = await app.inject({ method: "GET", url: `/api/audits?action=performance.cursor_test&page=1&cursor=${firstData.nextCursor}`, headers: { cookie } });
assert.equal(mixedPagination.statusCode, 400, mixedPagination.body);
const invalidPageSize = await app.inject({ method: "GET", url: "/api/audits?pageSize=15", headers: { cookie } });
assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body);
const cursorPage = await app.inject({ method: "GET", url: `/api/audits?action=performance.cursor_test&cursor=${firstData.nextCursor}`, headers: { cookie } });
assert.equal(cursorPage.statusCode, 200, cursorPage.body);
const secondRows = cursorPage.json<{ audits: AuditRow[] }>().audits;
assert.equal(secondRows.length, 2);
assert.equal(secondRows.length, 1);
const traversedIds = [...firstData.audits, ...secondRows].map((row) => row.id);
assert.equal(new Set(traversedIds).size, 52);
assert.equal(traversedIds.includes(inFlightId), true);
assert.equal(new Set(traversedIds).size, 51);
assert.equal(traversedIds.includes(inFlightId), false);
assert.equal(traversedIds.includes(concurrentId), false);

const mismatched = await app.inject({ method: "GET", url: `/api/audits?action=other.action&cursor=${firstData.nextCursor}`, headers: { cookie } });
Expand Down
43 changes: 27 additions & 16 deletions apps/api/src/authorization.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1223,22 +1223,17 @@ test("账号管理使用稳定搜索分页并审计固定角色组合变更", as
const adminHeaders = await loginWriteHeaders(app, "scope_admin");
const search = encodeURIComponent("分页账号");
const firstRequest = app.inject({ method: "GET", url: `/api/admin/users?search=${search}`, headers: adminHeaders });
const firstResult=await Promise.race([
firstRequest,
new Promise<null>((resolve)=>setTimeout(()=>resolve(null),1_000)),
]);
try {
for (let attempt = 0; attempt < 100; attempt += 1) {
const waiting = await setup.query<{ waiting: boolean }>(
`select exists(
select 1 from pg_locks locks join pg_class relation on relation.oid=locks.relation
where relation.relname='users' and locks.mode='ShareLock' and not locks.granted
) as waiting`,
);
if (waiting.rows[0]!.waiting) break;
if (attempt === 99) assert.fail("账号首屏必须等待在途账号写入提交后再冻结分页快照");
await new Promise((resolve) => setTimeout(resolve, 10));
}
} finally {
await writer.query("commit");
} finally {
if(!firstResult)await writer.query("rollback");
}
const first = await firstRequest;
assert.ok(firstResult,"账号读取不得等待在途写入提交");
const first=firstResult;
assert.equal(first.statusCode, 200, first.body);
assert.equal(first.json().users.length, 50);
assert.equal(first.json().pageSize, 50);
Expand Down Expand Up @@ -1269,11 +1264,11 @@ test("账号管理使用稳定搜索分页并审计固定角色组合变更", as
headers: adminHeaders,
});
assert.equal(second.statusCode, 200, second.body);
assert.equal(second.json().users.length, 11);
assert.equal(second.json().users.length, 10);
assert.equal(second.json().nextCursor, null);
const ids = [...first.json().users, ...second.json().users].map((user: { id: string }) => user.id);
assert.equal(new Set(ids).size, 61);
assert.equal(ids.includes(pending.rows[0]!.id), true);
assert.equal(new Set(ids).size, 60);
assert.equal(ids.includes(pending.rows[0]!.id), false);
assert.equal(ids.includes(late.rows[0]!.id), false);

const mismatchedCursor = await app.inject({
Expand Down Expand Up @@ -1450,12 +1445,25 @@ test("订单台账用固定快照稳定遍历并保持有界查询次数", async
assert.equal(numbered.json().pageSize, 10);
assert.equal(numbered.json().totalCount, 101);
assert.equal(numbered.json().orders.length, 10);
const numberedSnapshot=numbered.json<{snapshot:string}>().snapshot;
assert.ok(numberedSnapshot);
const mixedPagination = await app.inject({ method: "GET", url: `/api/performance/orders?search=CURSOR-FIX-&page=1&cursor=${encodeURIComponent(first.body.nextCursor!)}`, headers: { cookie: leaderCookie } });
assert.equal(mixedPagination.statusCode, 400, mixedPagination.body);
const invalidPageSize = await app.inject({ method: "GET", url: "/api/performance/orders?pageSize=15", headers: { cookie: leaderCookie } });
assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body);

const [newOrderId] = await insertRows("CURSOR-FIX-NEW-", 1);
const numberedLast=await app.inject({method:"GET",url:`/api/performance/orders?search=CURSOR-FIX-&page=11&pageSize=10&snapshot=${encodeURIComponent(numberedSnapshot)}`,headers:{cookie:leaderCookie}});
assert.equal(numberedLast.statusCode,200,numberedLast.body);
assert.equal(numberedLast.json().totalCount,101);
assert.equal(numberedLast.json().orders.length,1);
assert.equal(numberedLast.json<{orders:Array<{id:string}>}>().orders.some((order)=>order.id===newOrderId),false);
const numberedOutOfRange=await app.inject({method:"GET",url:`/api/performance/orders?search=CURSOR-FIX-&page=999&pageSize=10&snapshot=${encodeURIComponent(numberedSnapshot)}`,headers:{cookie:leaderCookie}});
assert.equal(numberedOutOfRange.statusCode,200,numberedOutOfRange.body);
assert.equal(numberedOutOfRange.json().totalCount,101);
assert.deepEqual(numberedOutOfRange.json().orders,[]);
const numberedMismatch=await app.inject({method:"GET",url:`/api/performance/orders?search=OTHER&page=1&pageSize=10&snapshot=${encodeURIComponent(numberedSnapshot)}`,headers:{cookie:leaderCookie}});
assert.equal(numberedMismatch.statusCode,400,numberedMismatch.body);
const pages = [first.body];
let nextCursor: string | null = first.body.nextCursor;
while (nextCursor) {
Expand Down Expand Up @@ -1506,6 +1514,8 @@ test("订单台账用固定快照稳定遍历并保持有界查询次数", async
headers: { cookie: bobCookie },
});
assert.equal(otherUser.statusCode, 400, otherUser.body);
const otherSnapshotUser=await app.inject({method:"GET",url:`/api/performance/orders?search=CURSOR-FIX-&page=1&pageSize=10&snapshot=${encodeURIComponent(numberedSnapshot)}`,headers:{cookie:bobCookie}});
assert.equal(otherSnapshotUser.statusCode,400,otherSnapshotUser.body);
const oversizedPayload = JSON.parse(Buffer.from(first.body.nextCursor!, "base64url").toString("utf8")) as Record<string, unknown>;
oversizedPayload.anchorId = "9223372036854775808";
const oversizedCursor = Buffer.from(JSON.stringify(oversizedPayload), "utf8").toString("base64url");
Expand Down Expand Up @@ -1542,6 +1552,7 @@ test("订单组合筛选始终叠加服务端权限范围", async () => {
customer_unit=case qingflow_order_no when 'SCOPE-1' then '甲客户单位' when 'SCOPE-2' then '乙客户单位' else '丙客户单位' end,
business_region_code=case qingflow_order_no when 'SCOPE-1' then 'CN-JS' when 'SCOPE-2' then 'CN-ZJ' else 'EXT-TRADE' end,
source_received_on=case qingflow_order_no when 'SCOPE-2' then '2026-09-01'::date else '2026-08-01'::date end,
counted_amount=case qingflow_order_no when 'SCOPE-2' then 0 else counted_amount end,
lifecycle_state=case qingflow_order_no when 'SCOPE-2' then 'paused' else 'active' end
where id=any($1::bigint[])`,
[scenario.orderIds],
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\|24\|[a-f0-9]{32}$/m);
assert.match(sourceSummary.stdout, /^schema_migrations\|25\|[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
7 changes: 7 additions & 0 deletions apps/api/src/domain/performance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ test("零金额订单通过首次计入事件转为正向计入", () => {
assert.equal(result.deltaAmount, 88);
assert.equal(result.next.lifecycle, "active");
});

test("首次计入金额舍入为零时拒绝改变账本状态", () => {
assert.throws(
() => decidePerformanceEvent({ currentRevenue: 0, countedAmount: 0, lifecycle: "zero" }, { type: "first_include", amount: 0.001 }),
PerformanceRuleError,
);
});
3 changes: 1 addition & 2 deletions apps/api/src/domain/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ export function decidePerformanceEvent(state: PerformanceState, command: Perform
return { eventType: "restart", deltaAmount: money(state.currentRevenue), next: { ...state, countedAmount: state.currentRevenue, lifecycle: state.currentRevenue > 0 ? "active" : "zero" } };
}
if (state.lifecycle !== "zero" || state.currentRevenue !== 0 || state.countedAmount !== 0) throw new PerformanceRuleError("只有零金额订单可以首次计入");
if (command.amount <= 0) throw new PerformanceRuleError("首次计入金额必须大于零");
const amount = money(command.amount);
if (amount <= 0) throw new PerformanceRuleError("首次计入金额必须大于零");
return { eventType: "first_include", deltaAmount: amount, next: { currentRevenue: amount, countedAmount: amount, lifecycle: "active" } };
}

4 changes: 2 additions & 2 deletions apps/api/src/modules/accounting-periods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from "zod";
import type { Database } from "../db.js";
import { standardBusinessRegionName } from "../domain/business-regions.js";
import { businessDate } from "../domain/business-time.js";
import { postgresBigintIdSchema } from "../validation.js";
import { nonnegativeMoneySchema, postgresBigintIdSchema } from "../validation.js";
import { hasAnyRole, type CurrentUser } from "./auth.js";
import { recordEventAnalysisDimensions } from "./event-analysis-dimensions.js";
import { OrganizationResolutionError, resolveOrganization } from "./organization.js";
Expand All @@ -30,7 +30,7 @@ const correctionListSchema = z.object({
const reviewSchema = z.strictObject({
orderId: postgresBigintIdSchema,
lifecycleState: z.enum(["active", "paused", "zero"]),
currentRevenue: z.number().finite().min(0).max(99_999_999_999.99),
currentRevenue: nonnegativeMoneySchema,
conclusion: z.string().trim().min(1).max(500),
evidence: z.string().trim().min(1).max(1000),
reason: z.string().trim().min(1).max(500),
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/modules/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export async function registerAdmin(app:FastifyInstance,db:Database){
await client.query("commit");
return{users:result.rows[0]!.users,roles:fixedRoles,permissionMatrix:ROLE_PERMISSION_MATRIX,page,pageSize,totalCount:Number(result.rows[0]!.totalCount)};
}
if(!cursor)await client.query("lock table users in share mode");
const result=await client.query<{cutoffId:string|null;users:Array<{id:string;username:string;displayName:string;isActive:boolean;mustChangePassword:boolean;roles:string[]}>}>(
`with cutoff as (select coalesce($3::bigint,max(id)) as id from users), page as (
select u.id as "__id",u.id::text,u.username,u.display_name as "displayName",u.is_active as "isActive",u.must_change_password as "mustChangePassword",
Expand Down
Loading