diff --git a/.env.server.example b/.env.server.example index 5470e05a..7f0c4de0 100644 --- a/.env.server.example +++ b/.env.server.example @@ -72,12 +72,6 @@ LOG_RETENTION_DAYS=30 LOG_MAX_FILE_SIZE_MB=20 LOG_MAX_FILES=5 -# 数据库敏感报文/事件保留策略(天)。清理任务每天执行一次。 -RAW_PAYLOAD_RETENTION_DAYS=180 -TASK_EVENT_RETENTION_DAYS=180 -WEBHOOK_EVENT_RETENTION_DAYS=180 -AUDIT_LOG_RETENTION_DAYS=365 -WORK_ORDER_EVENT_RETENTION_DAYS=180 # 已处理成本异常保留天数,超期搬入 cost_exceptions_archive(open 行不归档) COST_EXCEPTION_RETENTION_DAYS=60 diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 437296df..a41d38fb 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -24,12 +24,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { }, retention: { - // 原始报文只保留追溯所需窗口,清理后保留订单/事件摘要。 - rawPayloadDays: 180, - taskEventDays: 180, - webhookEventDays: 180, - auditLogDays: 365, - workOrderEventDays: 180, // 已处理的成本异常保留 60 天,之后搬进 cost_exceptions_archive;open 行不归档。 costExceptionDays: 60, }, diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index a53db9de..4ac95bd7 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -96,10 +96,6 @@ function createRuntimeConfig(): RuntimeConfig { retentionDays: 30, }, retention: { - rawPayloadDays: 180, - taskEventDays: 180, - webhookEventDays: 180, - auditLogDays: 365, costExceptionDays: 60, }, database: { diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index d3502261..6410c2df 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -30,11 +30,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']), integerEnv('LOG_MAX_FILE_SIZE_MB', ['logging', 'maxFileSizeMb']), integerEnv('LOG_MAX_FILES', ['logging', 'maxFiles']), - integerEnv('RAW_PAYLOAD_RETENTION_DAYS', ['retention', 'rawPayloadDays']), - integerEnv('TASK_EVENT_RETENTION_DAYS', ['retention', 'taskEventDays']), - integerEnv('WEBHOOK_EVENT_RETENTION_DAYS', ['retention', 'webhookEventDays']), - integerEnv('AUDIT_LOG_RETENTION_DAYS', ['retention', 'auditLogDays']), - integerEnv('WORK_ORDER_EVENT_RETENTION_DAYS', ['retention', 'workOrderEventDays']), integerEnv('COST_EXCEPTION_RETENTION_DAYS', ['retention', 'costExceptionDays']), stringEnv('DATABASE_URL', ['database', 'url']), booleanEnv('DATABASE_SSL', ['database', 'ssl']), diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index 59fa9223..6b06989b 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -160,10 +160,6 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo integrationLevel: 'warn', }, retention: { - rawPayloadDays: 180, - taskEventDays: 180, - webhookEventDays: 180, - auditLogDays: 365, costExceptionDays: 60, }, database: { diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index a7d16176..2688fb82 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -82,12 +82,6 @@ export function validateRuntimeConfig( max: 100, }) if (config.retention) { - requireInteger(issues, 'retention.rawPayloadDays', config.retention.rawPayloadDays, { min: 1 }) - requireInteger(issues, 'retention.taskEventDays', config.retention.taskEventDays, { min: 1 }) - requireInteger(issues, 'retention.webhookEventDays', config.retention.webhookEventDays, { - min: 1, - }) - requireInteger(issues, 'retention.auditLogDays', config.retention.auditLogDays, { min: 1 }) requireInteger(issues, 'retention.costExceptionDays', config.retention.costExceptionDays, { min: 1, }) diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts index 80d972a1..44064349 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -64,13 +64,6 @@ export async function runDatabaseMigrations() { console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`) - // 分支联调库可能已应用过 main 的 147-158,而本分支的寄卖迁移编号是 147-152; - // node-pg-migrate 的 checkOrder 会按名称逐位比较并直接抛错,导致服务起不来。 - // 生产保持严格顺序;开发/测试允许顺序放宽(迁移文件本身仍需幂等)。 - const strictMigrationOrder = - String(process.env.DB_MIGRATION_STRICT_ORDER || '').trim() === '1' || - String(process.env.NODE_ENV || '').trim() === 'production' - await runner({ databaseUrl: createMigrationDatabaseConfig(), dir: MIGRATIONS_DIR, @@ -79,7 +72,11 @@ export async function runDatabaseMigrations() { migrationsSchema: 'public', schema: 'public', singleTransaction: true, - checkOrder: strictMigrationOrder, + // 联调/生产库可能存在「已应用、但当前分支已删除」的历史迁移记录 + // (如 153-155 semi_auto_*)。node-pg-migrate 的严格顺序检查会把这类 + // 残留记录判成乱序并直接拒绝启动;本地迁移文件本身只增不改, + // 这里关闭顺序检查,仍会按文件名顺序执行所有未应用迁移。 + checkOrder: false, logger: { debug: () => undefined, info: (message) => { diff --git a/apps/backend/src/db/migrations/152_order_source_facts_immutable.sql b/apps/backend/src/db/migrations/152_order_source_facts_immutable.sql new file mode 100644 index 00000000..aa101765 --- /dev/null +++ b/apps/backend/src/db/migrations/152_order_source_facts_immutable.sql @@ -0,0 +1,57 @@ +-- 152_order_source_facts_immutable.sql +-- +-- 永久保留平台订单事实: +-- 1. 每次来源同步追加完整订单快照,供审计和后续重放; +-- 2. 快照只允许插入,不允许应用更新或删除; +-- 3. orders.raw_payload_json 由应用写入口保证首次写入后不再覆盖。 + +CREATE TABLE IF NOT EXISTS order_source_snapshots ( + id BIGSERIAL PRIMARY KEY, + order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE RESTRICT, + provider TEXT NOT NULL, + platform TEXT NOT NULL, + shop_id TEXT NOT NULL DEFAULT '', + platform_order_id TEXT NOT NULL, + source_label TEXT NOT NULL DEFAULT '', + payload_json JSONB NOT NULL, + payload_sha256 TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_order_source_snapshots_payload_sha256 + CHECK (payload_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT uq_order_source_snapshots_payload + UNIQUE (order_id, payload_sha256) +); + +CREATE INDEX IF NOT EXISTS idx_order_source_snapshots_order + ON order_source_snapshots(order_id, recorded_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_order_source_snapshots_platform_order + ON order_source_snapshots(provider, platform, platform_order_id, recorded_at DESC); + +COMMENT ON TABLE order_source_snapshots IS + '平台订单同步的不可变原始快照;重复同步只追加,不覆盖订单主表原始报文'; +COMMENT ON COLUMN order_source_snapshots.payload_json IS + '平台来源事件、原始报文与订单项完整快照'; +COMMENT ON COLUMN order_source_snapshots.payload_sha256 IS + '完整快照内容 SHA-256,用于同一订单重复消息幂等'; + +CREATE OR REPLACE FUNCTION reject_order_source_snapshot_mutation() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF current_setting('app.allow_order_fact_repair', TRUE) = 'on' THEN + RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END; + END IF; + + RAISE EXCEPTION 'order_source_snapshots is append-only' + USING ERRCODE = '23514'; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_source_snapshots_append_only + ON order_source_snapshots; +CREATE TRIGGER trg_order_source_snapshots_append_only +BEFORE UPDATE OR DELETE ON order_source_snapshots +FOR EACH ROW +EXECUTE FUNCTION reject_order_source_snapshot_mutation(); diff --git a/apps/backend/src/db/migrations/158_consignment_worker_name_sync.sql b/apps/backend/src/db/migrations/158_consignment_worker_name_sync.sql new file mode 100644 index 00000000..13dcb64d --- /dev/null +++ b/apps/backend/src/db/migrations/158_consignment_worker_name_sync.sql @@ -0,0 +1,75 @@ +-- 158_consignment_worker_name_sync.sql —— 打手改名后同步寄售登记人快照。 +-- +-- 背景:consignment_inventories.worker_name / consignment_submissions.worker_name +-- 是入库/提报时的名字快照。worker_users.display_name 改名后不会自动更新, +-- 后台寄售列表仍显示旧名,按新名搜索也搜不到。 +-- +-- 这里做两件事: +-- 1. 用当前昵称/电话回填存量快照; +-- 2. worker_users 改名(display_name/username/phone)时自动同步这两张表, +-- 之后新改名无需逐表手工处理。 +-- +-- 历史工单 material_json.consignment.workerName 不做批量改写: +-- 后台展示端按 consignment_inventories 的实时关联覆盖,保留历史 JSON 的登记痕迹。 + +UPDATE consignment_inventories ci +SET + worker_name = COALESCE(NULLIF(wu.display_name, ''), wu.username), + worker_phone = wu.phone +FROM worker_users wu +WHERE ci.worker_id = wu.id + AND ( + ci.worker_name IS DISTINCT FROM COALESCE(NULLIF(wu.display_name, ''), wu.username) + OR ci.worker_phone IS DISTINCT FROM wu.phone + ); + +UPDATE consignment_submissions cs +SET + worker_name = COALESCE(NULLIF(wu.display_name, ''), wu.username), + worker_phone = wu.phone +FROM worker_users wu +WHERE cs.worker_id = wu.id + AND ( + cs.worker_name IS DISTINCT FROM COALESCE(NULLIF(wu.display_name, ''), wu.username) + OR cs.worker_phone IS DISTINCT FROM wu.phone + ); + +CREATE OR REPLACE FUNCTION sync_worker_name_to_consignment_snapshots() +RETURNS TRIGGER AS $$ +DECLARE + next_worker_name TEXT := COALESCE(NULLIF(NEW.display_name, ''), NEW.username); +BEGIN + IF NEW.display_name IS DISTINCT FROM OLD.display_name + OR NEW.username IS DISTINCT FROM OLD.username + OR NEW.phone IS DISTINCT FROM OLD.phone + THEN + UPDATE consignment_inventories + SET + worker_name = next_worker_name, + worker_phone = NEW.phone + WHERE worker_id = NEW.id + AND ( + worker_name IS DISTINCT FROM next_worker_name + OR worker_phone IS DISTINCT FROM NEW.phone + ); + + UPDATE consignment_submissions + SET + worker_name = next_worker_name, + worker_phone = NEW.phone + WHERE worker_id = NEW.id + AND ( + worker_name IS DISTINCT FROM next_worker_name + OR worker_phone IS DISTINCT FROM NEW.phone + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_worker_users_consignment_name_sync ON worker_users; +CREATE TRIGGER trg_worker_users_consignment_name_sync +AFTER UPDATE OF display_name, username, phone ON worker_users +FOR EACH ROW +EXECUTE FUNCTION sync_worker_name_to_consignment_snapshots(); diff --git a/apps/backend/src/repositories/order-item-repo.test.ts b/apps/backend/src/repositories/order-item-repo.test.ts index 50a7d0ca..f9e75072 100644 --- a/apps/backend/src/repositories/order-item-repo.test.ts +++ b/apps/backend/src/repositories/order-item-repo.test.ts @@ -38,7 +38,7 @@ test('resolveOrderItemSyncPlan preserves matching order item ids across repeated }, ]) assert.deepEqual(plan.creates, []) - assert.deepEqual(plan.deletes, []) + assert.deepEqual(plan.surplusExistingIds, []) }) test('resolveOrderItemSyncPlan matches by identity before falling back to position', () => { @@ -74,10 +74,10 @@ test('resolveOrderItemSyncPlan matches by identity before falling back to positi [22, 21], ) assert.deepEqual(plan.creates, []) - assert.deepEqual(plan.deletes, []) + assert.deepEqual(plan.surplusExistingIds, []) }) -test('resolveOrderItemSyncPlan keeps surplus existing ids for conditional cleanup instead of recreating everything', () => { +test('resolveOrderItemSyncPlan reports surplus existing items without deleting them', () => { const existingItems = [ { id: 31, sku_code: 'sku-a', sku_name: '礼包 A', quantity: 1 }, { id: 32, sku_code: 'sku-b', sku_name: '礼包 B', quantity: 1 }, @@ -101,7 +101,7 @@ test('resolveOrderItemSyncPlan keeps surplus existing ids for conditional cleanu [31], ) assert.deepEqual(plan.creates, []) - assert.deepEqual(plan.deletes, [32]) + assert.deepEqual(plan.surplusExistingIds, [32]) }) test('已分配订单重推时冻结原渠道决策与中文内部 SKU', () => { @@ -155,7 +155,7 @@ test('已分配订单重推时冻结原渠道决策与中文内部 SKU', () => { }) }) -test('已分配订单重推写库时不会用平台新 SKU 覆盖冻结值', async () => { +test('平台订单重推只追加新订单项,不更新或删除已有原始订单项', async () => { const existing = { id: '41', order_id: 7, @@ -184,24 +184,37 @@ test('已分配订单重推写库时不会用平台新 SKU 覆盖冻结值', asy } const updateParams: unknown[][] = [] let selectCount = 0 + let writeSql = '' const executor = async (sql: string, params: unknown[] = []) => { if (sql.includes('SELECT *') && sql.includes('FROM v_order_item_current')) { selectCount += 1 - return { rows: selectCount === 1 ? [existing] : [] } + return { rows: selectCount === 1 ? [existing] : [{ ...existing, id: 42 }] } } - if (sql.includes('UPDATE order_items')) { + if (sql.includes('INSERT INTO order_items')) { updateParams.push(params) + writeSql += sql return { rows: [], rowCount: 1 } } + if (/UPDATE\s+order_items|DELETE\s+FROM\s+order_items/i.test(sql)) { + throw new Error(`permanent source item must not be mutated: ${sql}`) + } throw new Error(`unexpected SQL: ${sql}`) } - await replaceOrderItemsWithExecutor(executor as never, 7, [incoming]) + await replaceOrderItemsWithExecutor(executor as never, 7, [ + incoming, + { + ...incoming, + skuCode: 'platform-extra', + skuName: '平台新增商品', + }, + ]) assert.equal(updateParams.length, 1) - assert.equal(updateParams[0]?.[0], 'lucky_coin_x2') - assert.equal(updateParams[0]?.[1], '幸运币2个') + assert.ok(writeSql.includes('INSERT INTO order_items')) + assert.equal(updateParams[0]?.[1], 'platform-extra') + assert.match(String(updateParams[0]?.[2]), /平台新增商品/) }) test('所有替换订单项入口都在同一事务内分摊订单收入', async () => { diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 0fd3f8c2..ed139a97 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -13,7 +13,7 @@ type OrderItemSyncPlan = { item: OrderItemReplaceInput }> creates: OrderItemReplaceInput[] - deletes: number[] + surplusExistingIds: number[] } type OrderItemIdentityInput = Partial & Partial @@ -100,36 +100,6 @@ export async function replaceOrderItemsWithExecutor( ): Promise { const existingItems = await listOrderItemsByOrderIdWithExecutor(executor, orderId) const plan = resolveOrderItemSyncPlan(existingItems, items) - const existingById = new Map(existingItems.map((item) => [Number(item.id), item])) - - for (const update of plan.updates) { - const effectiveItem = preserveAssignedOrderItemState( - existingById.get(Number(update.orderItemId)), - update.item, - ) - await executor( - ` - UPDATE order_items - SET - sku_code = $1, - sku_name = $2, - quantity = $3, - spec_json = $4::jsonb, - item_snapshot_json = $5::jsonb, - updated_at = $6 - WHERE id = $7 - `, - [ - effectiveItem.skuCode, - effectiveItem.skuName, - update.item.quantity, - safeJsonStringify(effectiveItem.specJson || '{}'), - safeJsonStringify(effectiveItem.itemSnapshotJson || effectiveItem.specJson || '{}'), - effectiveItem.updatedAt, - update.orderItemId, - ], - ) - } for (const create of plan.creates) { await executor( @@ -158,14 +128,6 @@ export async function replaceOrderItemsWithExecutor( ) } - if (plan.deletes.length > 0) { - const deletableIds = await listDeletableOrderItemIdsWithExecutor(executor, plan.deletes) - - if (deletableIds.length > 0) { - await executor('DELETE FROM order_items WHERE id = ANY($1::bigint[])', [deletableIds]) - } - } - return listOrderItemsByOrderIdWithExecutor(executor, orderId) } @@ -280,7 +242,9 @@ export function resolveOrderItemSyncPlan( return { updates, creates, - deletes: unmatchedExisting.map((item) => Number(item.id)).filter((value) => value > 0), + surplusExistingIds: unmatchedExisting + .map((item) => Number(item.id)) + .filter((value) => value > 0), } } @@ -301,34 +265,6 @@ function buildOrderItemIdentity(item: OrderItemIdentityInput): string { ].join('::') } -async function listDeletableOrderItemIdsWithExecutor( - executor: QueryExecutor, - orderItemIds: number[], -): Promise { - const normalizedIds = Array.isArray(orderItemIds) - ? orderItemIds.map((value) => Number(value)).filter((value) => value > 0) - : [] - - if (normalizedIds.length === 0) { - return [] - } - - const result = await executor( - ` - SELECT oi.id - FROM v_order_item_current oi - LEFT JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id - WHERE oi.id = ANY($1::bigint[]) - GROUP BY oi.id - HAVING COUNT(ft.id) = 0 - ORDER BY oi.id ASC - `, - [normalizedIds], - ) - - return result.rows.map((row) => Number(row.id)).filter((value) => value > 0) -} - async function listOrderItemsByOrderIdWithExecutor( executor: QueryExecutor, orderId: number | string, diff --git a/apps/backend/src/repositories/order-repo.test.ts b/apps/backend/src/repositories/order-repo.test.ts index dd183799..33195cae 100644 --- a/apps/backend/src/repositories/order-repo.test.ts +++ b/apps/backend/src/repositories/order-repo.test.ts @@ -1,8 +1,18 @@ import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' import test from 'node:test' import { listOrdersWithExecutor, type OrderQueryExecutor } from './order-repo.js' +test('订单更新永不覆盖首次写入的平台原始报文', async () => { + const source = await readFile(new URL('./order-repo.ts', import.meta.url), 'utf8') + const updateSource = source.slice( + source.indexOf('export async function updateOrderWithExecutor'), + source.indexOf('export async function getOrderById'), + ) + assert.doesNotMatch(updateSource, /raw_payload_json\s*=/) +}) + test('订单列表综合筛选下推到总数和分页查询', async () => { const calls: Array<{ sql: string; params: unknown[] }> = [] const executor = createExecutor(calls, 3) diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 2874a936..b0765c1e 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -191,10 +191,9 @@ export async function updateOrderWithExecutor( receiver_contact = $9, total_amount = $10, currency = $11, - raw_payload_json = $12::jsonb, - paid_at = $13, - updated_at = $14 - WHERE id = $15 + paid_at = $12, + updated_at = $13 + WHERE id = $14 RETURNING * `, [ @@ -209,7 +208,6 @@ export async function updateOrderWithExecutor( sanitizeWellFormedUnicode(input.receiverContact), input.totalAmount, input.currency, - safeJsonStringify(input.rawPayloadJson || '{}'), input.paidAt || null, input.updatedAt, Number(orderId), diff --git a/apps/backend/src/repositories/order-source-facts.integration.test.ts b/apps/backend/src/repositories/order-source-facts.integration.test.ts new file mode 100644 index 00000000..138339f2 --- /dev/null +++ b/apps/backend/src/repositories/order-source-facts.integration.test.ts @@ -0,0 +1,137 @@ +/** + * 平台订单原始事实不可变集成测试(需要真实 PostgreSQL)。 + * + * ORDER_SOURCE_FACTS_TEST_DATABASE_URL=postgres://... \ + * npx tsx --test src/repositories/order-source-facts.integration.test.ts + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String(process.env.ORDER_SOURCE_FACTS_TEST_DATABASE_URL || '').trim() + +test( + '平台重推只追加原始事实,不覆盖订单报文、不删除已有订单项', + { skip: !TEST_DATABASE_URL ? '未设置 ORDER_SOURCE_FACTS_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query, withTransaction } = await import('../db/client.js') + const { appendOrderSourceSnapshotWithExecutor } = + await import('./order-source-snapshot-repo.js') + const { replaceOrderItemsWithExecutor } = await import('./order-item-repo.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + let orderId = 0 + + try { + const order = await query<{ id: number }>( + ` + INSERT INTO orders ( + provider, platform, shop_id, shop_name, platform_order_id, + order_status, pay_status, total_amount, raw_payload_json, created_at, updated_at + ) VALUES ( + '91kaquan', 'kuaishou-91', $1, '原始事实测试店', $2, + 'paid', 'paid', 1000, $3::jsonb, NOW(), NOW() + ) RETURNING id + `, + [ + `source-facts-shop-${suffix}`, + `SOURCE-FACTS-${suffix}`, + JSON.stringify({ orderNo: `SOURCE-FACTS-${suffix}`, version: 1 }), + ], + ) + orderId = Number(order.rows[0]?.id || 0) + assert.ok(orderId > 0) + + const item = await query<{ id: number }>( + ` + INSERT INTO order_items ( + order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json, + revenue_amount_fen, revenue_basis, created_at, updated_at + ) VALUES ( + $1, 'sku-old', '原始商品', 1, '{}'::jsonb, '{}'::jsonb, + 1000, 'order_total', NOW(), NOW() + ) RETURNING id + `, + [orderId], + ) + const originalItemId = Number(item.rows[0]?.id || 0) + assert.ok(originalItemId > 0) + + const snapshotInput = { + orderId, + provider: '91kaquan', + platform: 'kuaishou-91', + shopId: `source-facts-shop-${suffix}`, + platformOrderId: `SOURCE-FACTS-${suffix}`, + sourceLabel: 'integration-test', + payload: { orderNo: `SOURCE-FACTS-${suffix}`, version: 1, items: ['sku-old'] }, + recordedAt: new Date().toISOString(), + } + assert.equal(await appendOrderSourceSnapshotWithExecutor(query, snapshotInput), true) + assert.equal(await appendOrderSourceSnapshotWithExecutor(query, snapshotInput), false) + + const snapshots = await query<{ count: number }>( + 'SELECT COUNT(*)::int AS count FROM order_source_snapshots WHERE order_id = $1', + [orderId], + ) + assert.equal(Number(snapshots.rows[0]?.count), 1) + + await replaceOrderItemsWithExecutor(query, orderId, [ + { + skuCode: 'sku-new', + skuName: '平台新商品', + quantity: 1, + specJson: '{}', + itemSnapshotJson: '{}', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + skuCode: 'sku-extra', + skuName: '平台新增商品', + quantity: 1, + specJson: '{}', + itemSnapshotJson: '{}', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const items = await query<{ original_exists: boolean; total: number }>( + ` + SELECT + EXISTS (SELECT 1 FROM order_items WHERE id = $1) AS original_exists, + (SELECT COUNT(*)::int FROM order_items WHERE order_id = $2) AS total + `, + [originalItemId, orderId], + ) + assert.equal(items.rows[0]?.original_exists, true) + assert.equal(Number(items.rows[0]?.total), 2) + + await replaceOrderItemsWithExecutor(query, orderId, [ + { + skuCode: 'sku-new', + skuName: '平台新商品', + quantity: 1, + specJson: '{}', + itemSnapshotJson: '{}', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const afterShrink = await query<{ total: number }>( + 'SELECT COUNT(*)::int AS total FROM order_items WHERE order_id = $1', + [orderId], + ) + assert.equal(Number(afterShrink.rows[0]?.total), 2) + } finally { + if (orderId > 0) { + await withTransaction(async (client) => { + await client.query(`SET LOCAL app.allow_order_fact_repair = 'on'`) + await client.query('DELETE FROM order_source_snapshots WHERE order_id = $1', [orderId]) + await client.query('DELETE FROM orders WHERE id = $1', [orderId]) + }) + } + await closeDb() + } + }, +) diff --git a/apps/backend/src/repositories/order-source-snapshot-repo.test.ts b/apps/backend/src/repositories/order-source-snapshot-repo.test.ts new file mode 100644 index 00000000..2fbcc6a7 --- /dev/null +++ b/apps/backend/src/repositories/order-source-snapshot-repo.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + appendOrderSourceSnapshotWithExecutor, + orderSourceSnapshotHash, +} from './order-source-snapshot-repo.js' + +test('订单来源快照哈希稳定且能区分不同报文', () => { + const first = orderSourceSnapshotHash({ orderNo: 'A-1', status: 'paid' }) + assert.equal(first, orderSourceSnapshotHash({ orderNo: 'A-1', status: 'paid' })) + assert.notEqual(first, orderSourceSnapshotHash({ orderNo: 'A-1', status: 'refunded' })) + assert.match(first, /^[0-9a-f]{64}$/) +}) + +test('同订单相同来源快照只追加一次', async () => { + const calls: unknown[][] = [] + const executor = async (sql: string, params: unknown[] = []) => { + calls.push(params) + assert.ok(sql.includes('INSERT INTO order_source_snapshots')) + assert.ok(sql.includes('ON CONFLICT (order_id, payload_sha256) DO NOTHING')) + return { rows: [{ id: 1 }] } + } + + const inserted = await appendOrderSourceSnapshotWithExecutor(executor as never, { + orderId: 7, + provider: 'kuaishou-91', + platform: 'kuaishou-91', + shopId: 'shop-1', + platformOrderId: 'ORDER-1', + sourceLabel: 'source-order-upsert', + payload: { orderNo: 'ORDER-1', status: 'paid' }, + recordedAt: '2026-11-09T00:00:00.000Z', + }) + + assert.equal(inserted, true) + assert.equal(calls.length, 1) + assert.equal(calls[0]?.[0], 7) + assert.equal(calls[0]?.[7], orderSourceSnapshotHash({ orderNo: 'ORDER-1', status: 'paid' })) +}) diff --git a/apps/backend/src/repositories/order-source-snapshot-repo.ts b/apps/backend/src/repositories/order-source-snapshot-repo.ts new file mode 100644 index 00000000..8b02422c --- /dev/null +++ b/apps/backend/src/repositories/order-source-snapshot-repo.ts @@ -0,0 +1,67 @@ +import { createHash } from 'node:crypto' +import type { QueryResult, QueryResultRow } from 'pg' + +import { safeJsonStringify } from '../utils/json.js' + +type QueryExecutor = ( + text: string, + params?: unknown[], +) => Promise> + +export type OrderSourceSnapshotInput = { + orderId: number + provider: string + platform: string + shopId: string + platformOrderId: string + sourceLabel: string + payload: unknown + recordedAt: string +} + +export function orderSourceSnapshotHash(payload: unknown): string { + return createHash('sha256') + .update(safeJsonStringify(payload || {})) + .digest('hex') +} + +/** + * 追加平台订单原始快照。 + * 同一订单的相同内容只写一次,不同内容永远追加,不覆盖订单主表原始报文。 + */ +export async function appendOrderSourceSnapshotWithExecutor( + executor: QueryExecutor, + input: OrderSourceSnapshotInput, +): Promise { + const payloadJson = safeJsonStringify(input.payload || {}) + const payloadHash = orderSourceSnapshotHash(input.payload) + const result = await executor<{ id: number }>( + ` + INSERT INTO order_source_snapshots ( + order_id, + provider, + platform, + shop_id, + platform_order_id, + source_label, + payload_json, + payload_sha256, + recorded_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9) + ON CONFLICT (order_id, payload_sha256) DO NOTHING + RETURNING id + `, + [ + input.orderId, + input.provider, + input.platform, + input.shopId, + input.platformOrderId, + input.sourceLabel, + payloadJson, + payloadHash, + input.recordedAt, + ], + ) + return result.rows.length > 0 +} diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index add71870..3538d195 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -223,6 +223,22 @@ test('内部现货不再因上一手 last_assigned 进入打手可见集', async assert.equal(calls.length, 2, '列表和概览必须共用同一条历史可见条件') }) +test('后台接单人员筛选把寄售供货人的绑定工单纳入可见集合', () => { + const query = buildWorkOrderWhere({ + workerSharingId: 1494, + consignmentSupplierWorkerId: 1494, + }) + assert.match(query.joins, /FROM consignment_inventories supplier_ci/) + assert.match(query.joins, /supplier_ci\.worker_id = \$2::bigint/) + assert.match(query.joins, /supplier_ci\.bound_work_order_id IS NOT NULL/) + assert.deepEqual(query.params, [1494, 1494]) + + // 打手端「我的订单」不传扩展口径,可见范围必须保持原样 + const plain = buildWorkOrderWhere({ workerSharingId: 1494 }) + assert.doesNotMatch(plain.joins, /supplier_ci/) + assert.deepEqual(plain.params, [1494]) +}) + test('「仅问题单」检索走短路分支,从 pending 表反查而非枚举可见集', () => { const query = buildWorkOrderWhere({ workerSharingId: 7, statuses: ['problem'] }) diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts index b7e13c4e..dcc38154 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts @@ -64,6 +64,52 @@ test('寄卖仓库搜索必须覆盖供货打手姓名、打手电话与页面 ) }) +test('寄卖提报单搜索必须能搜打手,列表与计数 SQL 都带实时打手关联', async () => { + const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') + const start = source.indexOf('export async function listConsignmentSubmissions') + assert.ok(start > 0, '未找到寄卖提报单列表查询') + const body = source.slice(start, source.indexOf('\n}\n', start)) + + for (const field of [ + 'cs.worker_name', + 'cs.worker_phone', + 'supplier_worker.display_name', + 'supplier_worker.username', + 'supplier_worker.phone', + ]) { + assert.ok(body.includes(field), `提报单搜索缺少字段:${field}`) + } + assert.ok(body.includes('AS live_worker_name'), '列表必须带出实时打手昵称') + assert.ok(body.includes('AS live_worker_phone'), '列表必须带出实时打手电话') + + const countSqlLine = body + .split('\n') + .find((line) => + line.includes('SELECT COUNT(*)::integer AS total FROM consignment_submissions cs'), + ) + assert.ok(countSqlLine, '未找到提报单计数 SQL') + assert.ok( + countSqlLine!.includes('LEFT JOIN worker_users supplier_worker'), + '计数 SQL 缺少打手 join,按姓名/电话搜索会报错', + ) +}) + +test('寄售库存详情/批量/匹配候选查询都带出实时打手昵称', async () => { + const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') + for (const fn of [ + 'getConsignmentInventoryById', + 'listConsignmentInventoriesByIds', + 'getConsignmentInventoryBySubmissionId', + 'listActiveConsignmentInventoriesForMatching', + ]) { + const start = source.indexOf(`export async function ${fn}`) + assert.ok(start > 0, `未找到查询:${fn}`) + const body = source.slice(start, source.indexOf('\n}\n', start)) + assert.ok(body.includes('LEFT JOIN worker_users supplier_worker'), `${fn} 缺少实时打手 join`) + assert.ok(body.includes('AS live_worker_name'), `${fn} 缺少实时打手昵称字段`) + } +}) + test('寄卖仓库按通过审核入库时间筛选,结束日期必须包含当天全天', async () => { const source = await readRepoSource() const inventoryListStart = source.indexOf('export async function listConsignmentInventories') diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 5a7e78a6..548c1490 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -54,6 +54,10 @@ export type ConsignmentInventoryRow = { worker_id?: string | number | null worker_name?: string worker_phone?: string + /** 当前打手昵称(实时 join worker_users,优先于入库时的快照) */ + live_worker_name?: string | null + /** 当前打手电话(实时 join worker_users,优先于入库时的快照) */ + live_worker_phone?: string | null assigned_worker_id?: string | number | null deposit_amount_fen?: number /** 分类级寄卖配置与发布快照 */ @@ -104,6 +108,10 @@ export type ConsignmentSubmissionRow = { worker_id?: string | number | null worker_name?: string worker_phone?: string + /** 当前打手昵称(实时 join worker_users,优先于提报时的快照) */ + live_worker_name?: string | null + /** 当前打手电话(实时 join worker_users,优先于提报时的快照) */ + live_worker_phone?: string | null deposit_amount_fen?: number /** 分类级寄卖配置与发布快照 */ profile_id?: string | number | null @@ -324,6 +332,8 @@ export async function listConsignmentInventories(filters: { wc.name AS category_name, cs.submission_no AS source_submission_no, cs.screenshots_json AS submission_screenshots_json, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, wo.acceptance_json AS gift_evidence_json, wo.work_order_no AS gift_work_order_no, wo.status AS gift_work_order_status, @@ -487,6 +497,8 @@ export async function getConsignmentInventoryById( wc.name AS category_name, cs.submission_no AS source_submission_no, cs.screenshots_json AS submission_screenshots_json, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, wo.acceptance_json AS gift_evidence_json, wo.work_order_no AS gift_work_order_no, wo.status AS gift_work_order_status, @@ -498,6 +510,7 @@ export async function getConsignmentInventoryById( FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id WHERE ci.id = $1 @@ -519,10 +532,13 @@ export async function listConsignmentInventoriesByIds( wc.name AS category_name, cs.submission_no AS source_submission_no, cs.screenshots_json AS submission_screenshots_json, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id WHERE ci.id = ANY($1::bigint[]) ` @@ -1150,9 +1166,12 @@ export async function listActiveConsignmentInventoriesForMatching(options?: { SELECT ci.*, cs.submission_no AS source_submission_no, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id WHERE ${conditions.join(' AND ')} ORDER BY ci.id ASC @@ -1184,12 +1203,22 @@ export async function listConsignmentSubmissions(filters: { if (filters.keyword) { values.push(`%${filters.keyword.trim()}%`) conditions.push( - `(cs.item_name ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR cs.contact_info ILIKE $${values.length} OR cs.seller_game_id ILIKE $${values.length} OR cs.seller_phone ILIKE $${values.length} OR ccs.name ILIKE $${values.length})`, + `(cs.item_name ILIKE $${values.length} + OR cs.submission_no ILIKE $${values.length} + OR cs.contact_info ILIKE $${values.length} + OR cs.seller_game_id ILIKE $${values.length} + OR cs.seller_phone ILIKE $${values.length} + OR cs.worker_name ILIKE $${values.length} + OR cs.worker_phone ILIKE $${values.length} + OR supplier_worker.display_name ILIKE $${values.length} + OR supplier_worker.username ILIKE $${values.length} + OR supplier_worker.phone ILIKE $${values.length} + OR ccs.name ILIKE $${values.length})`, ) } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_submissions cs LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id ${whereClause}` + const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_submissions cs LEFT JOIN worker_users supplier_worker ON supplier_worker.id = cs.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id ${whereClause}` const countRes = await query<{ total: number }>(countSql, values) const total = countRes.rows[0]?.total || 0 @@ -1206,9 +1235,12 @@ export async function listConsignmentSubmissions(filters: { wo.acceptance_json AS gift_evidence_json, wo.work_order_no AS gift_work_order_no, wo.status AS gift_work_order_status, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN worker_users supplier_worker ON supplier_worker.id = cs.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id LEFT JOIN consignment_inventories rinv ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id) LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id @@ -1473,9 +1505,12 @@ export async function listConsignmentSubmissionsByIds( wo.acceptance_json AS gift_evidence_json, wo.work_order_no AS gift_work_order_no, wo.status AS gift_work_order_status, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN worker_users supplier_worker ON supplier_worker.id = cs.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id LEFT JOIN consignment_inventories rinv ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id) @@ -1502,10 +1537,13 @@ export async function getConsignmentInventoryBySubmissionId( ci.*, wc.name AS category_name, cs.submission_no AS source_submission_no, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id WHERE ci.source_submission_id = $1 ORDER BY ci.id DESC @@ -1526,9 +1564,12 @@ export async function getConsignmentSubmissionById( wo.acceptance_json AS gift_evidence_json, wo.work_order_no AS gift_work_order_no, wo.status AS gift_work_order_status, + COALESCE(NULLIF(supplier_worker.display_name, ''), supplier_worker.username) AS live_worker_name, + supplier_worker.phone AS live_worker_phone, ccs.name AS contact_supporter_name FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN worker_users supplier_worker ON supplier_worker.id = cs.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id LEFT JOIN consignment_inventories rinv ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id) LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index c7e82e19..cdd60818 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -529,6 +529,14 @@ export type ListInput = { /** 后台打手列表按提现黑名单筛选。 */ isWithdrawalBlacklisted?: boolean | null | undefined workerSharingId?: number + /** + * 后台「接单人员」筛选的扩展口径:同时把该打手作为寄售供货人的工单纳入可见集合。 + * + * 内部寄售直接履约不写 assigned_worker_id,供货关系只存在 + * consignment_inventories.worker_id;仅靠 workerSharingId 会漏掉这些单。 + * 该字段只由后台筛选使用,打手端「我的订单」不传,避免改变打手可见范围。 + */ + consignmentSupplierWorkerId?: number /** 大厅候选集合的总展示上限;筛选与分页在候选集合内执行。 */ hallCandidateLimit?: number hallCategoryLimits?: Record diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 5441b526..7d7b3198 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -391,6 +391,7 @@ export async function listWorkOrders({ workerLevelIds = [], categoryId = 0, workerSharingId = 0, + consignmentSupplierWorkerId = 0, hallCandidateLimit = 0, hallCategoryLimits = {}, visibleAfterIso = '', @@ -416,6 +417,7 @@ export async function listWorkOrders({ ...(resolvedWorkerIds ? { workerIds: resolvedWorkerIds } : {}), categoryId, workerSharingId, + consignmentSupplierWorkerId, hallCandidateLimit, hallCategoryLimits, visibleAfterIso, @@ -1212,6 +1214,7 @@ export function buildWorkOrderWhere({ workerIds, categoryId = 0, workerSharingId = 0, + consignmentSupplierWorkerId = 0, hallCandidateLimit = 0, hallCategoryLimits = {}, visibleAfterIso = '', @@ -1241,6 +1244,7 @@ export function buildWorkOrderWhere({ ) } let workerSharePlaceholder = '' + let consignmentSupplierUnion = '' // 旧的 excludeFilledSharing 写法:LEFT JOIN 一份「整张 work_order_shares 的聚合」 // (GROUP BY work_order_id)再在 WHERE 里比较。列表/计数每次调用都要重算这份全局聚合, @@ -1252,6 +1256,22 @@ export function buildWorkOrderWhere({ if (workerSharingId) { params.push(workerSharingId) workerSharePlaceholder = `$${params.length}` + const normalizedSupplierWorkerId = Math.max( + 0, + Math.floor(Number(consignmentSupplierWorkerId) || 0), + ) + if (normalizedSupplierWorkerId > 0) { + // 内部寄售直接履约不写 assigned_worker_id:供货关系只存在 + // consignment_inventories.worker_id,这里按库存回查绑定工单,补进可见集合。 + params.push(normalizedSupplierWorkerId) + const supplierPlaceholder = `$${params.length}` + consignmentSupplierUnion = ` + UNION + SELECT supplier_ci.bound_work_order_id AS work_order_id + FROM consignment_inventories supplier_ci + WHERE supplier_ci.worker_id = ${supplierPlaceholder}::bigint + AND supplier_ci.bound_work_order_id IS NOT NULL` + } joins.push(` INNER JOIN ( SELECT assigned.id AS work_order_id @@ -1270,7 +1290,7 @@ export function buildWorkOrderWhere({ SELECT timeout_ev.work_order_id FROM work_order_events timeout_ev WHERE timeout_ev.event_type LIKE 'timeout_%' - AND timeout_ev.payload_json->>'workerId' = ${workerSharePlaceholder}::text + AND timeout_ev.payload_json->>'workerId' = ${workerSharePlaceholder}::text${consignmentSupplierUnion} ) worker_visible ON worker_visible.work_order_id = wo.id LEFT JOIN LATERAL ( SELECT wos.status, wos.created_at diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index c717e4de..9255bff8 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -7,6 +7,7 @@ import { applyOrderItemRevenueAllocationWithExecutor, replaceOrderItemsWithExecutor, } from '../../repositories/order-item-repo.js' +import { appendOrderSourceSnapshotWithExecutor } from '../../repositories/order-source-snapshot-repo.js' import { withTransaction } from '../../db/client.js' import { syncDeliveryTasksForOrder } from './delivery-task-service.js' import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js' @@ -189,6 +190,34 @@ export async function upsertOrderFromSource( }) } + await appendOrderSourceSnapshotWithExecutor(executor, { + orderId: Number(order.id), + provider: event.provider, + platform: event.platform, + shopId: String(order.shop_id || event.shopId || '').trim(), + platformOrderId: event.platformOrderId, + sourceLabel, + payload: { + sourceLabel, + provider: event.provider, + platform: event.platform, + shopId: event.shopId, + shopName: event.shopName, + platformOrderId: event.platformOrderId, + orderStatus: event.orderStatus, + payStatus: event.payStatus, + buyerId: event.buyerId, + buyerName: event.buyerName, + receiverContact: event.receiverContact, + totalAmount: event.totalAmount, + currency: event.currency, + paidAt: event.paidAt || null, + rawPayload: event.rawPayload, + items: event.items, + }, + recordedAt: now, + }) + const orderItems = await replaceOrderItemsWithExecutor( executor, order.id, diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 78153313..fe8e2eec 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -25,9 +25,8 @@ import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' export const OPEN_91_PENDING_CONFIG_STATUS = 'pending_config' export const OPEN_91_MANUAL_FAILED_STATUS = 'manual_failed' -export async function markOpen91OrderPendingConfig(order: OrderRow, reason = '') { +export async function markOpen91OrderPendingConfig(order: OrderRow, _reason = '') { const now = nowIso() - const rawPayload = parseJsonObject(order.raw_payload_json) return updateOrder(order.id, { provider: order.provider, platform: order.platform, @@ -41,11 +40,6 @@ export async function markOpen91OrderPendingConfig(order: OrderRow, reason = '') receiverContact: order.receiver_contact, totalAmount: Number(order.total_amount) || 0, currency: order.currency, - rawPayloadJson: JSON.stringify({ - ...rawPayload, - pendingReason: String(reason || '').trim() || 'fulfillment_not_ready', - pendingAt: now, - }), paidAt: order.paid_at, updatedAt: now, }) @@ -154,11 +148,6 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: platformOrderId: event.platformOrderId, }) - const rawPayloadJson = JSON.stringify({ - ...event.rawPayload, - pendingReason: 'unconfigured_items', - pendingAt: now, - }) const orderPayload = { provider: event.provider, platform: event.platform, @@ -172,7 +161,6 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: receiverContact: '', totalAmount: event.totalAmount, currency: 'CNY', - rawPayloadJson, paidAt: existing?.paid_at || now, } @@ -245,10 +233,9 @@ export async function retryOpen91Order(orderId: string | number) { } } -export async function failOpen91Order(orderId: string | number, reason = '') { +export async function failOpen91Order(orderId: string | number, _reason = '') { const order = await getRequiredOpen91Order(orderId) const now = nowIso() - const rawPayload = parseJsonObject(order.raw_payload_json) const updated = await updateOrder(order.id, { provider: order.provider, platform: order.platform, @@ -262,11 +249,6 @@ export async function failOpen91Order(orderId: string | number, reason = '') { receiverContact: order.receiver_contact, totalAmount: order.total_amount, currency: order.currency, - rawPayloadJson: JSON.stringify({ - ...rawPayload, - manualFailedAt: now, - manualFailedReason: String(reason || '').trim() || '商家手动标记无法履约', - }), paidAt: order.paid_at, updatedAt: now, }) diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 662bdefd..89f1eb9b 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -3,12 +3,14 @@ import type { Response } from 'express' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { + type ConsignmentInventoryRow, getLatestMaterialEventAtByOrderIds, getPendingCancelRequestOrderIds, getPendingFeedbackOrderIds, getWorkerFinanceRequestById, getWorkerUserById, getWorkOrderById, + listConsignmentInventoriesByIds, listWorkerTimeoutEventsByOrderIds, listWorkerWorkOrderNotes, listWorkerWorkOrderViews, @@ -23,6 +25,7 @@ import { type RealtimeEventRow, } from '../../repositories/realtime-event-repo.js' import { getAdminDashboardSummary } from '../admin/admin-dashboard-service.js' +import { safeParseJson } from '../admin/admin-query-utils.js' import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js' import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js' import { @@ -298,8 +301,27 @@ async function publishWorkOrderSnapshots(input: { return } - const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds([workOrderId]) - const shares = await listWorkOrderShares(workOrderId) + const material = safeParseJson(workOrder.material_json) + const consignmentMaterial = material.consignment + const consignmentInventoryId = + consignmentMaterial && + typeof consignmentMaterial === 'object' && + !Array.isArray(consignmentMaterial) + ? Math.max( + 0, + Math.floor(Number((consignmentMaterial as Record).inventoryId) || 0), + ) + : 0 + const [pendingUnfreezeByOrderId, shares, consignmentInventories] = await Promise.all([ + sumPendingUnfreezeByOrderIds([workOrderId]), + listWorkOrderShares(workOrderId), + consignmentInventoryId > 0 + ? listConsignmentInventoriesByIds([consignmentInventoryId]) + : Promise.resolve([] as ConsignmentInventoryRow[]), + ]) + // 寄售工单的快照也要带库存实时供货人:前端会整行替换, + // 若只给 material 里的旧快照,改名后的列表会被实时事件刷回旧名。 + const consignmentInventory = consignmentInventories[0] || null // 事件驱动缓存失效:这里已经拿到的工单与拼单快照恰好给出完整的受影响打手集合 // (当前指派 + 历史保留 + 全部拼单人 + 调用方显式指定的)。 @@ -317,7 +339,7 @@ async function publishWorkOrderSnapshots(input: { .reduce((sum, share) => sum + Number(share.quantity || 0), 0) const pendingSubmissionCount = shares.filter((share) => share.status === 'joined').length const adminSnapshot = { - ...mapWorkOrderAdmin(workOrder, shares), + ...mapWorkOrderAdmin(workOrder, shares, consignmentInventory), pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(workOrderId) || 0, sharingProgress: { joinedQuantity, diff --git a/apps/backend/src/services/scheduler/data-retention-job.test.ts b/apps/backend/src/services/scheduler/data-retention-job.test.ts index ac70c39c..88bdffb0 100644 --- a/apps/backend/src/services/scheduler/data-retention-job.test.ts +++ b/apps/backend/src/services/scheduler/data-retention-job.test.ts @@ -6,21 +6,25 @@ async function readJobSource() { return readFile(new URL('./data-retention-job.ts', import.meta.url), 'utf8') } -test('保留清理必须覆盖 work_order_events,否则这张只增不减的表会一直拖慢历史单检索', async () => { +test('数据维护不得覆盖原始报文或删除永久业务事实', async () => { const src = await readJobSource() - assert.ok( - src.includes("deleteOldRowsInBatches('work_order_events'"), - 'work_order_events 未纳入保留清理(生产上 71 万行 / 269MB 且只增不减)', - ) - assert.ok( - src.includes("'task_events' | 'webhook_events' | 'admin_audit_logs' | 'work_order_events'"), - '删除助手需要显式允许 work_order_events(表名是白名单联合类型,避免拼接注入)', - ) - assert.ok(src.includes('retention.workOrderEventDays'), '保留天数必须走配置项,便于按环境调整') - assert.ok( - src.includes('workOrderEventRows: number') && src.includes('workOrderEventRows,'), - '清理结果要带上工单事件的删除行数,方便定时任务面板核对', - ) + assert.equal(src.includes('raw_payload_json ='), false) + assert.equal(src.includes('DELETE FROM'), false) + for (const table of [ + 'orders', + 'kuaishou_industry_vouchers', + 'task_events', + 'webhook_events', + 'admin_audit_logs', + 'work_order_events', + ]) { + assert.equal( + new RegExp(`(?:UPDATE|DELETE)\\s+${table}\\b`, 'i').test(src), + false, + `${table} 属于永久业务事实,不能在维护任务中清理或覆盖`, + ) + } + assert.ok(src.includes('永久业务事实未清理')) }) test('成本异常只归档已处理的行,open 队列不能被藏起来', async () => { @@ -64,29 +68,28 @@ test('保留任务要回填漏写的付款派生列,并在有命中时打日 ) }) -test('保留清理默认窗口:敏感报文/事件 180 天,审计 365 天,工单事件 180 天', async () => { +test('数据维护只保留可派生数据的成本异常归档窗口', async () => { const defaults = await readFile(new URL('../../config/defaults.ts', import.meta.url), 'utf8') + assert.ok(defaults.includes('costExceptionDays: 60')) for (const line of [ - 'rawPayloadDays: 180', - 'taskEventDays: 180', - 'webhookEventDays: 180', - 'auditLogDays: 365', - 'workOrderEventDays: 180', - 'costExceptionDays: 60', + 'rawPayloadDays:', + 'taskEventDays:', + 'webhookEventDays:', + 'auditLogDays:', + 'workOrderEventDays:', ]) { - assert.ok(defaults.includes(line), `默认保留策略缺少 ${line}`) + assert.equal(defaults.includes(line), false, `永久事实不能存在清理窗口 ${line}`) } const overrides = await readFile( new URL('../../config/env-overrides.ts', import.meta.url), 'utf8', ) - assert.ok( - overrides.includes( - "integerEnv('WORK_ORDER_EVENT_RETENTION_DAYS', ['retention', 'workOrderEventDays'])", - ), - 'WORK_ORDER_EVENT_RETENTION_DAYS 需要环境变量出口,否则生产无法调整', - ) + assert.equal(overrides.includes('RAW_PAYLOAD_RETENTION_DAYS'), false) + assert.equal(overrides.includes('TASK_EVENT_RETENTION_DAYS'), false) + assert.equal(overrides.includes('WEBHOOK_EVENT_RETENTION_DAYS'), false) + assert.equal(overrides.includes('AUDIT_LOG_RETENTION_DAYS'), false) + assert.equal(overrides.includes('WORK_ORDER_EVENT_RETENTION_DAYS'), false) assert.ok( overrides.includes( "integerEnv('COST_EXCEPTION_RETENTION_DAYS', ['retention', 'costExceptionDays'])", diff --git a/apps/backend/src/services/scheduler/data-retention-job.ts b/apps/backend/src/services/scheduler/data-retention-job.ts index f5a9d7ea..0c4a2d66 100644 --- a/apps/backend/src/services/scheduler/data-retention-job.ts +++ b/apps/backend/src/services/scheduler/data-retention-job.ts @@ -8,49 +8,29 @@ import { pruneWorkProductMatchLogs } from '../../repositories/worker-platform/pr export type DataRetentionResult = { status: 'ok' message: string - rawPayloadRows: number - taskEventRows: number - webhookEventRows: number - auditLogRows: number - workOrderEventRows: number - matchLogRows: number matchLogDeletedRows: number sourceOrderAmountBackfillRows: number costExceptionArchivedRows: number } -/** 清理敏感原始报文并删除已过保留期的事件/审计记录。 */ +/** + * 维护可重建的派生数据。 + * + * 原始订单、平台报文、工单/任务事件、审计日志和资金记录都是永久事实, + * 不在这里按时间清理或覆盖。 + */ export async function runDataRetentionJob(): Promise { const retention = runtimeConfig.retention || {} - const rawPayloadDays = positiveDays(retention.rawPayloadDays, 180) - const taskEventDays = positiveDays(retention.taskEventDays, 180) - const webhookEventDays = positiveDays(retention.webhookEventDays, 180) - const auditLogDays = positiveDays(retention.auditLogDays, 365) - // 工单事件表以前完全没纳入保留:717k 行 / 269MB 且只增不减,会一直拖慢历史单检索。 - const workOrderEventDays = positiveDays(retention.workOrderEventDays, 180) // 成本异常只归档「已处理」的行;open 行是待办队列,不能因为超期就藏起来。 const costExceptionDays = positiveDays(retention.costExceptionDays, 60) - // 1. 按配置清理超出上限或过期的打手商品匹配日志 + // 商品匹配日志是诊断数据,不是订单或履约事实。 const matchConfig = getWorkerProductMatchConfig() const matchLogPruneResult = await pruneWorkProductMatchLogs({ maxRows: matchConfig.maxMatchLogs, retentionDays: matchConfig.matchLogRetentionDays, }) - // 2. 清理其他报文与日志 - const rawPayloadRows = - (await scrubJsonColumnInBatches('orders', 'updated_at', rawPayloadDays)) + - (await scrubJsonColumnInBatches('kuaishou_industry_vouchers', 'updated_at', rawPayloadDays)) - const matchLogRows = await scrubJsonColumnInBatches( - 'work_product_match_logs', - 'created_at', - rawPayloadDays, - ) - const taskEventRows = await deleteOldRowsInBatches('task_events', taskEventDays) - const webhookEventRows = await deleteOldRowsInBatches('webhook_events', webhookEventDays) - const auditLogRows = await deleteOldRowsInBatches('admin_audit_logs', auditLogDays) - const workOrderEventRows = await deleteOldRowsInBatches('work_order_events', workOrderEventDays) // 统计热路径已不再读 material_json。若将来有第三个写入点忘了维护派生列, // 金额会静默回退 orders.total_amount。这里把漏写补上并打日志,让口径变化可见。 const costExceptionArchive = await archiveResolvedCostExceptions({ @@ -66,14 +46,8 @@ export async function runDataRetentionJob(): Promise { return { status: 'ok', - message: '数据保留清理完成', - rawPayloadRows, - matchLogRows, + message: '派生数据维护完成;永久业务事实未清理', matchLogDeletedRows: matchLogPruneResult.totalDeleted, - taskEventRows, - webhookEventRows, - auditLogRows, - workOrderEventRows, sourceOrderAmountBackfillRows, costExceptionArchivedRows, } @@ -92,59 +66,6 @@ async function backfillMissingSourceOrderAmount() { return result.rowCount || 0 } -async function scrubJsonColumnInBatches( - table: 'orders' | 'kuaishou_industry_vouchers' | 'work_product_match_logs', - dateColumn: 'created_at' | 'updated_at', - retentionDays: number, -) { - let total = 0 - while (true) { - const result = await query( - ` - WITH candidates AS ( - SELECT ctid - FROM ${table} - WHERE ${dateColumn} < NOW() - make_interval(days => $1::int) - AND raw_payload_json <> '{}'::jsonb - LIMIT 1000 - ) - UPDATE ${table} AS target - SET raw_payload_json = '{}'::jsonb - FROM candidates - WHERE target.ctid = candidates.ctid - `, - [retentionDays], - ) - total += result.rowCount || 0 - if ((result.rowCount || 0) < 1000) return total - } -} - -async function deleteOldRowsInBatches( - table: 'task_events' | 'webhook_events' | 'admin_audit_logs' | 'work_order_events', - retentionDays: number, -) { - let total = 0 - while (true) { - const result = await query( - ` - WITH candidates AS ( - SELECT ctid - FROM ${table} - WHERE created_at < NOW() - make_interval(days => $1::int) - LIMIT 1000 - ) - DELETE FROM ${table} AS target - USING candidates - WHERE target.ctid = candidates.ctid - `, - [retentionDays], - ) - total += result.rowCount || 0 - if ((result.rowCount || 0) < 1000) return total - } -} - function positiveDays(value: unknown, fallback: number) { const days = Number(value) return Number.isInteger(days) && days > 0 ? days : fallback diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index 6f36ca39..2190a16c 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -169,12 +169,9 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean } status, ...(job.type === 'data_retention' ? { - rawPayloadRows: Number(summary.rawPayloadRows || 0), - matchLogRows: Number(summary.matchLogRows || 0), - taskEventRows: Number(summary.taskEventRows || 0), - webhookEventRows: Number(summary.webhookEventRows || 0), - auditLogRows: Number(summary.auditLogRows || 0), + matchLogDeletedRows: Number(summary.matchLogDeletedRows || 0), sourceOrderAmountBackfillRows: Number(summary.sourceOrderAmountBackfillRows || 0), + costExceptionArchivedRows: Number(summary.costExceptionArchivedRows || 0), } : {}), }) diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index dd31d842..2a29fe02 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -59,6 +59,7 @@ function buildWorkOrderStatisticsCacheKey(filters: JsonObject): string { filters.keyword || '', filters.shopNames || [], filters.workerSharingId || 0, + filters.consignmentSupplierWorkerId || 0, filters.workerId || 0, filters.workerLevelIds || [], filters.categoryId || 0, @@ -149,6 +150,9 @@ export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListIn keyword, shopNames, workerSharingId: workerId, + // 「接单人员」筛选同时覆盖寄售供货人:内部寄售直接履约不写 assigned_worker_id, + // 供货关系只存在 consignment_inventories.worker_id,必须补进可见集合。 + ...(workerId > 0 ? { consignmentSupplierWorkerId: workerId } : {}), workerLevelIds, vipEvidencePending, ...(acceptanceMode ? { acceptanceMode } : {}), diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.test.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.test.ts index 4673380f..24407c8e 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { normalizeInventoryDate } from './consignment-admin-service.js' +import { mapConsignmentInventory, normalizeInventoryDate } from './consignment-admin-service.js' test('寄售仓库入库日期筛选只接受合法的 YYYY-MM-DD', () => { assert.equal(normalizeInventoryDate('2026-11-05'), '2026-11-05') @@ -10,3 +10,61 @@ test('寄售仓库入库日期筛选只接受合法的 YYYY-MM-DD', () => { assert.equal(normalizeInventoryDate('2026/11/05'), undefined) assert.equal(normalizeInventoryDate(''), undefined) }) + +test('寄售库存改名后优先显示实时打手昵称,打手被删才回退快照', () => { + const base = { + id: 1, + inventory_no: 'INV1', + game_name: '和平精英', + category_id: null, + item_name: '测试物品', + system: '', + server_zone: '', + series: '', + diamond_level: 1, + brilliance_count: 0, + cost_price_fen: 0, + buyer_name: '', + inbound_batch: '', + seller_contact: '', + seller_game_id: '', + seller_payment_info: '', + seller_phone: '', + credentials_json: {}, + screenshots_json: [], + remark: '', + allow_matching: true, + status: 'in_stock', + allocation_status: '待安排', + bound_order_id: null, + bound_work_order_id: null, + bound_platform_order_id: '', + shop_name: '', + source_type: 'manual', + source_submission_id: null, + inbound_at: '2026-11-01T00:00:00.000Z', + consumed_at: null, + created_at: '2026-11-01T00:00:00.000Z', + updated_at: '2026-11-01T00:00:00.000Z', + worker_id: 7, + worker_name: '旧昵称', + worker_phone: '111', + } + + const renamed = mapConsignmentInventory({ + ...base, + live_worker_name: '新昵称', + live_worker_phone: '222', + } as never) + assert.equal(renamed.workerName, '新昵称') + assert.equal(renamed.workerPhone, '222') + + // 打手被删除时 join 不到实时行,仍要能显示登记时快照 + const deleted = mapConsignmentInventory({ + ...base, + live_worker_name: null, + live_worker_phone: null, + } as never) + assert.equal(deleted.workerName, '旧昵称') + assert.equal(deleted.workerPhone, '111') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 3ddaf136..50175cf0 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -309,8 +309,9 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { }) : null, workerId: row.worker_id ? Number(row.worker_id) : null, - workerName: row.worker_name || '', - workerPhone: row.worker_phone || '', + // 改名后以当前昵称为准;快照只用于打手被删除等无法实时关联的兜底。 + workerName: row.live_worker_name || row.worker_name || '', + workerPhone: row.live_worker_phone || row.worker_phone || '', assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null, depositAmountFen: Number(row.deposit_amount_fen || 0), contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null, @@ -371,8 +372,9 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { ), askingPriceFen: Number(row.asking_price_fen || 0), workerId: row.worker_id ? Number(row.worker_id) : null, - workerName: row.worker_name || '', - workerPhone: row.worker_phone || '', + // 改名后以当前昵称为准;快照只用于打手被删除等无法实时关联的兜底。 + workerName: row.live_worker_name || row.worker_name || '', + workerPhone: row.live_worker_phone || row.worker_phone || '', depositAmountFen: Number(row.deposit_amount_fen || 0), contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null, contactSupporterName: row.contact_supporter_name || '', diff --git a/apps/backend/src/services/worker-platform/order-no-search-filter.test.ts b/apps/backend/src/services/worker-platform/order-no-search-filter.test.ts index 2c830dc3..b00cdd1a 100644 --- a/apps/backend/src/services/worker-platform/order-no-search-filter.test.ts +++ b/apps/backend/src/services/worker-platform/order-no-search-filter.test.ts @@ -41,6 +41,7 @@ test('后台:显式单号检索时忽略状态等排他筛选,只按单号 assert.equal(filters.vipEvidencePending, false) // 接单人员:目标单可能根本不在这名打手的可见集合里 assert.equal(filters.workerSharingId, 0) + assert.equal(filters.consignmentSupplierWorkerId, undefined) // 分类 / 等级 / 验收模式 / 赠送阶段 / 创建时间 / 定位工单 assert.equal(filters.categoryId, 0) assert.deepEqual(filters.workerLevelIds, []) @@ -77,6 +78,7 @@ test('后台:普通关键词(商品名/昵称)仍按原样下推全部筛 assert.deepEqual(filters.statuses, ['open', 'in_progress']) assert.equal(filters.vipEvidencePending, true) assert.equal(filters.workerSharingId, 7) + assert.equal(filters.consignmentSupplierWorkerId, 7) assert.equal(filters.categoryId, 12) assert.deepEqual(filters.workerLevelIds, [1, 2]) assert.equal(filters.acceptanceMode, 'friend_gift') diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts index 9c7c5f7c..95a7f220 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts @@ -4,6 +4,7 @@ import test from 'node:test' import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js' import { isWorkerOrderVipEvidencePending, + mapWorkOrderMaterialWithConsignmentWorker, pickWorkerRepresentativeShare, resolveWorkerOrderStatusTabs, stripWorkerPersonalShareFields, @@ -264,3 +265,36 @@ test('大厅广播快照必须剔除打手私有份额字段', () => { assert.deepEqual(snapshot.sharingProgress, { joinedQuantity: 2, pendingSubmissionCount: 1 }) assert.equal(snapshot.workOrderId, 1) }) + +test('寄售供货人改名后,工单 material 展示使用库存实时昵称', () => { + const material = { + consignment: { + inventoryId: 9, + workerName: '旧昵称', + workerPhone: '111', + itemName: '测试物品', + }, + } + + const renamed = mapWorkOrderMaterialWithConsignmentWorker(material, { + live_worker_name: '新昵称', + live_worker_phone: '222', + } as never) as { consignment: { workerName: string; workerPhone: string; itemName: string } } + + assert.equal(renamed.consignment.workerName, '新昵称') + assert.equal(renamed.consignment.workerPhone, '222') + // 其它寄售字段不能被覆盖 + assert.equal(renamed.consignment.itemName, '测试物品') + + const fallback = mapWorkOrderMaterialWithConsignmentWorker(material, null) as { + consignment: { workerName: string } + } + assert.equal(fallback.consignment.workerName, '旧昵称') + + // 历史上没有登记供货人的内部现货单,不能因为库存关联到打手就改标成「供货打手」 + const noSnapshot = mapWorkOrderMaterialWithConsignmentWorker( + { consignment: { inventoryId: 9, workerName: '' } }, + { live_worker_name: '新昵称', live_worker_phone: '222' } as never, + ) as { consignment: { workerName: string } } + assert.equal(noSnapshot.consignment.workerName, '') +}) diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 60cffd8b..b380b473 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -68,6 +68,40 @@ export function mapWorkOrderMaterial(value: unknown) { } } +/** + * 工单 material.consignment 里保存的是入库/履约时的打手昵称快照。 + * 打手改名后,后台列表仍要显示当前昵称;有实时库存行时用它覆盖展示字段, + * 历史 JSON 本身不改写(保留登记时痕迹)。 + */ +export function mapWorkOrderMaterialWithConsignmentWorker( + value: unknown, + consignmentInventory?: ConsignmentInventoryRow | null, +) { + const material = mapWorkOrderMaterial(value) as JsonObject + const liveWorkerName = String( + consignmentInventory?.live_worker_name || consignmentInventory?.worker_name || '', + ).trim() + if (!liveWorkerName) return material + const consignment = material.consignment + if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) { + return material + } + const consignmentRecord = consignment as JsonObject + // 只刷新「原本就登记过供货人」的单据,避免把历史上没有供货人快照的 + // 内部现货单改标成「供货打手」(那会改变历史单的展示语义)。 + if (!String(consignmentRecord.workerName || '').trim()) return material + return { + ...material, + consignment: { + ...consignmentRecord, + workerName: liveWorkerName, + workerPhone: String( + consignmentInventory?.live_worker_phone || consignmentInventory?.worker_phone || '', + ).trim(), + }, + } +} + /** 读取好友赠送模式的打手本人资料;主页截图访问链接需要读取时重签。 */ export function mapWorkOrderBoosterMaterial(value: unknown) { const material = safeParseJson(value) @@ -283,6 +317,12 @@ export function mapWorkOrderAdmin( const giftPhase = String(workOrder.gift_phase || '').trim() const resolvedGiftPhase = resolveWorkOrderGiftPhase(workOrder) const internalConsignment = hasInternalConsignmentFulfillment(workOrder) + const consignmentMaterial = safeParseJson( + safeParseJson(workOrder.material_json).consignment, + ) as JsonObject + const consignmentWorkerIdRaw = + consignmentInventory?.worker_id || consignmentMaterial.workerId || 0 + const consignmentWorkerId = Math.max(0, Math.floor(Number(consignmentWorkerIdRaw) || 0)) const hallAvailable = workOrder.status === WORK_ORDER_STATUS.OPEN && (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) @@ -437,7 +477,10 @@ export function mapWorkOrderAdmin( depositThresholdAmount: Number( workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, ), - material: mapWorkOrderMaterial(workOrder.material_json), + material: mapWorkOrderMaterialWithConsignmentWorker( + workOrder.material_json, + consignmentInventory, + ), requirement: { fields: resolveRequirementFields(workOrder), }, @@ -462,6 +505,8 @@ export function mapWorkOrderAdmin( displayName: workOrder.worker_display_name || '', } : null, + /** 寄售供货打手:后台「接单人员」筛选也认这个 id(内部寄售直接履约没有 assigned_worker_id)。 */ + consignmentWorkerId: consignmentWorkerId > 0 ? consignmentWorkerId : null, ...(shares ? { sharingWorkers: mapWorkOrderSharingWorkers(shares) } : {}), publishedAt: workOrder.published_at, pinnedAt: workOrder.pinned_at, diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 0239e9c4..faa95205 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -28,12 +28,6 @@ export type RuntimeConfig = { maxFiles?: number } retention: { - rawPayloadDays: number - taskEventDays: number - webhookEventDays: number - auditLogDays: number - /** 工单事件(work_order_events)保留天数:这张表只增不减,必须纳入清理。 */ - workOrderEventDays: number /** 已处理成本异常(cost_exceptions)保留天数:超期归档,open 行不归档。 */ costExceptionDays: number } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 8127caec..ca7581ca 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -782,6 +782,8 @@ export type WorkOrder = { username: string displayName: string } + /** 寄售供货打手 id:后台「接单人员」筛选同时认这个 id。 */ + consignmentWorkerId?: number | null /** 拼单订单的当前参与打手,普通整单不返回该字段。 */ sharingWorkers?: Array<{ workerId: number diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index 0751288d..16c60ef0 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -155,6 +155,18 @@ export function isExplicitOrderNoSearch(text: unknown): boolean { ) } +/** + * 快照里是否带寄售履约标记。 + * + * 寄售单的供货打手不在 worker / sharingWorkers 上,而在库存行上; + * 旧快照没有 consignmentWorkerId 时无法判断它是否命中「接单人员」筛选。 + */ +function hasConsignmentMaterial(snapshot: Record): boolean { + const material = isRecord(snapshot.material) ? snapshot.material : null + const consignment = material && isRecord(material.consignment) ? material.consignment : null + return Boolean(consignment && (consignment.inventoryId || consignment.inventoryNo)) +} + export function evaluateAdminWorkOrderRealtimeMatch( snapshot: unknown, filters: AdminWorkOrderListFilters, @@ -190,7 +202,16 @@ export function evaluateAdminWorkOrderRealtimeMatch( toNumber((worker as { workerId?: unknown })?.workerId), ) : [] - if (assignedWorkerId !== filters.workerId && !sharingWorkerIds.includes(filters.workerId)) { + // 后台「接单人员」筛选同时认寄售供货人;实时快照带 consignmentWorkerId。 + const consignmentWorkerId = toNumber(snapshot.consignmentWorkerId) + const matchesTargetWorker = + assignedWorkerId === filters.workerId || + sharingWorkerIds.includes(filters.workerId) || + consignmentWorkerId === filters.workerId + if (!matchesTargetWorker) { + // 寄售单的供货人只存在库存行上:旧快照 / 未加载库存时无法证伪, + // 直接删行会把「接单人员 + 内部寄售匹配」列表里的寄售单清空。 + if (consignmentWorkerId <= 0 && hasConsignmentMaterial(snapshot)) return 'unknown' return 'mismatch' } } diff --git a/apps/frontend/tests/admin-realtime-work-orders.test.ts b/apps/frontend/tests/admin-realtime-work-orders.test.ts index 65440072..a44083a9 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -332,11 +332,27 @@ test('打手 / 分类 / 工单号 / 创建日期筛选的证伪能力', () => { }), )! - // 指派给别的打手 → mismatch + // 指派给别的打手 → mismatch(consignmentWorkerId 明确指向别人,才能证伪) assert.equal( - evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ worker: { workerId: 8 } }), filters), + evaluateAdminWorkOrderRealtimeMatch( + buildSnapshot({ worker: { workerId: 8 }, consignmentWorkerId: 8 }), + filters, + ), 'mismatch', ) + // 寄售供货人是目标打手 → 命中(内部寄售直接履约没有 assigned_worker_id) + assert.equal( + evaluateAdminWorkOrderRealtimeMatch( + buildSnapshot({ consignmentWorkerId: 7, categoryId: 12 }), + filters, + ), + 'match', + ) + // 旧快照没有 consignmentWorkerId、但有寄售标记 → 无法证伪,只能原地更新,不能删行 + assert.equal( + evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ categoryId: 12 }), filters), + 'unknown', + ) // 拼单参与者是目标打手 → 命中(后端 workerId 筛选也认拼单参与者) assert.equal( evaluateAdminWorkOrderRealtimeMatch( diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 3519527f..7d1d797c 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,16 +3,16 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.4 -updated_at: 2026-11-08 +version: 2.5 +updated_at: 2026-11-09 --- # 统一订单、履约与财务模型 > 状态:**唯一权威文档**。 -> 最后校准:2026-11-08。 -> 生产基线:迁移 `146`。 -> 开发基线:迁移 `150`(仅本地,尚未部署生产)。 +> 最后校准:2026-11-09。 +> 生产基线:迁移 `151`。 +> 开发基线:迁移 `152`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -313,7 +313,7 @@ finance_journal_* -- 总账凭证,可重建 | 信息 | 唯一写入方 | 唯一真源 | 明确禁止 | | --- | --- | --- | --- | | 平台订单与订单项 | 订单接入服务 | `orders` / `order_items` | 财务或履约服务改写平台事实 | -| 平台原始报文 | 各平台适配器 | 原始表 / `raw_payload_json` | 覆盖历史报文 | +| 平台原始报文 | 各平台适配器 | `order_source_snapshots` / 首次 `raw_payload_json` | 覆盖历史报文 | | 中文内部 SKU | 商品身份服务 | `internal_skus` | 各业务表各自发明同义 SKU | | 平台字段到内部 SKU | 映射服务 | `internal_sku_mappings` | 按名称猜测渠道 | | 主渠道与兜底 | 路由服务 | `sku_channel_routes` | UI 根据商品前缀筛选 | @@ -366,6 +366,7 @@ finance_journal_* -- 总账凭证,可重建 - `orders` - `order_items` 的原始平台字段 - 平台原始订单表与原始报文字段 +- `order_source_snapshots` 平台订单不可变事件快照 - 平台售后、退款、支付事实 - `work_orders`、`work_order_shares`、`work_order_events` - `fulfillment_tasks` 中真实执行与结果记录 @@ -377,6 +378,14 @@ finance_journal_* -- 总账凭证,可重建 A 级数据只能追加新事件或做字段兼容迁移,不能为了“模型整齐”重写历史事实。 +订单重复同步必须遵守: + +- 首次写入的 `orders.raw_payload_json` 不再覆盖; +- 每次来源同步追加 `order_source_snapshots`,相同内容幂等; +- 已有 `order_items` 的平台 SKU、名称、数量、规格和快照不因重推更新; +- 重推中缺少的订单项只记录为来源差异,不得物理删除订单项或级联删除履约记录; +- 数据保留任务不得清空或删除 A 级事实,只允许维护可重建的派生数据。 + ### B 级:业务输入,必须迁移而不是重建 - `internal_sku_mappings`