From e2a8750209195dd06be11ddaf531e4c66c2d83d0 Mon Sep 17 00:00:00 2001 From: Eclipseic1848 <237380389+Eclipseic1848@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:18:04 -0700 Subject: [PATCH] fix: resolve P1 audit findings --- README.md | 2 +- UX-CONTRACT.md | 11 +- ...nforce_performance_order_state_amounts.sql | 7 + apps/api/src/audit-query.integration.test.ts | 43 ++++-- .../api/src/authorization.integration.test.ts | 43 +++--- .../src/backup-restore.integration.test.ts | 2 +- apps/api/src/domain/performance.test.ts | 7 + apps/api/src/domain/performance.ts | 3 +- apps/api/src/modules/accounting-periods.ts | 4 +- apps/api/src/modules/admin.ts | 1 - apps/api/src/modules/audits.ts | 78 +++++++---- apps/api/src/modules/goals.ts | 10 +- apps/api/src/modules/performance.ts | 124 ++++++++++++++---- .../organization-import.integration.test.ts | 3 +- .../performance-analysis.integration.test.ts | 57 +++++--- apps/api/src/services/import-job.ts | 6 +- .../api/src/test-database.integration.test.ts | 8 ++ apps/api/src/validation.test.ts | 11 ++ apps/api/src/validation.ts | 7 + apps/web/e2e/analysis.spec.ts | 6 +- apps/web/e2e/audits.spec.ts | 2 + apps/web/e2e/login.spec.ts | 2 + apps/web/e2e/onboarding.spec.ts | 28 ++++ apps/web/e2e/routes.spec.ts | 2 +- apps/web/src/app-types.ts | 4 +- apps/web/src/onboarding.tsx | 3 +- apps/web/src/pages/analysis-page.tsx | 34 ++--- apps/web/src/pages/audit-page.tsx | 20 +-- apps/web/src/pages/orders-page.tsx | 23 ++-- apps/web/src/pages/overview-page.tsx | 2 +- docs/specs/p1-product-closure.md | 2 +- handoff.md | 37 +++++- 32 files changed, 418 insertions(+), 174 deletions(-) create mode 100644 apps/api/migrations/025_enforce_performance_order_state_amounts.sql create mode 100644 apps/api/src/validation.test.ts diff --git a/README.md b/README.md index 84a9e0a..ee5357f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它 - 系统账号、首次改密、会话安全和角色权限矩阵。 - 部门、小组、人员身份和带有效期的组织任职。 - 订单台账与只追加、不覆盖的业绩事件链。 -- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码。 +- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码;订单、审计和分析穿透使用 URL 快照保持跨页结果稳定。 - 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。 - 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。 - 人工录入与受控 `.xlsx` 导入;预检、逐月核对、确认、回滚和幂等证据分离。 diff --git a/UX-CONTRACT.md b/UX-CONTRACT.md index 5c8f4be..355ac53 100644 --- a/UX-CONTRACT.md +++ b/UX-CONTRACT.md @@ -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 diff --git a/apps/api/migrations/025_enforce_performance_order_state_amounts.sql b/apps/api/migrations/025_enforce_performance_order_state_amounts.sql new file mode 100644 index 0000000..98f66ce --- /dev/null +++ b/apps/api/migrations/025_enforce_performance_order_state_amounts.sql @@ -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) +); diff --git a/apps/api/src/audit-query.integration.test.ts b/apps/api/src/audit-query.integration.test.ts index ed768a4..cd52eca 100644 --- a/apps/api/src/audit-query.integration.test.ts +++ b/apps/api/src/audit-query.integration.test.ts @@ -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((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; @@ -246,12 +246,33 @@ 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 } }); @@ -259,10 +280,10 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤", 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 } }); diff --git a/apps/api/src/authorization.integration.test.ts b/apps/api/src/authorization.integration.test.ts index cf0b30f..3fb1660 100644 --- a/apps/api/src/authorization.integration.test.ts +++ b/apps/api/src/authorization.integration.test.ts @@ -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((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); @@ -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({ @@ -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) { @@ -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; oversizedPayload.anchorId = "9223372036854775808"; const oversizedCursor = Buffer.from(JSON.stringify(oversizedPayload), "utf8").toString("base64url"); @@ -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], diff --git a/apps/api/src/backup-restore.integration.test.ts b/apps/api/src/backup-restore.integration.test.ts index c4e2aaa..58fe716 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\|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); diff --git a/apps/api/src/domain/performance.test.ts b/apps/api/src/domain/performance.test.ts index 10335d4..0044199 100644 --- a/apps/api/src/domain/performance.test.ts +++ b/apps/api/src/domain/performance.test.ts @@ -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, + ); +}); diff --git a/apps/api/src/domain/performance.ts b/apps/api/src/domain/performance.ts index 0f321be..bd42589 100644 --- a/apps/api/src/domain/performance.ts +++ b/apps/api/src/domain/performance.ts @@ -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" } }; } - diff --git a/apps/api/src/modules/accounting-periods.ts b/apps/api/src/modules/accounting-periods.ts index 5bb61a2..b96e6d6 100644 --- a/apps/api/src/modules/accounting-periods.ts +++ b/apps/api/src/modules/accounting-periods.ts @@ -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"; @@ -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), diff --git a/apps/api/src/modules/admin.ts b/apps/api/src/modules/admin.ts index 479cf9a..4c219ef 100644 --- a/apps/api/src/modules/admin.ts +++ b/apps/api/src/modules/admin.ts @@ -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", diff --git a/apps/api/src/modules/audits.ts b/apps/api/src/modules/audits.ts index b69e814..4bbefb7 100644 --- a/apps/api/src/modules/audits.ts +++ b/apps/api/src/modules/audits.ts @@ -12,7 +12,7 @@ const auditFiltersSchema = z.strictObject({ from: z.iso.datetime({ offset: true }).optional(), to: z.iso.datetime({ offset: true }).optional(), }); -const querySchema = auditFiltersSchema.extend({ cursor: z.string().max(2048).optional(), page:pageNumberSchema.optional(), pageSize:pageSizeSchema.optional() }); +const querySchema = auditFiltersSchema.extend({ cursor: z.string().max(2048).optional(), snapshot:z.string().max(2048).optional(), page:pageNumberSchema.optional(), pageSize:pageSizeSchema.optional() }); const auditCursorSchema = z.strictObject({ version: z.literal(1), userId: postgresBigintIdSchema, @@ -20,6 +20,12 @@ const auditCursorSchema = z.strictObject({ id: postgresBigintIdSchema, cutoffId: postgresBigintIdSchema, }); +const auditSnapshotSchema = z.strictObject({ + version:z.literal(1), + userId:postgresBigintIdSchema, + filters:auditFiltersSchema, + cutoffId:z.union([z.literal("0"),postgresBigintIdSchema]), +}); const PAGE_SIZE = 50; const SENSITIVE_FIELD = /password|token|secret|credential|authorization|cookie|session/i; @@ -36,6 +42,19 @@ function decodeAuditCursor(value: string): z.infer | n } } +function encodeAuditSnapshot(value:z.infer):string { + return Buffer.from(JSON.stringify(value),"utf8").toString("base64url"); +} + +function decodeAuditSnapshot(value:string):z.infer|null { + try { + const parsed=auditSnapshotSchema.safeParse(JSON.parse(Buffer.from(value,"base64url").toString("utf8"))); + return parsed.success?parsed.data:null; + } catch { + return null; + } +} + function redact(value: unknown): unknown { if (Array.isArray(value)) return value.map(redact); if (!value || typeof value !== "object") return value; @@ -50,14 +69,18 @@ export async function registerAudits(app: FastifyInstance, db: Database) { return reply.code(400).send({ message: "审计查询条件无效" }); } - const { cursor: encodedCursor, page:requestedPage, pageSize:requestedPageSize, ...filters } = parsed.data; + const { cursor: encodedCursor, snapshot:encodedSnapshot, page:requestedPage, pageSize:requestedPageSize, ...filters } = parsed.data; const numbered=requestedPage!==undefined||requestedPageSize!==undefined; - if(numbered&&encodedCursor)return reply.code(400).send({message:"页码与游标不能同时使用"}); + if((numbered&&encodedCursor)||(!numbered&&encodedSnapshot))return reply.code(400).send({message:"页码快照只能与页码一起使用,且不能与游标混用"}); const page=requestedPage??1;const pageSize=requestedPageSize??20; const cursor = encodedCursor ? decodeAuditCursor(encodedCursor) : null; if (encodedCursor && (!cursor || cursor.userId !== request.currentUser.id || JSON.stringify(cursor.filters) !== JSON.stringify(filters))) { return reply.code(400).send({ message: "审计分页游标无效或已不适用于当前查询" }); } + const snapshot=encodedSnapshot?decodeAuditSnapshot(encodedSnapshot):null; + if(encodedSnapshot&&(!snapshot||snapshot.userId!==request.currentUser.id||JSON.stringify(snapshot.filters)!==JSON.stringify(filters))){ + return reply.code(400).send({message:"审计页码快照无效或已不适用于当前查询"}); + } const systemAdmin = request.currentUser.roles.includes("system_admin"); const performanceAccess = await resolvePerformanceAccess(db, request.currentUser); @@ -70,28 +93,23 @@ export async function registerAudits(app: FastifyInstance, db: Database) { const client = await db.connect(); try { await client.query("begin"); - // ponytail: 首屏短暂锁表冻结快照;审计写入吞吐成为瓶颈时再改持久化快照。 - if (!cursor) await client.query("lock table audit_logs in share mode"); - const result = await client.query<{ + const result = await client.query<{audits:Array<{ action: string; actorDisplayName: string | null; actorPersonId: string | null; actorUsername: string | null; afterData: unknown; beforeData: unknown; - createdAt: Date; + createdAt: string; entityId: string | null; entityType: string; id: string; - cutoffId: string; - __totalCount: string; - }>( - `with cutoff as (select coalesce($20::bigint,max(id)) as id from audit_logs) - select audit.id::text, + }>;cutoffId:string;totalCount:string}>( + `with cutoff as (select coalesce($20::bigint,max(id),0) as id from audit_logs), filtered as materialized ( + select audit.id as "__id",audit.id::text, actor_person.id::text as "actorPersonId",actor_user.username as "actorUsername",actor_user.display_name as "actorDisplayName", audit.action,audit.entity_type as "entityType",audit.entity_id as "entityId", - audit.before_data as "beforeData",audit.after_data as "afterData",audit.created_at as "createdAt", - cutoff.id::text as "cutoffId",count(*) over()::text as "__totalCount" + audit.before_data as "beforeData",audit.after_data as "afterData",audit.created_at as "createdAt" from audit_logs audit cross join cutoff left join users actor_user on actor_user.id=audit.actor_user_id @@ -146,7 +164,13 @@ export async function registerAudits(app: FastifyInstance, db: Database) { and ($15::timestamptz is null or audit.created_at<=$15::timestamptz) and ($16::bigint is null or audit.id<$16::bigint) and audit.id<=cutoff.id - order by audit.id desc limit $19 offset $21`, + ), page_rows as ( + select * from filtered order by "__id" desc limit $19 offset $21 + ) + select cutoff.id::text as "cutoffId",(select count(*)::text from filtered) as "totalCount", + coalesce(jsonb_agg(to_jsonb(page_rows)-'__id' order by page_rows."__id" desc) + filter(where page_rows."__id" is not null),'[]'::jsonb) as audits + from cutoff left join page_rows on true group by cutoff.id`, [ systemAdmin, goalAccess.all, @@ -164,27 +188,27 @@ export async function registerAudits(app: FastifyInstance, db: Database) { request.currentUser.id, performanceReader, numbered?pageSize:PAGE_SIZE + 1, - cursor?.cutoffId ?? null, + cursor?.cutoffId ?? snapshot?.cutoffId ?? null, numbered?(page-1)*pageSize:0, ], ); await client.query("commit"); - const totalCount=Number(result.rows[0]?.__totalCount??0); - const hasNext = numbered?page*pageSize PAGE_SIZE; - const rows=numbered?result.rows:result.rows.slice(0,PAGE_SIZE); - const audits = rows.map(({ cutoffId: _cutoffId, __totalCount:_totalCount, ...row }) => ({ - ...row, - beforeData: redact(row.beforeData), - afterData: redact(row.afterData), + const row=result.rows[0]!; + const totalCount=Number(row.totalCount); + const hasNext = numbered?page*pageSize PAGE_SIZE; + const rows=numbered?row.audits:row.audits.slice(0,PAGE_SIZE); + const audits = rows.map((audit) => ({ + ...audit, + beforeData: redact(audit.beforeData), + afterData: redact(audit.afterData), })); - if(numbered)return{audits,page,pageSize,totalCount}; + if(numbered)return{audits,page,pageSize,totalCount,snapshot:encodeAuditSnapshot({version:1,userId:request.currentUser.id,filters,cutoffId:row.cutoffId})}; const last = audits.at(-1); - const cutoffId = result.rows[0]?.cutoffId; return { audits, pageSize: PAGE_SIZE, - nextCursor: hasNext && last && cutoffId - ? encodeAuditCursor({ version: 1, userId: request.currentUser.id, filters, id: last.id, cutoffId }) + nextCursor: hasNext && last && row.cutoffId!=="0" + ? encodeAuditCursor({ version: 1, userId: request.currentUser.id, filters, id: last.id, cutoffId:row.cutoffId }) : null, }; } catch (error) { diff --git a/apps/api/src/modules/goals.ts b/apps/api/src/modules/goals.ts index 720f3d1..90aec6b 100644 --- a/apps/api/src/modules/goals.ts +++ b/apps/api/src/modules/goals.ts @@ -3,7 +3,7 @@ import type { PoolClient } from "pg"; import { z } from "zod"; import type { Database } from "../db.js"; import { recordOperation } from "../observability.js"; -import { postgresBigintIdSchema } from "../validation.js"; +import { nonnegativeMoneySchema, postgresBigintIdSchema } from "../validation.js"; import { hasAnyRole, type CurrentUser } from "./auth.js"; import { canReadGoals, pendingGoalSql, pendingGoalValues, resolveGoalAccess } from "./authorization.js"; @@ -16,7 +16,7 @@ const createSchema = z.object({ ownerPersonId: postgresBigintIdSchema, orgUnitId: postgresBigintIdSchema.nullable().optional(), parentGoalId: postgresBigintIdSchema.nullable().optional(), - amount: z.number().finite().min(0).max(99_999_999_999.99), + amount: nonnegativeMoneySchema, changeReason: z.string().trim().min(1).max(500).optional().default("目标下达"), }); const confirmationSchema = z.strictObject({}); @@ -27,18 +27,18 @@ const decisionSchema = z.object({ comment: z.string().trim().min(1).max(500), }); const requestSchema = z.object({ - requestedAmount: z.number().finite().min(0).max(99_999_999_999.99).optional(), + requestedAmount: nonnegativeMoneySchema.optional(), reason: z.string().trim().min(1).max(500), }); const acceptSchema = z.object({ - newAmount: z.number().finite().min(0).max(99_999_999_999.99), + newAmount: nonnegativeMoneySchema, comment: z.string().trim().min(1).max(500), }); const rejectSchema = z.object({ comment: z.string().trim().min(1).max(500) }); const linkageSchema = z.object({ decision: z.enum(["keep_parent", "adjust_parent"]), reason: z.string().trim().min(1).max(500), - newAmount: z.number().finite().min(0).max(99_999_999_999.99).optional(), + newAmount: nonnegativeMoneySchema.optional(), }); const idSchema = z.object({ id: postgresBigintIdSchema }); const versionIdSchema = z.strictObject({ id: postgresBigintIdSchema }); diff --git a/apps/api/src/modules/performance.ts b/apps/api/src/modules/performance.ts index 8aef57f..fe942c3 100644 --- a/apps/api/src/modules/performance.ts +++ b/apps/api/src/modules/performance.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import type { Database } from "../db.js"; -import { pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js"; +import { nonnegativeMoneySchema, pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js"; import { businessDate } from "../domain/business-time.js"; import { decidePerformanceEvent, @@ -28,7 +28,7 @@ import { type QueryDatabase = Pick; -const moneySchema = z.number().finite().min(0).max(99_999_999_999.99); +const moneySchema = nonnegativeMoneySchema; const dateSchema = z.iso.date(); const dashboardQuerySchema = z.object({ month: z.string().regex(/^[1-9]\d{3}-(0[1-9]|1[0-2])$/).optional(), @@ -37,7 +37,7 @@ const analysisProvinceSchema = z.string().refine((value) => value.startsWith("CN const analysisMonthSchema = z.string().regex(/^[1-9]\d{3}-(0[1-9]|1[0-2])$/); 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(), ...paginationQueryFields }), + 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("months"), regionCode: analysisProvinceSchema, @@ -50,6 +50,7 @@ const analysisDrilldownQuerySchema = z.discriminatedUnion("level", [ customerUnit: z.string().trim().min(1).max(300), month: analysisMonthSchema, cursor: z.string().min(1).max(2048).optional(), + snapshot: z.string().min(1).max(2048).optional(), ...paginationQueryFields, }), ]); @@ -64,6 +65,7 @@ const ANALYSIS_CUSTOMER_PAGE_SIZE = 50; const ANALYSIS_EVENT_PAGE_SIZE = 100; const orderListQuerySchema = orderFilterQuerySchema.extend({ cursor: z.string().min(1).max(2048).optional(), + snapshot: z.string().min(1).max(2048).optional(), ...paginationQueryFields, }); const orderCursorSchema = z.strictObject({ @@ -77,6 +79,14 @@ const orderCursorSchema = z.strictObject({ userId: postgresBigintIdSchema, }); type OrderCursor = z.infer; +const sequenceSchema = z.union([z.literal("0"), postgresBigintIdSchema]); +const orderSnapshotSchema = z.strictObject({ + version: z.literal(1), + cutoffId: sequenceSchema, + filterDigest: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + userId: postgresBigintIdSchema, +}); +type OrderSnapshot = z.infer; const analysisCursorBase = { version: z.literal(1), queryDigest: z.string().regex(/^[A-Za-z0-9_-]{43}$/), @@ -97,6 +107,12 @@ const analysisEventCursorSchema = z.strictObject({ }); type AnalysisCustomerCursor = z.infer; type AnalysisEventCursor = z.infer; +const analysisSnapshotSchema = z.strictObject({ + ...analysisCursorBase, + cutoffEventId: sequenceSchema, + cutoffDimensionSequence: sequenceSchema, +}); +type AnalysisSnapshot = z.infer; function orderFilterDigest(filters: OrderFilters): string { return createHash("sha256").update(JSON.stringify(filters), "utf8").digest("base64url"); @@ -116,11 +132,25 @@ function decodeOrderCursor(value: string): OrderCursor | null { } } +function encodeOrderSnapshot(snapshot: OrderSnapshot): string { + return Buffer.from(JSON.stringify(snapshot), "utf8").toString("base64url"); +} + +function decodeOrderSnapshot(value: string): OrderSnapshot | null { + if (!/^[A-Za-z0-9_-]+$/.test(value)) return null; + try { + const parsed = orderSnapshotSchema.safeParse(JSON.parse(Buffer.from(value, "base64url").toString("utf8"))); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + function analysisQueryDigest(value: unknown): string { return createHash("sha256").update(JSON.stringify(value), "utf8").digest("base64url"); } -function encodeAnalysisCursor(value: AnalysisCustomerCursor | AnalysisEventCursor): string { +function encodeAnalysisCursor(value: AnalysisCustomerCursor | AnalysisEventCursor | AnalysisSnapshot): string { return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } @@ -1043,20 +1073,26 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const parsed = analysisDrilldownQuerySchema.safeParse(request.query); if (!parsed.success) return reply.code(400).send({ code: "ANALYSIS_DRILLDOWN_INVALID", message: "分析穿透条件无效" }); const numbered=parsed.data.level!=="months"&&(parsed.data.page!==undefined||parsed.data.pageSize!==undefined); - if(parsed.data.level!=="months"&&numbered&&parsed.data.cursor)return reply.code(400).send({code:"ANALYSIS_PAGINATION_INVALID",message:"页码与游标不能同时使用"}); + if(parsed.data.level!=="months"&&((numbered&&parsed.data.cursor)||(!numbered&&parsed.data.snapshot)))return reply.code(400).send({code:"ANALYSIS_PAGINATION_INVALID",message:"页码快照只能与页码一起使用,且不能与游标混用"}); const page=parsed.data.level==="months"?1:parsed.data.page??1; const pageSize=parsed.data.level==="months"?20:parsed.data.pageSize??20; - const queryDigest = analysisQueryDigest({ ...parsed.data, cursor: undefined, page:undefined, pageSize:undefined }); + const queryDigest = analysisQueryDigest({ ...parsed.data, cursor: undefined, snapshot:undefined, page:undefined, pageSize:undefined }); const customerCursor = parsed.data.level === "customers" && parsed.data.cursor ? decodeAnalysisCursor(parsed.data.cursor, analysisCustomerCursorSchema) : null; const eventCursor = parsed.data.level === "events" && parsed.data.cursor ? decodeAnalysisCursor(parsed.data.cursor, analysisEventCursorSchema) : null; + const snapshot = parsed.data.level !== "months" && parsed.data.snapshot + ? decodeAnalysisCursor(parsed.data.snapshot, analysisSnapshotSchema) + : null; if ((parsed.data.level === "customers" && parsed.data.cursor && (!customerCursor || customerCursor.queryDigest !== queryDigest || customerCursor.userId !== request.currentUser.id)) || (parsed.data.level === "events" && parsed.data.cursor && (!eventCursor || eventCursor.queryDigest !== queryDigest || eventCursor.userId !== request.currentUser.id))) { return reply.code(400).send({ code: "ANALYSIS_CURSOR_INVALID", message: "分析分页游标无效或已不适用于当前查询" }); } + if (parsed.data.level !== "months" && parsed.data.snapshot && (!snapshot || snapshot.queryDigest !== queryDigest || snapshot.userId !== request.currentUser.id)) { + return reply.code(400).send({ code: "ANALYSIS_SNAPSHOT_INVALID", message: "分析页码快照无效或已不适用于当前查询" }); + } const client = await db.connect(); try { const access = await resolvePerformanceAccess(client, request.currentUser); @@ -1064,9 +1100,6 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl return reply.code(403).send({ message: "当前角色没有业务查看权限" }); } await client.query("begin transaction isolation level repeatable read read only"); - if ((parsed.data.level === "customers" && !customerCursor) || (parsed.data.level === "events" && !eventCursor)) { - await client.query("lock table performance_event_analysis_dimensions in share mode"); - } if (parsed.data.level === "customers") { const result = await client.query( `with dimension_cutoff as ( @@ -1107,7 +1140,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 ?? null, customerCursor?.cutoffDimensionSequence ?? null, numbered?pageSize:ANALYSIS_CUSTOMER_PAGE_SIZE + 1, numbered?(page-1)*pageSize:0], + [`${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], ); await client.query("commit"); const row = result.rows[0]!; @@ -1123,7 +1156,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl totalAmount: row.total_amount, customerCount: Number(row.customer_count), nextCursor: hasNextPage && last && row.cutoff_event_id && row.cutoff_dimension_sequence ? encodeAnalysisCursor({ version: 1, queryDigest, userId: request.currentUser.id, cutoffEventId: row.cutoff_event_id, cutoffDimensionSequence: row.cutoff_dimension_sequence, totalAmount: last.totalAmount, customerUnit: last.customerUnit }) : null, - ...(numbered?{page,totalCount:Number(row.customer_count)}:{}), + ...(numbered?{page,totalCount:Number(row.customer_count),snapshot:encodeAnalysisCursor({version:1,queryDigest,userId:request.currentUser.id,cutoffEventId:row.cutoff_event_id??"0",cutoffDimensionSequence:row.cutoff_dimension_sequence??"0"})}:{}), pageSize: numbered?pageSize:ANALYSIS_CUSTOMER_PAGE_SIZE, customers, }; @@ -1206,7 +1239,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl filter(where page."__eventId" is not null),'[]'::jsonb) as events 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, parsed.data.customerUnit, ...performanceScopeValues(access), eventCursor?.eventId ?? null, eventCursor?.cutoffEventId ?? null, eventCursor?.cutoffDimensionSequence ?? null, numbered?pageSize:ANALYSIS_EVENT_PAGE_SIZE + 1, numbered?(page-1)*pageSize:0], + [`${parsed.data.month}-01`, parsed.data.regionCode, parsed.data.customerUnit, ...performanceScopeValues(access), eventCursor?.eventId ?? null, eventCursor?.cutoffEventId ?? snapshot?.cutoffEventId ?? null, eventCursor?.cutoffDimensionSequence ?? snapshot?.cutoffDimensionSequence ?? null, numbered?pageSize:ANALYSIS_EVENT_PAGE_SIZE + 1, numbered?(page-1)*pageSize:0], ); await client.query("commit"); const row = result.rows[0]!; @@ -1235,7 +1268,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl eventCount: Number(row.eventCount), totalAmount: row.totalAmount, nextCursor: hasNextPage && last && row.cutoffEventId && row.cutoffDimensionSequence ? encodeAnalysisCursor({ version: 1, queryDigest, userId: request.currentUser.id, cutoffEventId: row.cutoffEventId, cutoffDimensionSequence: row.cutoffDimensionSequence, eventId: last.id }) : null, - ...(numbered?{page,totalCount:Number(row.eventCount)}:{}), + ...(numbered?{page,totalCount:Number(row.eventCount),snapshot:encodeAnalysisCursor({version:1,queryDigest,userId:request.currentUser.id,cutoffEventId:row.cutoffEventId??"0",cutoffDimensionSequence:row.cutoffDimensionSequence??"0"})}:{}), pageSize: numbered?pageSize:ANALYSIS_EVENT_PAGE_SIZE, orders: [...orders.values()], }; @@ -1401,17 +1434,62 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const query = orderListQuerySchema.safeParse(request.query); if (!query.success) return reply.code(400).send({ message: "查询条件无效" }); const numbered=query.data.page!==undefined||query.data.pageSize!==undefined; - if(numbered&&query.data.cursor)return reply.code(400).send({code:"ORDER_PAGINATION_INVALID",message:"页码与游标不能同时使用"}); + if((numbered&&query.data.cursor)||(!numbered&&query.data.snapshot))return reply.code(400).send({code:"ORDER_PAGINATION_INVALID",message:"页码快照只能与页码一起使用,且不能与游标混用"}); const page=query.data.page??1;const pageSize=query.data.pageSize??20; const filters = normalizeOrderFilters(query.data); const cursor = query.data.cursor ? decodeOrderCursor(query.data.cursor) : null; if (query.data.cursor && (!cursor || cursor.filterDigest !== orderFilterDigest(filters) || cursor.userId !== request.currentUser.id)) { return reply.code(400).send({ code: "ORDER_CURSOR_INVALID", message: "分页游标无效或已不适用于当前查询" }); } + const snapshot = query.data.snapshot ? decodeOrderSnapshot(query.data.snapshot) : null; + if (query.data.snapshot && (!snapshot || snapshot.filterDigest !== orderFilterDigest(filters) || snapshot.userId !== request.currentUser.id)) { + return reply.code(400).send({ code: "ORDER_SNAPSHOT_INVALID", message: "页码快照无效或已不适用于当前查询" }); + } + if (numbered) { + const result = await db.query<{ + cutoffId:string; + totalCount:string; + orders:Array>; + }>( + `with cutoff as ( + select coalesce($15::bigint,max(id),0) as id from performance_orders + ), filtered as materialized ( + select performance_orders.id as "__id",performance_orders.id::text,performance_orders.created_at as "__cursorCreatedAt", + qingflow_order_no as "orderNo",customer_name as "customerName",customer_unit as "customerUnit", + performance_orders.salesperson_name as "salespersonName",service_type as "serviceType", + source_received_on as "sourceReceivedOn",original_amount::text as "originalAmount", + current_revenue::text as "currentRevenue",counted_amount::text as "countedAmount", + lifecycle_state as "lifecycleState",posted_at as "postedAt", + latest.department_name as "departmentName",latest.group_name as "groupName", + latest.leader_name as "leaderName",latest.supervisor_name as "supervisorName" + from performance_orders + ${latestOrderEventJoinSql("performance_orders", "latest")} + cross join cutoff + where performance_orders.id<=cutoff.id + and ${performanceScopeSql("latest", 2)} + and ${orderFilterSql("performance_orders", "latest", 6)} + ), page_rows as ( + select * from filtered order by "__cursorCreatedAt" desc,"__id" desc limit $1 offset $16 + ) + select cutoff.id::text as "cutoffId",(select count(*)::text from filtered) as "totalCount", + coalesce(jsonb_agg(to_jsonb(page_rows)-'__id'-'__cursorCreatedAt' order by page_rows."__cursorCreatedAt" desc,page_rows."__id" desc) + filter(where page_rows."__id" is not null),'[]'::jsonb) as orders + from cutoff left join page_rows on true group by cutoff.id`, + [pageSize,...performanceScopeValues(access),...orderFilterValues(filters),snapshot?.cutoffId??null,(page-1)*pageSize], + ); + const row=result.rows[0]!; + return { + orders:row.orders, + page, + pageSize, + totalCount:Number(row.totalCount), + snapshot:encodeOrderSnapshot({version:1,cutoffId:row.cutoffId,filterDigest:orderFilterDigest(filters),userId:request.currentUser.id}), + }; + } const direction = cursor?.direction ?? "next"; - type OrderListRow = Record & { id: string; __cursorCreatedAt: Date; __totalCount:string }; + type OrderListRow = Record & { id: string; __cursorCreatedAt: Date }; const result = await db.query( - `select id::text, created_at as "__cursorCreatedAt",count(*) over()::text as "__totalCount",qingflow_order_no as "orderNo", customer_name as "customerName", + `select id::text, created_at as "__cursorCreatedAt",qingflow_order_no as "orderNo", customer_name as "customerName", customer_unit as "customerUnit", performance_orders.salesperson_name as "salespersonName", service_type as "serviceType", source_received_on as "sourceReceivedOn", original_amount::text as "originalAmount", current_revenue::text as "currentRevenue", counted_amount::text as "countedAmount", @@ -1425,19 +1503,17 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl ${cursor ? `and (performance_orders.created_at,performance_orders.id)<=($15::timestamptz,$16::bigint) and (performance_orders.created_at,performance_orders.id)${direction === "next" ? "<" : ">"}($17::timestamptz,$18::bigint)` : ""} order by performance_orders.created_at ${direction === "previous" ? "asc" : "desc"},performance_orders.id ${direction === "previous" ? "asc" : "desc"} - limit $1 ${numbered?"offset $15":""}`, + limit $1`, [ - numbered?pageSize:ORDER_PAGE_SIZE + 1, + ORDER_PAGE_SIZE + 1, ...performanceScopeValues(access), ...orderFilterValues(filters), - ...(cursor ? [cursor.cutoffCreatedAt, cursor.cutoffId, cursor.anchorCreatedAt, cursor.anchorId] : numbered?[(page-1)*pageSize]:[]), + ...(cursor ? [cursor.cutoffCreatedAt, cursor.cutoffId, cursor.anchorCreatedAt, cursor.anchorId] : []), ], ); - const totalCount=Number(result.rows[0]?.__totalCount??0); - const hasExtra = !numbered&&result.rows.length > ORDER_PAGE_SIZE; - const pageRows = numbered?result.rows:result.rows.slice(0, ORDER_PAGE_SIZE); + const hasExtra = result.rows.length > ORDER_PAGE_SIZE; + const pageRows = result.rows.slice(0, ORDER_PAGE_SIZE); if (direction === "previous") pageRows.reverse(); - if(numbered)return{orders:pageRows.map(({__cursorCreatedAt:_createdAt,__totalCount:_total,...order})=>order),page,pageSize,totalCount}; const cutoff = cursor ?? (pageRows[0] ? { cutoffCreatedAt: pageRows[0].__cursorCreatedAt.toISOString(), cutoffId: pageRows[0].id, @@ -1457,7 +1533,7 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const previousCursor = first && cursor && (cursor.direction === "next" || hasExtra) ? makeCursor("previous", first) : null; const nextCursor = last && (cursor?.direction === "previous" || hasExtra) ? makeCursor("next", last) : null; return { - orders: pageRows.map(({ __cursorCreatedAt: _createdAt, __totalCount:_totalCount, ...order }) => order), + orders: pageRows.map(({ __cursorCreatedAt: _createdAt, ...order }) => order), previousCursor, nextCursor, pageSize: ORDER_PAGE_SIZE, diff --git a/apps/api/src/organization-import.integration.test.ts b/apps/api/src/organization-import.integration.test.ts index 75d0e3b..8c48fe3 100644 --- a/apps/api/src/organization-import.integration.test.ts +++ b/apps/api/src/organization-import.integration.test.ts @@ -45,7 +45,8 @@ test("组织初始化可重放,保留金额并拒绝同来源替换映射", as const order = await client.query<{id:string}>( `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) - values($1,'客户','单位',$2,$3,100,100,$4,'active',now()) returning id::text`, + values($1,'客户','单位',$2,$3,100,greatest($4::numeric,0),$4, + case when $4::numeric>0 then 'active' else 'historical_review_required' end,now()) returning id::text`, [`IMPORT-${index+1}`,row[0],`2026-01-0${index+2}`,row[3]], ); await client.query( diff --git a/apps/api/src/performance-analysis.integration.test.ts b/apps/api/src/performance-analysis.integration.test.ts index 30a103d..cb1543d 100644 --- a/apps/api/src/performance-analysis.integration.test.ts +++ b/apps/api/src/performance-analysis.integration.test.ts @@ -418,23 +418,6 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 await setup.query("delete from performance_event_analysis_dimensions where event_id=$1", [eventIds[4]]); await setup.query("set session_replication_role=origin"); }; - const waitForAnalysisShareLock = async () => { - 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 - join pg_stat_activity activity on activity.pid=locks.pid - where relation.relname='performance_event_analysis_dimensions' - and locks.mode='ShareLock' and not locks.granted - and activity.application_name='sampleflow-api-runtime' - ) as waiting`, - ); - if (waiting.rows[0]!.waiting) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - assert.fail("首屏分析必须等待在途维度写入提交后再冻结快照"); - }; const scaledDrilldowns = [ { name: "省份客户", url: "/api/performance/analysis/drilldown?level=customers®ionCode=CN-JS&month=2026-08", baseline: smallCustomersReadCount }, { name: "客户月份", url: "/api/performance/analysis/drilldown?level=months®ionCode=CN-JS&customerUnit=%E5%AE%A2%E6%88%B7%E5%8D%95%E4%BD%8D%E7%94%B2&year=2026", baseline: smallMonthsReadCount }, @@ -465,12 +448,17 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 [heldEventId], ); const pendingResult = app.inject({ method: "GET", url: drilldown.url, headers: { cookie: leaderCookie } }); + const resolvedBeforeCommit=await Promise.race([ + pendingResult, + new Promise((resolve)=>setTimeout(()=>resolve(null),1_000)), + ]); try { - await waitForAnalysisShareLock(); - } finally { await writer.query("commit"); + } finally { + if(!resolvedBeforeCommit)await writer.query("rollback"); } - result = await pendingResult; + assert.ok(resolvedBeforeCommit,"分析读取不得等待在途维度写入提交"); + result = resolvedBeforeCommit; } finally { await writer.end(); } @@ -485,9 +473,22 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 const page = result.json(); assert.equal(page.customers.length, 50); assert.equal(page.customerCount, 61); - assert.equal(page.eventCount, 4699); + assert.equal(page.eventCount, 4698); assert.ok(page.nextCursor); const firstUnits = new Set(page.customers.map((customer: { customerUnit: string }) => customer.customerUnit)); + const numberedFirst=await app.inject({method:"GET",url:`${drilldown.url}&page=1&pageSize=10`,headers:{cookie:leaderCookie}}); + assert.equal(numberedFirst.statusCode,200,numberedFirst.body); + const numberedBody=numberedFirst.json<{snapshot:string;totalCount:number;customers:Array<{customerUnit:string}>}>(); + assert.ok(numberedBody.snapshot); + await insertConcurrentDimension("页码并发单位"); + try { + const numberedNext=await app.inject({method:"GET",url:`${drilldown.url}&page=2&pageSize=10&snapshot=${encodeURIComponent(numberedBody.snapshot)}`,headers:{cookie:leaderCookie}}); + assert.equal(numberedNext.statusCode,200,numberedNext.body); + assert.equal(numberedNext.json().totalCount,numberedBody.totalCount); + assert.equal(numberedNext.json().customers.some((customer:{customerUnit:string})=>customer.customerUnit==="页码并发单位"),false); + } finally { + await removeConcurrentDimension(); + } const concurrentEventId = await insertConcurrentEvent(-1_000_000_000); await insertConcurrentDimension("并发补齐单位"); try { @@ -508,6 +509,19 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 assert.equal(page.orders.flatMap((order: { events: unknown[] }) => order.events).length, 100); assert.ok(page.nextCursor); const ids = new Set(page.orders.flatMap((order: { events: Array<{ id: string }> }) => order.events.map((event) => event.id))); + const numberedFirst=await app.inject({method:"GET",url:`${drilldown.url}&page=1&pageSize=100`,headers:{cookie:leaderCookie}}); + assert.equal(numberedFirst.statusCode,200,numberedFirst.body); + const numberedBody=numberedFirst.json<{snapshot:string;totalCount:number}>(); + assert.ok(numberedBody.snapshot); + await insertConcurrentDimension("客户单位甲"); + try { + const numberedNext=await app.inject({method:"GET",url:`${drilldown.url}&page=2&pageSize=100&snapshot=${encodeURIComponent(numberedBody.snapshot)}`,headers:{cookie:leaderCookie}}); + assert.equal(numberedNext.statusCode,200,numberedNext.body); + assert.equal(numberedNext.json().totalCount,numberedBody.totalCount); + assert.equal(numberedNext.json().orders.flatMap((order:{events:Array<{id:string}>})=>order.events).some((event:{id:string})=>event.id===eventIds[4]),false); + } finally { + await removeConcurrentDimension(); + } const concurrentEventId = await insertConcurrentEvent(1); await insertConcurrentDimension("客户单位甲"); try { @@ -522,6 +536,7 @@ test("地区与客户分析按事件快照对账且查询次数不随规模增 } } } + assert.equal(analysisShareLockCount,0,"分析穿透不得取得阻塞业务写入的表级锁"); for (const [index, captured] of largeDrilldownQueries.entries()) { const explain = await setup.query<{ "QUERY PLAN": Array<{ Plan: Record }> }>( diff --git a/apps/api/src/services/import-job.ts b/apps/api/src/services/import-job.ts index 4d5ae66..6d334a8 100644 --- a/apps/api/src/services/import-job.ts +++ b/apps/api/src/services/import-job.ts @@ -1472,10 +1472,6 @@ export async function confirmImportBatch(database: Database, batchId: string, ac priorEventCount = Number(existing.event_count); preserveHistoricalReview = existing.lifecycle_state === "historical_review_required"; } else { - const total = Math.round(orderRows.reduce((sum, row) => sum + row.amount, 0) * 100) / 100; - const lifecycle = allLegacy - ? total > 0 ? "active" : orderRows.length === 1 && total === 0 ? "zero" : "historical_review_required" - : "draft"; const inserted = 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,service_type,source_received_on,original_amount,current_revenue,counted_amount, @@ -1483,7 +1479,7 @@ export async function confirmImportBatch(database: Database, batchId: string, ac values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,0,$11,$12,now()) returning id::text`, [orderNo, first.customerName, first.customerUnit, first.businessRegionSourceText, first.businessRegionCode, first.organization.personId, first.organization.salespersonName, first.serviceType || null, first.occurredOn, - Math.max(0, first.amount), lifecycle, actorUserId], + Math.max(0, first.amount), "draft", actorUserId], ); orderId = inserted.rows[0]!.id; importedOrders += 1; diff --git a/apps/api/src/test-database.integration.test.ts b/apps/api/src/test-database.integration.test.ts index 1f4efb3..94cce6f 100644 --- a/apps/api/src/test-database.integration.test.ts +++ b/apps/api/src/test-database.integration.test.ts @@ -84,8 +84,16 @@ test("干净隔离数据库可应用全部现有迁移", async () => { "022_freeze_analysis_dimension_pagination.sql", "023_immutable_confirmations_and_audit.sql", "024_auth_throttle_cleanup.sql", + "025_enforce_performance_order_state_amounts.sql", ]); assert.ok(result.rows.every((row) => /^[a-f0-9]{64}$/.test(row.sha256))); + await assert.rejects( + 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) + values('INVALID-ACTIVE-STATE','约束客户','约束单位','约束业务员',current_date,1,0,0,'active')`, + ), + /performance_orders_state_amounts_check/, + ); } finally { await client.end(); } diff --git a/apps/api/src/validation.test.ts b/apps/api/src/validation.test.ts new file mode 100644 index 0000000..3448dc2 --- /dev/null +++ b/apps/api/src/validation.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { nonnegativeMoneySchema } from "./validation.js"; + +test("金额只接受非负且最多两位小数的有限数字", () => { + assert.equal(nonnegativeMoneySchema.safeParse(12.34).success, true); + assert.equal(nonnegativeMoneySchema.safeParse(85_521_505_025.01).success, true); + for (const value of [-0.01, 0.001, Number.POSITIVE_INFINITY]) { + assert.equal(nonnegativeMoneySchema.safeParse(value).success, false); + } +}); diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 1a250a7..258a39e 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -4,5 +4,12 @@ export const postgresBigintIdSchema = z.string().refine( (value) => /^[1-9]\d*$/.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n, ); +export const nonnegativeMoneySchema = z.number().finite().min(0).max(99_999_999_999.99) + .refine((value) => { + const cents = value * 100; + const tolerance = Number.EPSILON * Math.max(1, Math.abs(cents)) * 4; + return Math.abs(cents - Math.round(cents)) <= tolerance; + }, "金额最多保留两位小数"); + export const pageNumberSchema=z.coerce.number().int().min(1).max(1_000_000); export const pageSizeSchema=z.coerce.number().int().refine((value)=>[10,20,50,100].includes(value)); diff --git a/apps/web/e2e/analysis.spec.ts b/apps/web/e2e/analysis.spec.ts index 2842465..a368f1d 100644 --- a/apps/web/e2e/analysis.spec.ts +++ b/apps/web/e2e/analysis.spec.ts @@ -149,6 +149,8 @@ test("业绩分析页显示事件快照地区、外贸、客户单位和待补 await expect(drilldown.getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); await expect(drilldown.getByRole("row", { name: /E2E-ANALYSIS.*E2E 分析客户.*1.*¥100\.00/ })).toBeVisible(); await expect(drilldown.getByRole("row", { name: /第 1 条.*首次录入.*¥100\.00.*江苏省.*客户单位甲/ })).toBeVisible(); + await expect.poll(()=>new URL(page.url()).searchParams.get("analysisCustomerSnapshot")).toBeTruthy(); + await expect.poll(()=>new URL(page.url()).searchParams.get("analysisEventSnapshot")).toBeTruthy(); expect(Object.fromEntries(new URL(page.url()).searchParams)).toMatchObject({analysisMonth:"2026-08",analysisRegion:"CN-JS",analysisCustomer:"客户单位甲",analysisEventMonth:"2026-08"}); await page.reload(); await expect(analysis.getByRole("region", { name: "分析穿透" }).getByRole("heading", { name: "2026年8月订单与事件" })).toBeVisible(); @@ -238,14 +240,14 @@ test("第二批客户穿透可通过刷新和浏览器历史恢复", async ({ da const customers = secondPage ? [{ customerUnit: "客户51", eventCount: 0, totalAmount: "0.00" }] : Array.from({ length: pageSize }, (_, index) => ({ customerUnit: `客户${String(index + 1).padStart(2, "0")}`, eventCount: 0, totalAmount: "0.00" })); - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level, regionCode: "CN-JS", regionName: "江苏省", month: "2026-08", eventCount: 0, totalAmount: "0.00", customerCount: 51, nextCursor: null, page: pageNumber, pageSize, totalCount: 51, customers }) }); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level, regionCode: "CN-JS", regionName: "江苏省", month: "2026-08", eventCount: 0, totalAmount: "0.00", customerCount: 51, nextCursor: null, snapshot:"mock-customer-snapshot", page: pageNumber, pageSize, totalCount: 51, customers }) }); return; } if (level === "months") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level, regionCode: "CN-JS", regionName: "江苏省", customerUnit: url.searchParams.get("customerUnit"), year: "2026", eventCount: 0, totalAmount: "0.00", months: [{ month: "2026-08", eventCount: 0, totalAmount: "0.00" }] }) }); return; } - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level: "events", regionCode: "CN-JS", regionName: "江苏省", customerUnit: url.searchParams.get("customerUnit"), month: "2026-08", eventCount: 0, totalAmount: "0.00", nextCursor: null, page: 1, pageSize: 20, totalCount: 0, orders: [] }) }); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ level: "events", regionCode: "CN-JS", regionName: "江苏省", customerUnit: url.searchParams.get("customerUnit"), month: "2026-08", eventCount: 0, totalAmount: "0.00", nextCursor: null, snapshot:"mock-event-snapshot", page: 1, pageSize: 20, totalCount: 0, orders: [] }) }); }); await page.goto(`/?${new URLSearchParams({ page: "analysis", analysisMonth: "2026-08", analysisRegion: "CN-JS", analysisCustomer: "客户51", analysisEventMonth: "2026-08", analysisCustomerPage: "2", analysisCustomerPageSize: "50" })}`); diff --git a/apps/web/e2e/audits.spec.ts b/apps/web/e2e/audits.spec.ts index b183820..e10a03f 100644 --- a/apps/web/e2e/audits.spec.ts +++ b/apps/web/e2e/audits.spec.ts @@ -49,11 +49,13 @@ test("审计页面只读展示所属域并支持组合过滤", async ({ context, await page.getByRole("button", { name: "审计查询" }).click(); await expect(page.getByRole("heading", { name: "审计查询" })).toBeVisible(); await expect(page.getByRole("cell", { name: "创建账号" })).toBeVisible(); + await expect.poll(()=>new URL(page.url()).searchParams.get("auditSnapshot")).toBeTruthy(); await expect(page.getByText("performance.order_posted", { exact: true })).not.toBeVisible(); const pageSize=page.getByLabel("审计记录每页条数"); expect(await pageSize.locator("option").allTextContents()).toEqual(["10 条/页","20 条/页","50 条/页","100 条/页"]); await page.getByRole("button", { name: "第 2 页" }).click(); expect(new URL(page.url()).searchParams.get("auditPage")).toBe("2"); + expect(new URL(page.url()).searchParams.get("auditSnapshot")).toBeTruthy(); await expect(page.getByText("创建账号", { exact: true })).toHaveCount(0); await expect(page.getByRole("cell", { name: "组织分页记录" }).first()).toBeVisible(); const secondPageUrl = page.url(); diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts index 32455f2..3f17740 100644 --- a/apps/web/e2e/login.spec.ts +++ b/apps/web/e2e/login.spec.ts @@ -308,6 +308,7 @@ test("订单组合筛选由 URL 恢复并区分空集、失败和无权限", asy await page.getByLabel("客户单位筛选").fill(matching.customerUnit); await page.getByRole("button", { name: "应用筛选" }).click(); await expect(ledger.getByText("E2E-FILTER-0051", { exact: true })).toBeVisible(); + await expect.poll(()=>new URL(page.url()).searchParams.get("orderSnapshot")).toBeTruthy(); await expect(ledger.getByText("E2E-OTHER-0001", { exact: true })).toHaveCount(0); await expect(page).toHaveURL(/page=orders/); for (const value of ["orderMonth=2026-08", "orderStatus=active", "orderRegion=CN-JS"]) await expect(page).toHaveURL(new RegExp(value)); @@ -318,6 +319,7 @@ test("订单组合筛选由 URL 恢复并区分空集、失败和无权限", asy const secondPageUrl = page.url(); expect(secondPageUrl).toContain("orderPage=2"); expect(secondPageUrl).toContain("orderPageSize=50"); + expect(new URL(secondPageUrl).searchParams.get("orderSnapshot")).toBeTruthy(); await page.reload(); await expect(ledger.getByText("E2E-FILTER-0001", { exact: true })).toBeVisible(); await ledger.getByRole("button", { name: "查看 / 调整" }).click(); diff --git a/apps/web/e2e/onboarding.spec.ts b/apps/web/e2e/onboarding.spec.ts index bd3aa46..21b2ee5 100644 --- a/apps/web/e2e/onboarding.spec.ts +++ b/apps/web/e2e/onboarding.spec.ts @@ -22,6 +22,34 @@ async function completeTour(page: import("@playwright/test").Page) { await expect(dialog).toBeHidden(); } +test("总览等待异步内容就绪后再播放完整引导",async({database,page})=>{ + await seedTestUser(database.url,{ + username:"e2e_tour_overview_ready", + displayName:"E2E 总览引导就绪", + password:"Tour@123", + roleCode:"sales_assistant", + roleName:"销售助理", + }); + let releaseDashboard!:()=>void; + let markRequested!:()=>void; + const dashboardReleased=new Promise((resolve)=>{releaseDashboard=resolve;}); + const dashboardRequested=new Promise((resolve)=>{markRequested=resolve;}); + await page.route("**/api/performance/dashboard",async(route)=>{markRequested();await dashboardReleased;await route.continue();}); + await page.goto("/?page=overview"); + await login(page,"e2e_tour_overview_ready"); + await dashboardRequested; + const dialog=page.locator(".onboarding-bubble"); + await expect(page.getByRole("heading",{name:"业绩账本总览"})).toBeVisible(); + await expect(dialog).toBeHidden(); + releaseDashboard(); + await expect(dialog.getByRole("heading",{name:"从这里进入工作页面"})).toBeVisible(); + for(const title of ["业绩总览","目标与账本指标","最近业绩事件"]){ + await dialog.getByRole("button",{name:"下一步"}).click(); + await expect(dialog.getByRole("heading",{name:title})).toBeVisible(); + } + await dialog.getByRole("button",{name:"跳过本页"}).click(); +}); + test("首次自动播放,完成后不再打扰且可以手动重播", async ({ database, page }) => { const userId = await seedTestUser(database.url, { username: "e2e_tour_persistence", diff --git a/apps/web/e2e/routes.spec.ts b/apps/web/e2e/routes.spec.ts index d50283f..9f15cb4 100644 --- a/apps/web/e2e/routes.spec.ts +++ b/apps/web/e2e/routes.spec.ts @@ -141,7 +141,7 @@ test("退出失败保留会话,受保护请求只在 401 时回到登录", asy }); await page.getByRole("button", { name: "导出当前授权范围匹配订单" }).click(); await expect(page.getByRole("heading", { name: "登录系统", exact: true })).toBeVisible(); - await expect(page).toHaveURL(/\?page=orders&orderSearch=keep$/); + await expect(page).toHaveURL(/\?page=orders&orderSearch=keep&orderPage=1&orderPageSize=20$/); }); test("目标创建原样提交超出 JavaScript 安全整数范围的标识", async ({ database, page }) => { diff --git a/apps/web/src/app-types.ts b/apps/web/src/app-types.ts index bb0a0a3..30fa6fc 100644 --- a/apps/web/src/app-types.ts +++ b/apps/web/src/app-types.ts @@ -25,11 +25,11 @@ 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;page:number;pageSize:number;totalCount:number;customers:AnalysisCustomer[]}; +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 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:"active"|"paused"|"zero"|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[]}; -export type AnalysisEventsDrilldown = {level:"events";regionCode:string;regionName:string;customerUnit:string;month:string;eventCount:number;totalAmount:string;nextCursor:string|null;page:number;pageSize:number;totalCount:number;orders:AnalysisDrilldownOrder[]}; +export type AnalysisEventsDrilldown = {level:"events";regionCode:string;regionName:string;customerUnit:string;month:string;eventCount:number;totalAmount:string;nextCursor:string|null;snapshot:string;page:number;pageSize:number;totalCount:number;orders:AnalysisDrilldownOrder[]}; export type GoalLevel="sales_manager"|"department"|"group"|"personal"; export type Goal = { id:string;periodMonth:string;level:GoalLevel;ownerUsername:string|null;ownerName:string;ownerPersonId:string;orgUnitId:string|null;orgUnitName:string|null;parentGoalId:string|null;versionId:string;versionNo:string;amount:string;effectiveAmount:string|null;status:string;signatureText:string|null;signedAt:string|null;changeReason:string;allocatedAmount:string;allocationDifference:string;allocationType:"unallocated"|"overallocated"|"balanced";allocationRatio:string|null }; export type GoalOption={personId:string;name:string;orgUnitId:string|null;orgUnitName:string|null}; diff --git a/apps/web/src/onboarding.tsx b/apps/web/src/onboarding.tsx index f08606b..30b52be 100644 --- a/apps/web/src/onboarding.tsx +++ b/apps/web/src/onboarding.tsx @@ -241,6 +241,7 @@ export function Onboarding({ user, page, canOpen, includeNavigation }: { user: U useEffect(() => { if (!canOpen || hasFlag(pageFlag) || hasFlag(allFlag) || autoAttempted.current.has(pageFlag)) return; const tryStart = () => { + if (page === "overview" && !document.querySelector('[data-onboarding-page="overview"][data-onboarding-ready="true"]')) return false; if (!start()) return false; autoAttempted.current.add(pageFlag); return true; @@ -249,7 +250,7 @@ export function Onboarding({ user, page, canOpen, includeNavigation }: { user: U const root = document.getElementById("root"); if (!root) return; const observer = new MutationObserver(() => { if (tryStart()) observer.disconnect(); }); - observer.observe(root, { childList: true, subtree: true }); + observer.observe(root, { attributes:true,childList: true, subtree: true,attributeFilter:["data-onboarding-ready"] }); return () => observer.disconnect(); }, [allFlag, canOpen, pageFlag, start]); diff --git a/apps/web/src/pages/analysis-page.tsx b/apps/web/src/pages/analysis-page.tsx index 4558a83..8b40244 100644 --- a/apps/web/src/pages/analysis-page.tsx +++ b/apps/web/src/pages/analysis-page.tsx @@ -13,8 +13,8 @@ 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")),eventPage:parsePageNumber(params.get("analysisEventPage")),eventPageSize:parsePageSize(params.get("analysisEventPageSize"))};} -function writeAnalysisUrlState(values:{month?:string;region?:string|null;customer?:string|null;eventMonth?:string|null;customerPage?:number|null;customerPageSize?:PageSize|null;eventPage?:number|null;eventPageSize?:PageSize|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,analysisEventPage:values.eventPage,analysisEventPageSize:values.eventPageSize}))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}`);} +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}`);} export function AnalysisPage(){ const initial=useRef(readAnalysisUrlState()).current; @@ -25,8 +25,8 @@ export function AnalysisPage(){ 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]); - function chooseProvince(province:AnalysisProvince){writeAnalysisUrlState({region:province.regionCode,customer:null,eventMonth:null,customerPage:null,customerPageSize:null,eventPage:null,eventPageSize:null});setSelectedProvince(province);} - return

地区与客户单位分析

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

+ 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

地区与客户单位分析

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

{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} @@ -67,11 +67,13 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str const[eventsRevision,setEventsRevision]=useState(0); const[eventPage,setEventPage]=useState(initial.eventPage); const[eventPageSize,setEventPageSize]=useState(initial.eventPageSize); + const customerSnapshotRef=useRef(initial.customerSnapshot); + const eventSnapshotRef=useRef(initial.eventSnapshot); 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)}); - 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??"省份客户加载失败");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:"省份客户加载失败");}); + const params=new URLSearchParams({level:"customers",regionCode:province.regionCode,month,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]); @@ -84,8 +86,8 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str useEffect(()=>{ setEvents(null);setEventsError("");if(!selectedCustomer||!selectedMonth)return; - const controller=new AbortController();const params=new URLSearchParams({level:"events",regionCode:province.regionCode,customerUnit:selectedCustomer.customerUnit,month:selectedMonth.month,page:String(eventPage),pageSize:String(eventPageSize)}); - 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??"订单事件加载失败");const lastPage=Math.max(1,Math.ceil(result.totalCount/eventPageSize));if(eventPage>lastPage){writeAnalysisUrlState({eventPage:lastPage},"replace");setEventPage(lastPage);return;}setEvents(result);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setEventsError(failure instanceof Error?failure.message:"订单事件加载失败");}); + const controller=new AbortController();const params=new URLSearchParams({level:"events",regionCode:province.regionCode,customerUnit:selectedCustomer.customerUnit,month:selectedMonth.month,page:String(eventPage),pageSize:String(eventPageSize)});if(eventSnapshotRef.current)params.set("snapshot",eventSnapshotRef.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??"订单事件加载失败");eventSnapshotRef.current=result.snapshot;writeAnalysisUrlState({eventSnapshot:result.snapshot},"replace");const lastPage=Math.max(1,Math.ceil(result.totalCount/eventPageSize));if(eventPage>lastPage){writeAnalysisUrlState({eventPage:lastPage},"replace");setEventPage(lastPage);return;}setEvents(result);}).catch((failure)=>{if(failure instanceof DOMException&&failure.name==="AbortError")return;setEventsError(failure instanceof Error?failure.message:"订单事件加载失败");}); return()=>controller.abort(); },[province.regionCode,selectedCustomer,selectedMonth,eventPage,eventPageSize,eventsRevision]); @@ -96,15 +98,15 @@ function AnalysisDrilldown({province,month}:{province:AnalysisProvince;month:str const monthMatched=parentMonth&&selectedCustomer&&formatMoney(parentMonth.totalAmount)===formatMoney(selectedCustomer.totalAmount)&&parentMonth.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,eventPage:null,eventPageSize:null});setCustomerPage(page);} - function changeCustomerPageSize(pageSize:PageSize){writeAnalysisUrlState({customer:null,eventMonth:null,customerPage:1,customerPageSize:pageSize,eventPage:null,eventPageSize:null});setCustomerPageSize(pageSize);setCustomerPage(1);} - function changeEventPage(page:number){writeAnalysisUrlState({eventPage:page,eventPageSize});setEventPage(page);} - function changeEventPageSize(pageSize:PageSize){writeAnalysisUrlState({eventPage:1,eventPageSize:pageSize});setEventPageSize(pageSize);setEventPage(1);} + function changeCustomerPage(page:number){writeAnalysisUrlState({customer:null,eventMonth:null,customerPage:page,customerSnapshot:customerSnapshotRef.current,eventPage:null,eventPageSize:null,eventSnapshot:null});setCustomerPage(page);} + function changeCustomerPageSize(pageSize:PageSize){customerSnapshotRef.current=null;eventSnapshotRef.current=null;writeAnalysisUrlState({customer:null,eventMonth:null,customerPage:1,customerPageSize:pageSize,customerSnapshot:null,eventPage:null,eventPageSize:null,eventSnapshot:null});setCustomerPageSize(pageSize);setCustomerPage(1);} + function changeEventPage(page:number){writeAnalysisUrlState({eventPage:page,eventPageSize,eventSnapshot:eventSnapshotRef.current});setEventPage(page);} + function changeEventPageSize(pageSize:PageSize){eventSnapshotRef.current=null;writeAnalysisUrlState({eventPage:1,eventPageSize:pageSize,eventSnapshot:null});setEventPageSize(pageSize);setEventPage(1);} return

{province.regionName}客户单位

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

- {customersError?

{customersError}

:null} + {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} - {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} + {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} + {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/audit-page.tsx b/apps/web/src/pages/audit-page.tsx index 4aa50a4..9d04599 100644 --- a/apps/web/src/pages/audit-page.tsx +++ b/apps/web/src/pages/audit-page.tsx @@ -6,22 +6,22 @@ import { Pagination, parsePageNumber, parsePageSize, type PageSize } from "../sh const emptyAuditFilters:AuditFilters={person:"",action:"",entityType:"",entityId:"",from:"",to:""}; const auditUrlKeys:Record={person:"auditPerson",action:"auditAction",entityType:"auditEntityType",entityId:"auditEntityId",from:"auditFrom",to:"auditTo"}; -function readAuditUrlState(){const params=new URLSearchParams(window.location.search);const filters={...emptyAuditFilters};for(const key of Object.keys(auditUrlKeys) as Array)filters[key]=params.get(auditUrlKeys[key])??"";return{filters,page:parsePageNumber(params.get("auditPage")),pageSize:parsePageSize(params.get("auditPageSize"))};} -function writeAuditUrlState(filters:AuditFilters,page:number,pageSize:PageSize,mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);for(const key of Object.keys(auditUrlKeys) as Array){if(filters[key])params.set(auditUrlKeys[key],filters[key]);else params.delete(auditUrlKeys[key]);}params.delete("auditCursor");params.set("auditPage",String(page));params.set("auditPageSize",String(pageSize));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} +function readAuditUrlState(){const params=new URLSearchParams(window.location.search);const filters={...emptyAuditFilters};for(const key of Object.keys(auditUrlKeys) as Array)filters[key]=params.get(auditUrlKeys[key])??"";return{filters,page:parsePageNumber(params.get("auditPage")),pageSize:parsePageSize(params.get("auditPageSize")),snapshot:params.get("auditSnapshot")};} +function writeAuditUrlState(filters:AuditFilters,page:number,pageSize:PageSize,mode:"push"|"replace"="push",snapshot?:string|null){const params=new URLSearchParams(window.location.search);for(const key of Object.keys(auditUrlKeys) as Array){if(filters[key])params.set(auditUrlKeys[key],filters[key]);else params.delete(auditUrlKeys[key]);}params.delete("auditCursor");if(snapshot===null)params.delete("auditSnapshot");else if(snapshot!==undefined)params.set("auditSnapshot",snapshot);params.set("auditPage",String(page));params.set("auditPageSize",String(pageSize));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} function auditDateTime(value:string):string{return new Intl.DateTimeFormat("zh-CN",{dateStyle:"medium",timeStyle:"medium",timeZone:"Asia/Shanghai"}).format(new Date(value));} function auditTimeParameter(value:string):string{return value?`${value}${value.length===16?":00":""}+08:00`:"";} export function AuditPage(){ const initial=useRef(readAuditUrlState()).current; - const[draft,setDraft]=useState(initial.filters);const[filters,setFilters]=useState(initial.filters);const[audits,setAudits]=useState([]);const[page,setPage]=useState(initial.page);const[pageSize,setPageSize]=useState(initial.pageSize);const[totalCount,setTotalCount]=useState(0);const[loading,setLoading]=useState(false);const[error,setError]=useState("");const[revision,setRevision]=useState(0); - useEffect(()=>{const restore=()=>{const state=readAuditUrlState();setDraft(state.filters);setFilters(state.filters);setPage(state.page);setPageSize(state.pageSize);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); + const[draft,setDraft]=useState(initial.filters);const[filters,setFilters]=useState(initial.filters);const[audits,setAudits]=useState([]);const[page,setPage]=useState(initial.page);const[pageSize,setPageSize]=useState(initial.pageSize);const[totalCount,setTotalCount]=useState(0);const[loading,setLoading]=useState(false);const[error,setError]=useState("");const[revision,setRevision]=useState(0);const snapshotRef=useRef(initial.snapshot); + useEffect(()=>{const restore=()=>{const state=readAuditUrlState();snapshotRef.current=state.snapshot;setDraft(state.filters);setFilters(state.filters);setPage(state.page);setPageSize(state.pageSize);setRevision((value)=>value+1);};window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore);},[]); const requestKey=JSON.stringify([filters,page,pageSize,revision]); - useEffect(()=>{const controller=new AbortController();const params=new URLSearchParams({page:String(page),pageSize:String(pageSize)});for(const key of ["person","action","entityType","entityId"] as const)if(filters[key])params.set(key,filters[key]);if(filters.from)params.set("from",auditTimeParameter(filters.from));if(filters.to)params.set("to",auditTimeParameter(filters.to));setLoading(true);setError("");apiFetch(`/api/audits?${params}`,{signal:controller.signal}).then(async(response)=>{const data=await readResponseJson<{audits?:AuditRow[];page?:number;pageSize?:PageSize;totalCount?:number;message?:string}>(response,"审计响应无效,请重试。");if(!response.ok)throw new Error(data.message??"审计查询失败");const total=data.totalCount??0;const lastPage=Math.max(1,Math.ceil(total/pageSize));if(page>lastPage){writeAuditUrlState(filters,lastPage,pageSize,"replace");setPage(lastPage);return;}setAudits(data.audits??[]);setTotalCount(total);}).catch((reason)=>{if(reason instanceof DOMException&&reason.name==="AbortError")return;setError(reason instanceof Error?reason.message:"审计查询失败");}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});return()=>controller.abort();},[requestKey]); + useEffect(()=>{const controller=new AbortController();const params=new URLSearchParams({page:String(page),pageSize:String(pageSize)});for(const key of ["person","action","entityType","entityId"] as const)if(filters[key])params.set(key,filters[key]);if(filters.from)params.set("from",auditTimeParameter(filters.from));if(filters.to)params.set("to",auditTimeParameter(filters.to));if(snapshotRef.current)params.set("snapshot",snapshotRef.current);setLoading(true);setError("");apiFetch(`/api/audits?${params}`,{signal:controller.signal}).then(async(response)=>{const data=await readResponseJson<{audits?:AuditRow[];page?:number;pageSize?:PageSize;totalCount?:number;snapshot?:string;message?:string}>(response,"审计响应无效,请重试。");if(!response.ok)throw new Error(data.message??"审计查询失败");if(data.snapshot){snapshotRef.current=data.snapshot;writeAuditUrlState(filters,page,pageSize,"replace",data.snapshot);}const total=data.totalCount??0;const lastPage=Math.max(1,Math.ceil(total/pageSize));if(page>lastPage){writeAuditUrlState(filters,lastPage,pageSize,"replace",snapshotRef.current);setPage(lastPage);return;}setAudits(data.audits??[]);setTotalCount(total);}).catch((reason)=>{if(reason instanceof DOMException&&reason.name==="AbortError")return;setError(reason instanceof Error?reason.message:"审计查询失败");}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});return()=>controller.abort();},[requestKey]); function update(key:keyof AuditFilters,value:string){setDraft((current)=>({...current,[key]:value}));} - function query(event:FormEvent){event.preventDefault();const next=Object.fromEntries(Object.entries(draft).map(([key,value])=>[key,value.trim()])) as AuditFilters;writeAuditUrlState(next,1,pageSize);setFilters(next);setPage(1);setRevision((value)=>value+1);} - function clear(){writeAuditUrlState(emptyAuditFilters,1,pageSize);setDraft(emptyAuditFilters);setFilters(emptyAuditFilters);setPage(1);setRevision((value)=>value+1);} - function changePage(nextPage:number){writeAuditUrlState(filters,nextPage,pageSize);setPage(nextPage);} - function changePageSize(nextPageSize:PageSize){writeAuditUrlState(filters,1,nextPageSize);setPageSize(nextPageSize);setPage(1);} - return

审计查询

按账号、动作、实体和时间追溯不可变记录;数据范围沿用当前角色权限

此页面只提供查询,不提供修改或删除入口;敏感凭据字段不会返回。

审计记录

{loading?"正在查询…":error?"查询失败":`本页 ${audits.length} 条记录`}
{error?

{error}

:null}
{!loading&&!error&&audits.length===0?:audits.map((row)=>)}
时间人员动作实体变更前变更后
没有符合当前权限和条件的审计记录。
{row.actorDisplayName??"系统"}{row.actorUsername??row.actorPersonId??"—"}{auditActionName(row.action)}{auditEntityName(row.entityType)}{row.entityId??"—"}{auditDataText(row.beforeData)}{auditDataText(row.afterData)}
; + function query(event:FormEvent){event.preventDefault();const next=Object.fromEntries(Object.entries(draft).map(([key,value])=>[key,value.trim()])) as AuditFilters;snapshotRef.current=null;writeAuditUrlState(next,1,pageSize,"push",null);setFilters(next);setPage(1);setRevision((value)=>value+1);} + function clear(){snapshotRef.current=null;writeAuditUrlState(emptyAuditFilters,1,pageSize,"push",null);setDraft(emptyAuditFilters);setFilters(emptyAuditFilters);setPage(1);setRevision((value)=>value+1);} + function changePage(nextPage:number){writeAuditUrlState(filters,nextPage,pageSize,"push",snapshotRef.current);setPage(nextPage);} + function changePageSize(nextPageSize:PageSize){snapshotRef.current=null;writeAuditUrlState(filters,1,nextPageSize,"push",null);setPageSize(nextPageSize);setPage(1);} + return

审计查询

按账号、动作、实体和时间追溯不可变记录;数据范围沿用当前角色权限

此页面只提供查询,不提供修改或删除入口;敏感凭据字段不会返回。

审计记录

{loading?"正在查询…":error?"查询失败":`本页 ${audits.length} 条记录`}
{error?

{error}

:null}
{!loading&&!error&&audits.length===0?:audits.map((row)=>)}
时间人员动作实体变更前变更后
没有符合当前权限和条件的审计记录。
{row.actorDisplayName??"系统"}{row.actorUsername??row.actorPersonId??"—"}{auditActionName(row.action)}{auditEntityName(row.entityType)}{row.entityId??"—"}{auditDataText(row.beforeData)}{auditDataText(row.afterData)}
; } diff --git a/apps/web/src/pages/orders-page.tsx b/apps/web/src/pages/orders-page.tsx index bad41b2..00848ce 100644 --- a/apps/web/src/pages/orders-page.tsx +++ b/apps/web/src/pages/orders-page.tsx @@ -6,8 +6,8 @@ import { Field, Modal, PaginatedCollection, Pagination, Status, parsePageNumber, const emptyOrderFilters:OrderFilters={search:"",month:"",status:"",salesperson:"",department:"",group:"",region:"",customerUnit:""}; const orderFilterUrlKeys:Record={search:"orderSearch",month:"orderMonth",status:"orderStatus",salesperson:"orderSalesperson",department:"orderDepartment",group:"orderGroup",region:"orderRegion",customerUnit:"orderCustomerUnit"}; -function readOrderUrlState(){const params=new URLSearchParams(window.location.search);const filters={...emptyOrderFilters};for(const key of Object.keys(orderFilterUrlKeys) as Array)filters[key]=params.get(orderFilterUrlKeys[key])??"";return{filters,page:parsePageNumber(params.get("orderPage")),pageSize:parsePageSize(params.get("orderPageSize"))};} -function writeOrderUrlState(filters:OrderFilters,page:number,pageSize:PageSize,mode:"push"|"replace"="push"){const params=new URLSearchParams(window.location.search);params.set("page","orders");for(const key of Object.keys(orderFilterUrlKeys) as Array){if(filters[key])params.set(orderFilterUrlKeys[key],filters[key]);else params.delete(orderFilterUrlKeys[key]);}params.delete("orderCursor");params.set("orderPage",String(page));params.set("orderPageSize",String(pageSize));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} +function readOrderUrlState(){const params=new URLSearchParams(window.location.search);const filters={...emptyOrderFilters};for(const key of Object.keys(orderFilterUrlKeys) as Array)filters[key]=params.get(orderFilterUrlKeys[key])??"";return{filters,page:parsePageNumber(params.get("orderPage")),pageSize:parsePageSize(params.get("orderPageSize")),snapshot:params.get("orderSnapshot")};} +function writeOrderUrlState(filters:OrderFilters,page:number,pageSize:PageSize,mode:"push"|"replace"="push",snapshot?:string|null){const params=new URLSearchParams(window.location.search);params.set("page","orders");for(const key of Object.keys(orderFilterUrlKeys) as Array){if(filters[key])params.set(orderFilterUrlKeys[key],filters[key]);else params.delete(orderFilterUrlKeys[key]);}params.delete("orderCursor");if(snapshot===null)params.delete("orderSnapshot");else if(snapshot!==undefined)params.set("orderSnapshot",snapshot);params.set("orderPage",String(page));params.set("orderPageSize",String(pageSize));window.history[mode==="push"?"pushState":"replaceState"]({},"",`${window.location.pathname}?${params.toString()}${window.location.hash}`);} export function OrdersPage({ user }: { user: User }) { const canEdit=user.capabilities.editPerformance; @@ -28,19 +28,20 @@ export function OrdersPage({ user }: { user: User }) { const [page,setPage]=useState(initialUrlState.page); const [pageSize,setPageSize]=useState(initialUrlState.pageSize); const [totalCount,setTotalCount]=useState(0); + const snapshotRef=useRef(initialUrlState.snapshot); const searchRef = useRef(null); const commitFilters=useCallback((value:OrderFilters,historyMode:"push"|"replace"="push",updateDraft=true)=>{ const normalized:OrderFilters={ search:value.search.trim(),month:value.month.trim(),status:value.status.trim(),salesperson:value.salesperson.trim(), department:value.department.trim(),group:value.group.trim(),region:value.region.trim(),customerUnit:value.customerUnit.trim(), }; - writeOrderUrlState(normalized,1,pageSize,historyMode); + snapshotRef.current=null;writeOrderUrlState(normalized,1,pageSize,historyMode,null); if(updateDraft)setDraftFilters(normalized); setPage(1); setCommittedFilters(normalized); },[pageSize]); useEffect(()=>{ - const restore=()=>{const restored=readOrderUrlState();setDraftFilters(restored.filters);setCommittedFilters(restored.filters);setPage(restored.page);setPageSize(restored.pageSize);}; + const restore=()=>{const restored=readOrderUrlState();snapshotRef.current=restored.snapshot;setDraftFilters(restored.filters);setCommittedFilters(restored.filters);setPage(restored.page);setPageSize(restored.pageSize);setRefreshVersion((value)=>value+1);}; window.addEventListener("popstate",restore);return()=>window.removeEventListener("popstate",restore); },[]); useEffect(()=>{ @@ -51,12 +52,12 @@ export function OrdersPage({ user }: { user: User }) { const filterRequestKey=JSON.stringify(committedFilters); useEffect(() => { const controller=new AbortController();setLoadState("loading");setLoadError(""); - const params=new URLSearchParams({page:String(page),pageSize:String(pageSize)});for(const [key,value] of Object.entries(committedFilters))if(value)params.set(key,value); + const params=new URLSearchParams({page:String(page),pageSize:String(pageSize)});for(const [key,value] of Object.entries(committedFilters))if(value)params.set(key,value);if(snapshotRef.current)params.set("snapshot",snapshotRef.current); apiFetch(`/api/performance/orders?${params.toString()}`,{signal:controller.signal}).then(async(response)=>{ - const data=await readResponseJson<{orders?:Order[];page?:number;pageSize?:PageSize;totalCount?:number;message?:string}>(response,"订单服务响应无效,请重试"); + const data=await readResponseJson<{orders?:Order[];page?:number;pageSize?:PageSize;totalCount?:number;snapshot?:string;message?:string}>(response,"订单服务响应无效,请重试"); if(response.status===403){setOrders([]);setTotalCount(0);setLoadState("forbidden");return;} if(!response.ok)throw new Error(data.message??"订单加载失败"); - const total=data.totalCount??0;const lastPage=Math.max(1,Math.ceil(total/pageSize));if(page>lastPage){writeOrderUrlState(committedFilters,lastPage,pageSize,"replace");setPage(lastPage);return;} + if(data.snapshot){snapshotRef.current=data.snapshot;writeOrderUrlState(committedFilters,page,pageSize,"replace",data.snapshot);}const total=data.totalCount??0;const lastPage=Math.max(1,Math.ceil(total/pageSize));if(page>lastPage){writeOrderUrlState(committedFilters,lastPage,pageSize,"replace",snapshotRef.current);setPage(lastPage);return;} setOrders(data.orders??[]); setTotalCount(total); setLoadState("ready"); @@ -65,16 +66,16 @@ export function OrdersPage({ user }: { user: User }) { }, [filterRequestKey,page,pageSize,refreshVersion]); const loading=loadState==="loading"; const hasFilters=Object.values(committedFilters).some(Boolean); - async function refresh() { setRefreshVersion((value)=>value+1); } - function changePage(nextPage:number){writeOrderUrlState(committedFilters,nextPage,pageSize);setPage(nextPage);} - function changePageSize(nextPageSize:PageSize){writeOrderUrlState(committedFilters,1,nextPageSize);setPageSize(nextPageSize);setPage(1);} + async function refresh() { snapshotRef.current=null;writeOrderUrlState(committedFilters,page,pageSize,"replace",null);setRefreshVersion((value)=>value+1); } + function changePage(nextPage:number){writeOrderUrlState(committedFilters,nextPage,pageSize,"push",snapshotRef.current);setPage(nextPage);} + function changePageSize(nextPageSize:PageSize){snapshotRef.current=null;writeOrderUrlState(committedFilters,1,nextPageSize,"push",null);setPageSize(nextPageSize);setPage(1);} function clearSearch(){setDraftFilters((current)=>({...current,search:""}));commitFilters({...committedFilters,search:""},"push",false);window.requestAnimationFrame(()=>searchRef.current?.focus());} function updateFilter(key:keyof OrderFilters,value:string){setDraftFilters((current)=>({...current,[key]:value}));} async function exportOrders(){if(exporting)return;const params=new URLSearchParams();for(const [key,value] of Object.entries(committedFilters))if(value)params.set(key,value);const query=params.toString();setExporting(true);setExportError("");try{await downloadApiFile(`/api/exports/performance.csv${query?`?${query}`:""}`);}catch(failure){setExportError(failure instanceof Error?failure.message:"导出失败,请重试。");}finally{setExporting(false);}} const emptyMessage=loadState==="forbidden"?"无可显示订单。":loadState==="error"?"订单加载失败,可重试。":hasFilters?"没有符合当前组合条件的订单。":"暂无订单数据。"; return

订单业绩

按订单编号维护不可变业绩事件;已入账记录不能覆盖或删除

{canExport||canEdit ?
{canExport?:null}{canEdit?<>:null}
: null}
{exportError?

{exportError}

:null} - {loadState==="error"?

{loadError}

:null} + {loadState==="error"?

{loadError}

:null} {loadState==="forbidden"?
当前账号没有订单查看权限。
:null} {!canEdit ?
当前角色仅可查看。只有销售助理及销售助理组长可以录入或调整业绩。
: null} diff --git a/apps/web/src/pages/overview-page.tsx b/apps/web/src/pages/overview-page.tsx index 554b33e..052a473 100644 --- a/apps/web/src/pages/overview-page.tsx +++ b/apps/web/src/pages/overview-page.tsx @@ -54,7 +54,7 @@ export function Overview({ canEdit, canExport, onEnterOrders }: { canEdit: boole const organizationDepartments=organizationDetails?("departments" in organizationDetails?organizationDetails.departments:[organizationDetails]):[]; const dashboardDescription=sales?"销售组织目标与不可变业绩事件":departments.length?"个人、部门目标与不可变业绩事件":personal?"个人目标与不可变业绩事件":"原始账本,不代表正式绩效结果"; async function exportOrders(){if(exporting)return;setExporting(true);setExportError("");try{await downloadApiFile("/api/exports/performance.csv");}catch(failure){setExportError(failure instanceof Error?failure.message:"导出失败,请重试。");}finally{setExporting(false);}} - return

业绩账本总览

{data ? `${data.month.replace("-", " 年 ")} 月 · ${dashboardDescription}` : loadError?"总览暂时不可用":"正在加载真实业绩账本…"}

{canExport?:null}{canEdit?:null}
+ return

业绩账本总览

{data ? `${data.month.replace("-", " 年 ")} 月 · ${dashboardDescription}` : loadError?"总览暂时不可用":"正在加载真实业绩账本…"}

{canExport?:null}{canEdit?:null}
{exportError?

{exportError}

:null} {loadError?

{loadError}

:null} {data?<>{personal?<>
openPersonalDetails("actual")} actionLabel="查看个人业绩构成"/>openPersonalDetails("gap")} actionLabel={personal.gapAmount===null?undefined:"查看个人差距构成"}/>
{showPersonalDetails?setShowPersonalDetails(false)}>{personalDetailsError?

{personalDetailsError}

:personalDetails?
{personalDetails.events.length?{(pageItems)=>
{pageItems.map((event)=>)}
订单客户不可变事件业务日 / 记账月金额方向状态 / 计入组织归属订单当前分析资料原因
{event.orderNo}{event.customerName}{eventTypeName(event.eventType)} · 第 {event.sequence} 条{event.occurredOn} / {event.accountingMonth}{event.deltaAmount.startsWith("-")?"负向":"正向"} {formatMoney(event.deltaAmount.startsWith("-")?event.deltaAmount.slice(1):event.deltaAmount)}{event.resultingLifecycleState?<>计入至 {formatMoney(event.resultingCountedAmount)}:"原始状态未推断"}{[event.departmentName,event.groupName].filter(Boolean).join(" / ")||"—"}分析维度待补齐{event.reason??"—"}
}
:

当前月份没有个人业绩事件。

}
:

正在读取个人业绩构成…

}
:null}:null} diff --git a/docs/specs/p1-product-closure.md b/docs/specs/p1-product-closure.md index 817d350..7a62b00 100644 --- a/docs/specs/p1-product-closure.md +++ b/docs/specs/p1-product-closure.md @@ -100,7 +100,7 @@ SampleFlow 已具备 P0 的权限、组织、不可变业绩事件、目标审 - 个人、小组、部门和销售范围使用各自层级的生效目标与事件范围。个人业绩不包含下属业绩;团队汇总使用事件发生时组织归属快照。 - 看板、正式报表和导出共享同一服务端汇总语义;前端不自行重算正式金额或绕过目标生效门禁。 - 指标穿透返回构成该指标的订单和不可变事件,并携带足够信息解释正向、负向、状态变化、组织归属和分析维度。 -- 订单、账号、审计和分析穿透使用服务端页码分页并返回精确 `totalCount`;其他已完整加载的数据表格与可增长业务清单在客户端分页。统一默认 20 条,可选 10/20/50/100 条,展示总条数、总页数、当前页、上一页/下一页和可点击页码。筛选或每页条数变化回到第 1 页,超出有效页范围时回到可用页。既有游标 API 保持向后兼容,但桌面 Web 不再以游标作为分页交互。 +- 订单、账号、审计和分析穿透使用服务端页码分页并返回精确 `totalCount`;其他已完整加载的数据表格与可增长业务清单在客户端分页。统一默认 20 条,可选 10/20/50/100 条,展示总条数、总页数、当前页、上一页/下一页和可点击页码。订单、审计和分析穿透由首次页码查询返回绑定用户、筛选与 cutoff 的不透明快照并写入 URL;后续翻页复用,筛选、每页条数或主动刷新时重建。超出有效页范围时回到可用页。既有游标 API 保持向后兼容,但桌面 Web 不再以游标作为分页交互。 - 自由搜索覆盖订单编号、客户姓名、客户单位和业务员;结构化筛选覆盖月份、订单状态、业务员、部门、小组、标准业务区域和客户单位。 - 列表与导出共享一个服务端查询合同、权限谓词和筛选解释。订单台账导出为带 UTF-8 BOM 的 CSV,每个匹配订单一行,固定包含订单编号、客户、客户单位、业务员、部门、小组、来源区域原文、标准业务区域、到样日期、当前营业额、当前计入金额和状态;用户来源文本防止 CSV 公式注入。导出覆盖全部匹配结果而不是当前页,并采用流式或分批读取,不能形成逐行查询。导出审计只保存筛选摘要、行数、结果状态、请求 ID 和完成文件的 SHA-256,不保存导出正文。 - 页面 URL 保存页面、筛选、排序和分页状态。优先复用浏览器 History API;只有出现其无法清晰承载的嵌套路由需求时才评估路由依赖。 diff --git a/handoff.md b/handoff.md index 97edafb..eeab3e1 100644 --- a/handoff.md +++ b/handoff.md @@ -2,7 +2,7 @@ > 更新时间:2026-09-02(America/Los_Angeles) > -> 当前结论:**当前版本中可由工程代理独立完成的 P1 代码与桌面 Web UI/UX 修复(包括全站统一页码分页)均已完成并合入 `main`。剩余 P1 全部是人工 Gate:真实数据业务 UAT 与公司服务器验收。当前没有真实数据授权,也没有公司服务器,绝不能把自动化、合成数据或本机 Docker 结果冒充这些验收。** +> 当前结论:**全仓复审新增的 6 个 P1 工程问题已建立为 #118—#123,并在现有 checkout 的 `codex/p1-audit-remediation` 分支完成修复与本地验收;尚待提交、PR CI、合并和远端关闭核验。之后剩余 P1 仍是人工 Gate:真实数据业务 UAT 与公司服务器验收。当前没有真实数据授权,也没有公司服务器,绝不能把自动化、合成数据或本机 Docker 结果冒充这些验收。** ## 1. 我们在做什么 @@ -36,6 +36,7 @@ SampleFlow 是销售业绩、目标、组织、账号和审计管理的桌面 We - 默认分支:`main` - UI/UX 审查交付:[PR #113](https://github.com/Eclipseic1848/SampleFlow/pull/113),已 squash merge。 - 统一页码分页交付:[Issue #116](https://github.com/Eclipseic1848/SampleFlow/issues/116) / [PR #117](https://github.com/Eclipseic1848/SampleFlow/pull/117)。 +- 本轮复审整改:[#118](https://github.com/Eclipseic1848/SampleFlow/issues/118)—[#123](https://github.com/Eclipseic1848/SampleFlow/issues/123),本地分支 `codex/p1-audit-remediation`,PR 尚未创建。 - PR #113 合并提交:`a1505add0191cb2bd4176bf01c993c523569ae27` - PR required check:run `33591249761`,SUCCESS,7分08秒。 - 合并后 `main` quality gate:run `33591736403`,SUCCESS,5分29秒。 @@ -60,7 +61,18 @@ docker compose -f docker-compose.dev.yml ps ### 2.2 当前 P1 -当前没有 `ready-for-agent` 的 P1。以下 Issue 必须保持 `ready-for-human`,直到真实条件成立: +本轮 `ready-for-agent` P1 已完成本地实现,待 PR 合并自动关闭: + +| Issue | 本地结果 | +| --- | --- | +| #118 金额精度与账本状态不变量 | 统一 API 两位小数校验;首次计入先按分舍入;迁移 025 在数据库层约束状态与金额组合 | +| #119 读路径表级锁 | 删除账号、审计和分析查询中的 `LOCK TABLE ... SHARE`;回归证明读取不等待在途写入 | +| #120 超范围页总数 | 订单和审计用单语句 CTE 同时返回精确 `totalCount` 与空页 | +| #121 页码稳定快照 | 订单、审计、分析页码查询返回绑定用户与筛选的 cutoff 快照;URL 跨页、刷新和历史恢复复用 | +| #122 总览异步引导 | 总览数据或失败状态就绪后才自动开始,避免首次引导缺少异步目标步骤 | +| #123 分页合同文档 | `README.md`、`UX-CONTRACT.md` 与 P1 规格同步真实分页和快照语义 | + +以下 Issue 必须保持 `ready-for-human`,直到真实条件成立: | Issue | 当前事实 | 关闭条件 | | --- | --- | --- | @@ -88,6 +100,7 @@ https://github.com/Eclipseic1848/SampleFlow/issues/65#issuecomment-5504522089 | #101 搜索与历史 | 账号、订单、审计搜索/分页与 URL、刷新、前进后退一致;冷启动深链不会伪造“上一页” | | #112 后台信息架构 | 创建账号明确“新建人员/绑定已有人员”和多角色;组织按部门/小组、当前/历史分组并支持搜索;文本域可垂直调整 | | #116 统一页码分页 | 所有数据表格与可增长业务清单默认 20 条,可选 10/20/50/100 条;支持总数、当前页/总页数、可点击页码、上一页和下一页;服务端大列表返回 `page`/`pageSize`/`totalCount`,旧游标调用保持兼容 | +| #118—#123 全仓复审整改 | 金额边界和数据库不变量、无阻塞读取、超范围精确总数、稳定页码快照、异步引导与权威文档已完成本地修复;等待 PR/CI/合并 | ### 3.2 既有 P1 工程能力 @@ -100,20 +113,21 @@ https://github.com/Eclipseic1848/SampleFlow/issues/65#issuecomment-5504522089 - 数据库迁移哈希、bigint 字符串边界、认证与限速、CSRF/Origin、最小权限容器、备份恢复、结构化日志与指标。 - 受保护下载、稳定游标、筛选 URL、写入不确定状态恢复和失败真相。 - 统一分页控件与 URL 恢复:账号、订单、审计和分析穿透使用服务端页码,其余已加载业务集合使用共享客户端分页;筛选或每页条数变化回到第 1 页。 -- 当前迁移为 `001`—`024`;不要改写已应用迁移。 +- 当前迁移为 `001`—`025`;不要改写已应用迁移。 ## 4. 验收证据 最终产品源码与测试变更的证据: -- 本地 API:158/158。 -- 本地 Web Playwright:49/49。 +- 本地 API:160/160。 +- 本地 Web Playwright:50/50;最后的快照重试微调另有订单 1/1、审计 1/1 定向通过。 - CI 竞态修复相关用例:12/12。 - 容器契约:11/11。 - API/Web 类型检查和生产构建:通过。 - 分页合同回归覆盖 10/20/50/100 条、页码直达、总数、筛选/URL 恢复、非法每页条数及游标/页码互斥。 - 隔离 Compose:首装、真实升级、ready/smoke、代理大请求、备份、新库恢复、最小权限、恢复库登录全部通过。 -- `npm audit --omit=dev`:0 vulnerabilities。 +- 本轮隔离 Compose 明确验证迁移 024 到 025 的真实升级并通过全部运行验收,且只清理本轮 `sfsecurity*` 资源。 +- `npm audit --omit=dev --package-lock-only`:0 vulnerabilities。本机 Node 22/npm 10 对已安装树返回 `Invalid package tree`;项目要求 Node 24,最终以干净 CI 的 `npm ci` 后标准审计为准。 - `docker compose --env-file .env.example config --quiet`:通过。 - PR #113 第二轮 required check:SUCCESS。 - 合并后 `main` quality gate:SUCCESS。 @@ -129,7 +143,7 @@ https://github.com/Eclipseic1848/SampleFlow/issues/65#issuecomment-5504522089 ## 5. 当前卡点 -没有剩余可由工程代理自行完成的 P1 代码任务。唯一卡点: +本轮代码与本地验收完成;当前工程步骤是提交、推送、创建 PR、等待 required check、合并并核验 #118—#123 关闭。完成后只剩人工 Gate: 1. #63:缺真实数据授权、来源哈希、权威组织/区域映射、批准的隔离环境、业务验收人和签字。 2. #64:缺公司服务器、域名/HTTPS、正式秘密、备份存储、监控、RPO/RTO、维护窗口和运维责任人。 @@ -138,6 +152,13 @@ https://github.com/Eclipseic1848/SampleFlow/issues/65#issuecomment-5504522089 ## 6. 下一步计划 +### 6.0 当前分支收口 + +1. 仅暂存本轮明确文件,提交并推送 `codex/p1-audit-remediation`。 +2. 创建包含 `Closes #118`—`Closes #123` 的 PR。 +3. required check 通过后 squash merge;核验 6 个 Issue 自动关闭和最新 `main` CI。 +4. 用最终 PR、merge SHA、run ID 和远端 P1 状态再次刷新本交接文档。 + ### 6.1 当真实数据条件齐全时 1. 先获得明确授权,冻结来源文件 SHA-256、字段映射、组织/区域映射、批准人、目标验收库、备份与恢复路径。 @@ -200,6 +221,8 @@ https://github.com/Eclipseic1848/SampleFlow/issues/65#issuecomment-5504522089 19. **GitHub 写入结果不确定时先查远端。** 遇到 TLS/EOF 先查 Issue、PR、分支和 run,不要重复写入。 20. **页码 API 与旧游标 API 不得混用。** 桌面 Web 使用 `page`/`pageSize`/`totalCount`;兼容调用可继续使用游标,但单个请求同时提交两种模式必须返回 400。 21. **本机代理可能只让部分 HTTPS 客户端成功。** 2026-09-02 曾出现 GitHub API 可访问但 Git、Docker Hub 和 npm 间歇 TLS/EOF;写入失败先查远端,Git 必要时一次性指定本机代理、HTTP/1.1、TLS 1.2 与 OpenSSL,禁止关闭证书校验。 +22. **金额两位小数校验不能使用固定极小误差。** 接近 `99_999_999_999.99` 时 IEEE-754 乘 100 误差可超过 `1e-7`;必须保留按数值规模计算的容差及大金额回归用例。 +23. **页码快照不能靠表级共享锁。** 用不可变递增 cutoff 冻结集合;令牌必须绑定当前用户和查询条件,筛选、每页条数、主动刷新或失败重试时重建。 ## 9. 新会话接手顺序