diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 2fbd1903..22070a03 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -5,7 +5,6 @@ import process from 'node:process' import adminRouter from './routes/admin.js' import affiliateDashRouter from './routes/affiliate-dash.js' import collectRouter from './routes/collect.js' -import consignmentRouter from './routes/consignment.js' import filesRouter from './routes/files.js' import claimsRouter from './routes/claims.js' import kuaishouIndustryRouter from './routes/kuaishou-industry.js' @@ -105,7 +104,6 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter) app.use('/api/v1/claim', claimsRouter) app.use('/api/v1/collect', collectRouter) - app.use('/api/v1/consignment', consignmentRouter) app.use('/api/v1/pdd/extension', pddExtensionRouter) app.use('/api/v1/files', filesRouter) app.use('/api/v1/worker', workerRouter) diff --git a/apps/backend/src/db/migrations/079_drop_consignment_collect_links.sql b/apps/backend/src/db/migrations/079_drop_consignment_collect_links.sql new file mode 100644 index 00000000..566a8a09 --- /dev/null +++ b/apps/backend/src/db/migrations/079_drop_consignment_collect_links.sql @@ -0,0 +1,102 @@ +-- 079_drop_consignment_collect_links.sql —— 下线「定向收集链接 / H5 填报」渠道 +-- +-- 背景:寄卖提报统一走接单平台(打手端提报道),对外收集链接这条线整条移除: +-- - 前端:ConsignmentCollectPage、services/consignment-public.ts、/consignment-collect/:token 路由 +-- - 后端:routes/consignment.ts(公开 link/submit/upload)、consignment-public-service.ts、 +-- 管理端 /consignment/links 三个接口 +-- - 数据:consignment_collect_links 表、consignment_submissions.link_id 列 +-- 历史数据不做兼容处理(测试阶段),source_type 的 collection_link 归并为 worker_portal。 + +-- 1) 提报单去掉链接外键列(依赖该列的索引随之删除) +ALTER TABLE consignment_submissions + DROP COLUMN IF EXISTS link_id; + +-- 2) 收集链接主表下线 +DROP TABLE IF EXISTS consignment_collect_links; + +-- 3) 库存来源枚举:collection_link -> worker_portal +-- 顺序很重要:旧 CHECK 只允许 ('manual','collection_link','recycle'), +-- 必须先摘掉旧约束再回填,否则 'worker_portal' 会被旧约束拒绝,整条迁移失败回滚。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'consignment_inventories_source_type_check' + AND conrelid = 'consignment_inventories'::regclass + ) THEN + ALTER TABLE consignment_inventories + DROP CONSTRAINT consignment_inventories_source_type_check; + END IF; +END +$$; + +UPDATE consignment_inventories +SET source_type = 'worker_portal', updated_at = NOW() +WHERE source_type = 'collection_link'; + +ALTER TABLE consignment_inventories + ADD CONSTRAINT consignment_inventories_source_type_check + CHECK (source_type IN ('manual', 'worker_portal', 'recycle')); + +COMMENT ON COLUMN consignment_inventories.source_type IS + '入库来源:manual 手工录入 / worker_portal 接单平台提报入库 / recycle 回收'; + +COMMENT ON TABLE consignment_submissions IS '打手通过接单平台提交的寄卖提报审核单'; + +-- 4) 区分"卖家"与"被指派的履约打手":worker_id 语义固定为卖家,指派结果单独存列, +-- 避免占库时覆盖卖家导致「我的寄卖」丢件。 +ALTER TABLE consignment_inventories + ADD COLUMN IF NOT EXISTS assigned_worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL; + +COMMENT ON COLUMN consignment_inventories.worker_id IS '寄卖卖家(提报人)打手ID,入库后不再变更'; +COMMENT ON COLUMN consignment_inventories.assigned_worker_id IS + '被指派执行本次履约的打手ID(仅押金履约模式占用,回库/撤单时清空)'; + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_assigned_worker_id + ON consignment_inventories(assigned_worker_id) + WHERE assigned_worker_id IS NOT NULL; + +-- 一件现货同一时刻最多绑定一张工单:防止同一件现货被多张工单占用。 +-- 先清理历史脏数据:同一工单被多件现货同时绑定时,保留工单 material 里记录的那一件, +-- 其余解绑回库(它们是此前"回库后又被另一张单占用"缺陷的产物)。 +WITH ranked AS ( + SELECT + ci.id, + ci.bound_work_order_id, + ROW_NUMBER() OVER ( + PARTITION BY ci.bound_work_order_id + ORDER BY + (ci.id = COALESCE(NULLIF(wo.material_json -> 'consignment' ->> 'inventoryId', '')::bigint, 0)) DESC, + ci.id DESC + ) AS rn + FROM consignment_inventories ci + JOIN work_orders wo ON wo.id = ci.bound_work_order_id + WHERE ci.bound_work_order_id IS NOT NULL + AND ci.status = 'consumed' +) +UPDATE consignment_inventories ci +SET + status = 'in_stock', + allocation_status = '待安排', + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + consumed_at = NULL, + remark = remark || ' [重复绑定自动解绑: 工单 ' || ranked.bound_work_order_id || ']', + updated_at = NOW() +FROM ranked +WHERE ci.id = ranked.id + AND ranked.rn > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_consignment_inventories_bound_work_order + ON consignment_inventories(bound_work_order_id) + WHERE bound_work_order_id IS NOT NULL AND status = 'consumed'; + +-- 5) 寄卖发布押金的冻结核销行幂等键:同一提报单只能有一笔发布冻结, +-- 防止未来任何绕过"先读台账再冻结"约定的调用方重复扣款。 +CREATE UNIQUE INDEX IF NOT EXISTS uniq_worker_wallet_ledgers_consignment_publish_freeze + ON worker_wallet_ledgers ((payload_json ->> 'consignmentSubmissionId')) + WHERE ledger_type = 'deposit_freeze' + AND payload_json ->> 'kind' = 'consignment_publish'; diff --git a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts index 5a1a3ba1..885d6196 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts @@ -170,6 +170,36 @@ export async function releaseConsignmentPublishDepositWithClient( return releaseAmount } +/** + * 押金归属与金额以「台账实际冻结核销行」为准。 + * + * 提报单上的 `worker_id` / `deposit_amount_fen` 都是可变字段(审核可以改派打手、 + * 运营可以调整金额),若用它反查钱包,会在改派后把押金退给从未发布过的打手 + * (对原发布者而言是静默不退的挂账);反过来重冻时也可能向错误钱包扣款。 + * 台账行是唯一不可变的事实来源。 + */ +async function getConsignmentDepositLedgerSnapshot( + client: PoolClient, + submissionId: number | string, +): Promise<{ ownerWorkerId: number; frozenAmountFen: number } | null> { + const res = await client.query<{ worker_id: number | string | null; amount: number | string }>( + ` + SELECT worker_id, ABS(amount) AS amount + FROM worker_wallet_ledgers + WHERE ledger_type = 'deposit_freeze' + AND COALESCE(payload_json->>'kind', '') = 'consignment_publish' + AND COALESCE(payload_json->>'consignmentSubmissionId', '') = $1 + ORDER BY id ASC + LIMIT 1 + `, + [String(submissionId)], + ) + const row = res.rows[0] + const ownerWorkerId = Number(row?.worker_id || 0) + if (!ownerWorkerId) return null + return { ownerWorkerId, frozenAmountFen: Math.max(0, Math.round(Number(row?.amount || 0))) } +} + async function getConsignmentSubmissionDepositTarget( client: PoolClient, submissionId: number | string, @@ -192,11 +222,15 @@ async function getConsignmentSubmissionDepositTarget( [submissionId], ) const row = submission.rows[0] - const workerId = Number(row?.worker_id || 0) + const ledger = await getConsignmentDepositLedgerSnapshot(client, submissionId) + + const workerId = ledger?.ownerWorkerId || Number(row?.worker_id || 0) if (!workerId) return null + return { workerId, - depositAmountFen: Math.max(0, Math.round(Number(row?.deposit_amount_fen || 0))), + depositAmountFen: + ledger?.frozenAmountFen || Math.max(0, Math.round(Number(row?.deposit_amount_fen || 0))), submissionNo: String(row?.submission_no || ''), } } diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 0667d805..b711c2eb 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -30,11 +30,12 @@ export type ConsignmentInventoryRow = { bound_work_order_id: string | number | null bound_platform_order_id: string shop_name: string - source_type: 'manual' | 'collection_link' | 'recycle' + source_type: 'manual' | 'worker_portal' | 'recycle' source_submission_id: string | number | null worker_id?: string | number | null worker_name?: string worker_phone?: string + assigned_worker_id?: string | number | null deposit_amount_fen?: number inbound_at: string consumed_at: string | null @@ -43,35 +44,9 @@ export type ConsignmentInventoryRow = { category_name?: string } -export type ConsignmentCollectLinkRow = { - id: string | number - token: string - title: string - game_name: string - category_id: string | number | null - system: string - server_zone: string - series: string - diamond_level: number - brilliance_count: number - preset_price_fen: number - inbound_batch: string - buyer_name: string - template_json: string | Record - is_multi_use: boolean - expires_at: string | null - enabled: boolean - created_by: string - created_at: string - updated_at: string - category_name?: string - submission_count?: number -} - export type ConsignmentSubmissionRow = { id: string | number submission_no: string - link_id: string | number | null game_name: string category_id: string | number | null item_name: string @@ -99,7 +74,6 @@ export type ConsignmentSubmissionRow = { created_at: string updated_at: string category_name?: string - link_title?: string } export type ConsignmentStatsSummary = { @@ -340,7 +314,7 @@ export async function createConsignmentInventory(input: { screenshotsJson?: unknown[] remark?: string allowMatching?: boolean - sourceType?: 'manual' | 'collection_link' | 'recycle' + sourceType?: 'manual' | 'worker_portal' | 'recycle' sourceSubmissionId?: number | null workerId?: number | null workerName?: string @@ -421,7 +395,6 @@ export async function updateConsignmentInventory( screenshotsJson?: unknown[] | undefined remark?: string | undefined allowMatching?: boolean | undefined - status?: 'in_stock' | 'consumed' | 'archived' | undefined allocationStatus?: string | undefined workerId?: number | null | undefined workerName?: string | undefined @@ -501,10 +474,6 @@ export async function updateConsignmentInventory( values.push(input.allowMatching) updates.push(`allow_matching = $${values.length}`) } - if (input.status !== undefined) { - values.push(input.status) - updates.push(`status = $${values.length}`) - } if (input.allocationStatus !== undefined) { values.push(input.allocationStatus) updates.push(`allocation_status = $${values.length}`) @@ -552,7 +521,16 @@ export async function updateConsignmentInventory( export async function claimConsignmentInventoryForWorkerWithClient( client: PoolClient, id: number | string, - input: { workerId: number; now: string; allocationStatus?: string }, + input: { + workerId: number + now: string + allocationStatus?: string + /** 占库时同时写入工单绑定,便于撤单/超时/售后按工单解绑回库。 */ + boundOrderId?: number | null + boundWorkOrderId?: number | null + boundPlatformOrderId?: string + shopName?: string + }, ): Promise { const res = await client.query( ` @@ -560,20 +538,41 @@ export async function claimConsignmentInventoryForWorkerWithClient( SET status = 'consumed', allocation_status = $1, - worker_id = $2, + assigned_worker_id = $2, consumed_at = $3, - updated_at = $3 - WHERE id = $4 AND status = 'in_stock' + updated_at = $3, + bound_order_id = $4, + bound_work_order_id = $5, + bound_platform_order_id = $6, + shop_name = $7 + WHERE id = $8 AND status = 'in_stock' RETURNING * `, - [input.allocationStatus || '已指派打手(押金履约中)', input.workerId, input.now, id], + [ + input.allocationStatus || '已指派打手(押金履约中)', + input.workerId, + input.now, + input.boundOrderId ?? null, + input.boundWorkOrderId ?? null, + input.boundPlatformOrderId ?? '', + input.shopName ?? '', + id, + ], ) return res.rows[0] || null } export async function claimConsignmentInventoryForWorker( id: number | string, - input: { workerId: number; now: string; allocationStatus?: string }, + input: { + workerId: number + now: string + allocationStatus?: string + boundOrderId?: number | null + boundWorkOrderId?: number | null + boundPlatformOrderId?: string + shopName?: string + }, ): Promise { return withTransaction((client) => claimConsignmentInventoryForWorkerWithClient(client, id, input), @@ -591,7 +590,7 @@ export async function releaseConsignmentInventoryClaimWithClient( SET status = 'in_stock', allocation_status = '待安排', - worker_id = NULL, + assigned_worker_id = NULL, bound_order_id = NULL, bound_work_order_id = NULL, bound_platform_order_id = '', @@ -617,6 +616,19 @@ export async function releaseConsignmentInventoryClaim( return withTransaction((client) => releaseConsignmentInventoryClaimWithClient(client, id, input)) } +/** 仅当库存未被"进行中的工单"占用时才允许人工改状态(作废 / 退回复库) */ +const NO_LIVE_BOUND_WORK_ORDER_SQL = ` + ( + ci.bound_work_order_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM work_orders wo + WHERE wo.id = ci.bound_work_order_id + AND wo.status NOT IN ('accepted', 'cancelled') + ) + ) +` + export async function archiveConsignmentInventoryWithClient( client: PoolClient, id: number | string, @@ -624,12 +636,13 @@ export async function archiveConsignmentInventoryWithClient( ): Promise { const res = await client.query( ` - UPDATE consignment_inventories + UPDATE consignment_inventories ci SET status = 'archived', allow_matching = FALSE, updated_at = $1 - WHERE id = $2 + WHERE ci.id = $2 + AND ${NO_LIVE_BOUND_WORK_ORDER_SQL} RETURNING * `, [now, id], @@ -645,22 +658,24 @@ export async function returnConsignmentInventoryToStockWithClient( ): Promise { const res = await client.query( ` - UPDATE consignment_inventories + UPDATE consignment_inventories ci SET status = 'in_stock', allocation_status = '待安排', + assigned_worker_id = NULL, bound_order_id = NULL, bound_work_order_id = NULL, bound_platform_order_id = '', shop_name = '', consumed_at = NULL, - allow_matching = TRUE, remark = CASE WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']' ELSE remark END, updated_at = $2 - WHERE id = $3 AND status = 'consumed' + WHERE ci.id = $3 + AND ci.status = 'consumed' + AND ${NO_LIVE_BOUND_WORK_ORDER_SQL} RETURNING * `, [reason, now, id], @@ -668,6 +683,44 @@ export async function returnConsignmentInventoryToStockWithClient( return res.rows[0] || null } +/** + * 工单终止 / 回退时解绑寄卖库存:把被该工单占用的现货放回在库池。 + * + * 撤单、退回大厅、超时处置、售后取消都必须调用本函数,否则现货会永久停留在 + * `consumed`(占成本但不再参与匹配)。发货押金仍保持冻结:现货回到在库池即等于 + * 重新挂卖,符合"发布冻结、回库重冻"的既有口径。 + */ +export async function releaseConsignmentInventoryBindingForWorkOrderWithClient( + client: PoolClient, + workOrderId: number | string, + input: { now: string; reason?: string }, +): Promise { + const res = await client.query( + ` + UPDATE consignment_inventories + SET + status = 'in_stock', + allocation_status = '待安排', + assigned_worker_id = NULL, + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + consumed_at = NULL, + remark = CASE + WHEN $3 = '' THEN remark + ELSE remark || ' [工单解绑回库: ' || $3 || ']' + END, + updated_at = $1 + WHERE bound_work_order_id = $2 + AND status = 'consumed' + RETURNING * + `, + [input.now, workOrderId, input.reason || ''], + ) + return res.rows[0] || null +} + /** 退回复库(将已出库物品恢复为在库待匹配) */ export async function returnConsignmentInventoryToStock( id: number | string, @@ -706,132 +759,10 @@ export async function listActiveConsignmentInventoriesForMatching(options?: { return res.rows } -// ---------------- 收集链接管理 ---------------- - -export async function listConsignmentCollectLinks(): Promise { - const sql = ` - SELECT - ccl.*, - wc.name AS category_name, - (SELECT COUNT(*)::integer FROM consignment_submissions cs WHERE cs.link_id = ccl.id) AS submission_count - FROM consignment_collect_links ccl - LEFT JOIN work_categories wc ON wc.id = ccl.category_id - ORDER BY ccl.id DESC - ` - const res = await query(sql) - return res.rows -} - -export async function getConsignmentCollectLinkByToken( - token: string, -): Promise { - const sql = ` - SELECT - ccl.*, - wc.name AS category_name - FROM consignment_collect_links ccl - LEFT JOIN work_categories wc ON wc.id = ccl.category_id - WHERE ccl.token = $1 AND ccl.enabled = TRUE - LIMIT 1 - ` - const res = await query(sql, [token]) - return res.rows[0] || null -} - -export async function createConsignmentCollectLink(input: { - token: string - title: string - gameName?: string - categoryId?: number | null - system?: string - serverZone?: string - series?: string - diamondLevel?: number - brillianceCount?: number - presetPriceFen?: number - inboundBatch?: string - buyerName?: string - templateJson?: Record - isMultiUse?: boolean - expiresAt?: string | null - createdBy?: string - now: string -}): Promise { - const sql = ` - INSERT INTO consignment_collect_links ( - token, title, game_name, category_id, system, server_zone, - series, diamond_level, brilliance_count, preset_price_fen, - inbound_batch, buyer_name, template_json, is_multi_use, - expires_at, enabled, created_by, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13::jsonb, $14, - $15, TRUE, $16, $17, $17 - ) - RETURNING * - ` - const res = await query(sql, [ - input.token, - input.title, - input.gameName || '和平精英', - input.categoryId || null, - input.system || '', - input.serverZone || '', - input.series || '', - input.diamondLevel || 1, - input.brillianceCount || 0, - input.presetPriceFen || 0, - input.inboundBatch || '', - input.buyerName || '', - toJsonString(input.templateJson || {}), - input.isMultiUse !== false, - input.expiresAt || null, - input.createdBy || '', - input.now, - ]) - return res.rows[0]! -} - -export async function updateConsignmentCollectLink( - id: number | string, - input: { - title?: string | undefined - enabled?: boolean | undefined - now: string - }, -): Promise { - const updates: string[] = [] - const values: unknown[] = [] - - if (input.title !== undefined) { - values.push(input.title) - updates.push(`title = $${values.length}`) - } - if (input.enabled !== undefined) { - values.push(input.enabled) - updates.push(`enabled = $${values.length}`) - } - - values.push(input.now) - updates.push(`updated_at = $${values.length}`) - - values.push(id) - const sql = ` - UPDATE consignment_collect_links - SET ${updates.join(', ')} - WHERE id = $${values.length} - RETURNING * - ` - const res = await query(sql, values) - return res.rows[0] || null -} - -// ---------------- 玩家提报与审核 ---------------- +// ---------------- 打手提报与审核 ---------------- export async function listConsignmentSubmissions(filters: { status?: string | undefined - linkId?: number | undefined workerId?: number | undefined keyword?: string | undefined page?: number | undefined @@ -844,10 +775,6 @@ export async function listConsignmentSubmissions(filters: { values.push(filters.status) conditions.push(`cs.status = $${values.length}`) } - if (filters.linkId) { - values.push(filters.linkId) - conditions.push(`cs.link_id = $${values.length}`) - } if (filters.workerId) { values.push(filters.workerId) conditions.push(`cs.worker_id = $${values.length}`) @@ -872,11 +799,9 @@ export async function listConsignmentSubmissions(filters: { const listSql = ` SELECT cs.*, - wc.name AS category_name, - ccl.title AS link_title + wc.name AS category_name FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id - LEFT JOIN consignment_collect_links ccl ON ccl.id = cs.link_id ${whereClause} ORDER BY cs.id DESC LIMIT $${values.length - 1} OFFSET $${values.length} @@ -891,11 +816,9 @@ export async function getConsignmentSubmissionById( const sql = ` SELECT cs.*, - wc.name AS category_name, - ccl.title AS link_title + wc.name AS category_name FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id - LEFT JOIN consignment_collect_links ccl ON ccl.id = cs.link_id WHERE cs.id = $1 LIMIT 1 ` @@ -905,7 +828,6 @@ export async function getConsignmentSubmissionById( export async function createConsignmentSubmission(input: { submissionNo: string - linkId?: number | null gameName?: string categoryId?: number | null itemName: string @@ -934,7 +856,6 @@ export async function createConsignmentSubmissionWithClient( client: PoolClient, input: { submissionNo: string - linkId?: number | null gameName?: string categoryId?: number | null itemName: string @@ -959,25 +880,24 @@ export async function createConsignmentSubmissionWithClient( ): Promise { const sql = ` INSERT INTO consignment_submissions ( - submission_no, link_id, game_name, category_id, item_name, + submission_no, game_name, category_id, item_name, system, server_zone, series, diamond_level, brilliance_count, seller_game_id, seller_payment_info, seller_phone, contact_info, content_json, screenshots_json, asking_price_fen, worker_id, worker_name, worker_phone, deposit_amount_fen, status, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, $8, $9, $10, - $11, $12, $13, $14, - $15::jsonb, $16::jsonb, $17, - $18, $19, $20, $21, - 'pending_review', $22, $22 + $1, $2, $3, $4, + $5, $6, $7, $8, $9, + $10, $11, $12, $13, + $14::jsonb, $15::jsonb, $16, + $17, $18, $19, $20, + 'pending_review', $21, $21 ) RETURNING * ` const res = await client.query(sql, [ input.submissionNo, - input.linkId || null, input.gameName || '和平精英', input.categoryId || null, input.itemName, @@ -1099,7 +1019,7 @@ export async function approveConsignmentSubmissionWithInbound(input: { $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::jsonb, $18::jsonb, $19, - TRUE, 'in_stock', '待安排', 'collection_link', + TRUE, 'in_stock', '待安排', 'worker_portal', $20, $21, $22, $23, $24, $25, $25, $25 ) diff --git a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts index c4ae93ce..b7797355 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts @@ -1,5 +1,6 @@ import { withTransaction } from '../../db/client.js' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' +import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' @@ -78,6 +79,12 @@ export async function cancelWorkerWorkOrder(input: { ) } + // 打手撤单:被本工单占用的寄卖现货放回在库池(否则现货永久停留在 consumed)。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: '打手撤单', + }) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'worker', @@ -181,6 +188,12 @@ export async function unassignWorkOrder(input: { ) } + // 取消指派:寄卖现货回到在库池等待重新匹配。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: '后台取消指派', + }) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', @@ -289,6 +302,12 @@ export async function returnAssignedWorkOrderToHall(input: { ) } + // 退回大厅:寄卖现货回到在库池;若该单原本走内部现货履约,运营可重新指派。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: '后台退回大厅', + }) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', @@ -397,6 +416,12 @@ export async function cancelAssignedWorkOrder(input: { ) } + // 撤单取消订单:寄卖现货回到在库池,避免占用成本却不再参与匹配。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: '后台撤单取消订单', + }) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', @@ -479,6 +504,13 @@ export async function reopenCancelledWorkOrder(input: { [input.now, input.workOrderId], ) + // 重新启用已取消工单:历史绑定若未清理干净,这里兜底解绑,避免"重新履约再次占库" + // 造成同一件现货被两张单子锁定。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: '已取消工单重新启用', + }) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index f5e9e483..d4cd80f2 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -1,5 +1,6 @@ import { withTransaction } from '../../db/client.js' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' +import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' @@ -318,6 +319,14 @@ export async function settleOverdueWorkOrder(input: { [targetStatus, input.now, input.workOrderId], ) + // 超时处置(重开/取消):被本工单占用的寄卖现货必须先放回在库池。 + // 内部现货履约单在赠送冷静期内同样带 deadline_at,若不解绑,现货会永久停留在 consumed: + // 既不再参与匹配,发货押金也没有释放入口。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: `超时处置(${effectivePolicy})`, + }) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'system', diff --git a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts index c705c091..714d182a 100644 --- a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts @@ -2,6 +2,7 @@ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' +import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, @@ -557,6 +558,11 @@ export async function reviewWorkerCancelRequest(input: { WHERE id = $2`, [input.now, workOrder.id], ) + // 打手退单申请通过:工单回到大厅,被本工单占用的寄卖现货同时放回在库池。 + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, workOrder.id, { + now: input.now, + reason: '打手退单申请通过', + }) if (releasedDepositAmount > 0) { const nextAvailable = Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releasedDepositAmount) diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index a3238b92..69ab7f9b 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -3,9 +3,7 @@ import { approveAdminConsignmentSubmission, archiveAdminConsignmentInventory, createAdminConsignmentInventory, - createAdminConsignmentLink, fetchAdminConsignmentInventories, - fetchAdminConsignmentLinks, fetchAdminConsignmentStats, fetchAdminConsignmentSubmissions, getAdminConsignmentInventory, @@ -14,7 +12,6 @@ import { returnAdminConsignmentInventory, toggleAdminConsignmentMatching, updateAdminConsignmentInventory, - updateAdminConsignmentLink, } from '../../../services/worker-platform/consignment-admin-service.js' import { assignWorkOrderWithConsignmentWorker, @@ -131,41 +128,6 @@ router.post( ), ) -// ---------------- 收集链接管理 ---------------- - -router.get( - '/worker-platform/consignment/links', - requireAdminPermission('consignment.view'), - createJsonHandler(() => fetchAdminConsignmentLinks(), { - successMessage: 'ok', - errorMessage: '读取收集链接列表失败', - scope: '[admin/consignment/links]', - }), -) - -router.post( - '/worker-platform/consignment/links', - requireAdminPermission('consignment.manage'), - createJsonHandler( - (req) => createAdminConsignmentLink(req.body, req.adminSession?.username || 'admin'), - { - successMessage: '收集链接已生成', - errorMessage: '生成收集链接失败', - scope: '[admin/consignment/links/create]', - }, - ), -) - -router.put( - '/worker-platform/consignment/links/:id', - requireAdminPermission('consignment.manage'), - createJsonHandler((req) => updateAdminConsignmentLink(String(req.params.id || ''), req.body), { - successMessage: '收集链接已更新', - errorMessage: '更新收集链接失败', - scope: '[admin/consignment/links/update]', - }), -) - // ---------------- 提报单审核 ---------------- router.get( diff --git a/apps/backend/src/routes/consignment.ts b/apps/backend/src/routes/consignment.ts deleted file mode 100644 index 7ac9b86b..00000000 --- a/apps/backend/src/routes/consignment.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Router } from 'express' -import { createRateLimitMiddleware } from '../middleware/rate-limit.js' -import { - getPublicConsignmentLink, - submitPublicConsignment, -} from '../services/worker-platform/consignment-public-service.js' -import { uploadFileAsset } from '../services/file-storage/file-storage-service.js' -import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' -import { uploadSingleFile } from './file-upload.js' - -const router = Router() - -const consignmentRateLimit = createRateLimitMiddleware({ - scope: 'consignment-public', - windowMs: 60_000, - max: 60, -}) - -router.get( - '/:token', - consignmentRateLimit, - createRouteHandler((req) => getPublicConsignmentLink(String(req.params.token || '')), { - successMessage: 'ok', - errorMessage: '获取收集信息失败', - scope: '[consignment/link]', - }), -) - -router.post( - '/:token/submit', - consignmentRateLimit, - createRouteHandler( - (req) => submitPublicConsignment(String(req.params.token || ''), req.body || {}), - { - successMessage: '寄卖申请已提交,客服审核后将与您联系', - errorMessage: '提交寄卖信息失败', - scope: '[consignment/submit]', - }, - ), -) - -router.post( - '/upload', - consignmentRateLimit, - uploadSingleFile, - createRouteHandler( - (req) => { - return uploadFileAsset({ - file: req.file, - scene: req.body?.scene || 'consignment-material', - uploaderType: 'consignment', - uploaderId: 'anonymous', - }) - }, - { - successMessage: '凭据图片已上传', - errorMessage: '凭据图片上传失败', - scope: '[consignment/upload]', - }, - ), -) - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) - -export default router diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index a2292af4..9bcc2551 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -5,7 +5,7 @@ import { publishWorkerConsignment, } from '../../services/worker-platform/consignment-worker-service.js' import { sendRouteError } from '../../utils/http.js' -import { getRequiredWorkerSession } from './session.js' +import { getRequiredWorkerSession, requireActiveWorker } from './session.js' const router = Router() @@ -13,7 +13,7 @@ const router = Router() * POST /api/v1/worker/consignment/publish * 打手在接单平台直接提报苏尔南手枪寄卖 */ -router.post('/consignment/publish', async (req, res) => { +router.post('/consignment/publish', requireActiveWorker, async (req, res) => { try { const session = getRequiredWorkerSession(req) const result = await publishWorkerConsignment(session.workerId, req.body || {}) 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 a95d17d8..0b8a9931 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -1,21 +1,17 @@ import { withTransaction } from '../../db/client.js' import { - type ConsignmentCollectLinkRow, type ConsignmentInventoryRow, type ConsignmentSubmissionRow, approveConsignmentSubmissionWithInbound, - createConsignmentCollectLink, createConsignmentInventory, getConsignmentInventoryById, getConsignmentInventoryStats, getConsignmentSubmissionById, - listConsignmentCollectLinks, listConsignmentInventories, listConsignmentSubmissions, rejectConsignmentSubmissionWithClient, archiveConsignmentInventoryWithClient, returnConsignmentInventoryToStockWithClient, - updateConsignmentCollectLink, updateConsignmentInventory, } from '../../repositories/worker-platform/consignment-repo.js' import { @@ -24,15 +20,15 @@ import { } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' -import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js' +import { generateConsignmentInventoryNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { ensureConsignmentPublishDepositFrozenBySubmissionWithClient, getConsignmentDepositErrorCode, releaseConsignmentPublishDepositBySubmissionWithClient, - releaseConsignmentPublishDepositWithClient, } from '../../repositories/worker-platform/consignment-deposit-repo.js' +import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js' function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never { const code = getConsignmentDepositErrorCode(error) @@ -53,6 +49,21 @@ function throwConsignmentDepositHttpError(error: unknown, restock: boolean): nev throw error } +/** + * 前端历史字段为 credentialsJson / screenshotsJson,早期接口写的是 credentials / screenshots, + * 两个键名都接受,避免客服录入的账号密码被静默丢弃。 + */ +function pickCredentialsJson(payload: JsonObject): Record | undefined { + const value = payload.credentialsJson ?? payload.credentials + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + return value as Record +} + +function pickScreenshotsJson(payload: JsonObject): unknown[] | undefined { + const value = payload.screenshotsJson ?? payload.screenshots + return Array.isArray(value) ? value : undefined +} + export function mapConsignmentInventory(row: ConsignmentInventoryRow) { return { id: Number(row.id), @@ -94,6 +105,7 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { workerId: row.worker_id ? Number(row.worker_id) : null, workerName: row.worker_name || '', workerPhone: row.worker_phone || '', + assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null, depositAmountFen: Number(row.deposit_amount_fen || 0), inboundAt: row.inbound_at || row.created_at, consumedAt: row.consumed_at, @@ -102,42 +114,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { } } -export function mapConsignmentCollectLink(row: ConsignmentCollectLinkRow) { - return { - id: Number(row.id), - token: row.token, - title: row.title, - gameName: row.game_name, - categoryId: row.category_id ? Number(row.category_id) : null, - categoryName: row.category_name, - system: row.system, - serverZone: row.server_zone, - series: row.series, - diamondLevel: Number(row.diamond_level || 1), - brillianceCount: Number(row.brilliance_count || 0), - presetPriceFen: Number(row.preset_price_fen || 0), - inboundBatch: row.inbound_batch || '', - buyerName: row.buyer_name || '', - templateJson: - row.template_json && typeof row.template_json === 'object' - ? (row.template_json as Record) - : safeParseJson(row.template_json), - isMultiUse: Boolean(row.is_multi_use), - expiresAt: row.expires_at, - enabled: Boolean(row.enabled), - createdBy: row.created_by || '', - createdAt: row.created_at, - updatedAt: row.updated_at, - submissionCount: Number(row.submission_count || 0), - } -} - export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { return { id: Number(row.id), submissionNo: row.submission_no, - linkId: row.link_id ? Number(row.link_id) : null, - linkTitle: row.link_title, gameName: row.game_name, categoryId: row.category_id ? Number(row.category_id) : null, categoryName: row.category_name, @@ -252,11 +232,8 @@ export async function createAdminConsignmentInventory(payload: JsonObject, actor sellerGameId: String(payload.sellerGameId || '').trim(), sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(), sellerPhone: String(payload.sellerPhone || '').trim(), - credentialsJson: - payload.credentials && typeof payload.credentials === 'object' - ? (payload.credentials as Record) - : {}, - screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [], + credentialsJson: pickCredentialsJson(payload) ?? {}, + screenshotsJson: pickScreenshotsJson(payload) ?? [], remark: String(payload.remark || '').trim(), allowMatching: payload.allowMatching !== false, sourceType: 'manual', @@ -276,6 +253,14 @@ export async function updateAdminConsignmentInventory( _actorName = '', ) { const now = nowIso() + // 状态只能通过「下架作废 / 退回复库 / 匹配开关」这些带守卫的动作变更, + // 编辑接口直接写 status 会绕过「现货被进行中工单占用」保护与押金处理。 + if (payload.status !== undefined) { + throw createHttpError('编辑接口不支持直接修改物品状态', { + statusCode: 400, + errorCode: 'consignment_status_update_forbidden', + }) + } const updated = await updateConsignmentInventory(id, { itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined, system: payload.system !== undefined ? String(payload.system).trim() : undefined, @@ -305,14 +290,10 @@ export async function updateAdminConsignmentInventory( ? String(payload.sellerPaymentInfo).trim() : undefined, sellerPhone: payload.sellerPhone !== undefined ? String(payload.sellerPhone).trim() : undefined, - credentialsJson: - payload.credentials && typeof payload.credentials === 'object' - ? (payload.credentials as Record) - : undefined, - screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined, + credentialsJson: pickCredentialsJson(payload), + screenshotsJson: pickScreenshotsJson(payload), remark: payload.remark !== undefined ? String(payload.remark).trim() : undefined, allowMatching: payload.allowMatching !== undefined ? Boolean(payload.allowMatching) : undefined, - status: payload.status !== undefined ? (payload.status as any) : undefined, workerId: payload.workerId !== undefined ? payload.workerId @@ -362,17 +343,46 @@ export async function archiveAdminConsignmentInventory(id: number | string) { return archived }) if (!updated) { - throw createHttpError('寄卖物品不存在', { - statusCode: 404, - errorCode: 'consignment_not_found', - }) + await throwConsignmentInventoryBlockedError(id, '作废') } - return mapConsignmentInventory(updated) + const mapped = mapConsignmentInventory(updated!) + if (mapped.workerId) { + publishWorkerWalletRealtimeChange(mapped.workerId) + } + return mapped } catch (error) { throwConsignmentDepositHttpError(error, false) } } +/** + * 人工改状态被「现货正被进行中工单占用」挡住时,给出可操作提示, + * 而不是笼统地报"不存在"。 + */ +async function throwConsignmentInventoryBlockedError( + id: number | string, + actionLabel: string, +): Promise { + const current = await getConsignmentInventoryById(id) + if (!current) { + throw createHttpError('寄卖物品不存在', { + statusCode: 404, + errorCode: 'consignment_not_found', + }) + } + const boundWorkOrderId = Number(current.bound_work_order_id || 0) + if (boundWorkOrderId > 0 && current.status === 'consumed') { + throw createHttpError( + `该现货正被工单 #${boundWorkOrderId} 使用,无法${actionLabel};请先撤销寄售履约或撤单,再操作`, + { statusCode: 409, errorCode: 'consignment_inventory_in_use' }, + ) + } + throw createHttpError(`当前状态(${current.status})不支持${actionLabel}`, { + statusCode: 409, + errorCode: 'consignment_inventory_status_invalid', + }) +} + /** 已出库物品退回复库 */ export async function returnAdminConsignmentInventory( id: number | string, @@ -395,84 +405,41 @@ export async function returnAdminConsignmentInventory( return returned }) if (!updated) { + const current = await getConsignmentInventoryById(id) + if (!current) { + throw createHttpError('寄卖物品不存在', { + statusCode: 404, + errorCode: 'consignment_not_found', + }) + } + if (current.status === 'consumed') { + await throwConsignmentInventoryBlockedError(id, '退回复库') + } throw createHttpError('只有已出库物品可以退回复库', { statusCode: 409, errorCode: 'consignment_not_returnable', }) } - return mapConsignmentInventory(updated) + const mapped = mapConsignmentInventory(updated) + if (mapped.workerId) { + publishWorkerWalletRealtimeChange(mapped.workerId) + } + return mapped } catch (error) { throwConsignmentDepositHttpError(error, true) } } -// ---------------- 收集链接管理 ---------------- - -export async function fetchAdminConsignmentLinks() { - const items = await listConsignmentCollectLinks() - return { items: items.map(mapConsignmentCollectLink) } -} - -export async function createAdminConsignmentLink(payload: JsonObject, actorName = '') { - const title = String(payload.title || '').trim() - if (!title) { - throw createHttpError('链接标题不能为空', { statusCode: 400, errorCode: 'invalid_link_title' }) - } - - const now = nowIso() - const token = randomToken(16) - - const row = await createConsignmentCollectLink({ - token, - title, - gameName: String(payload.gameName || '和平精英').trim(), - categoryId: payload.categoryId ? Number(payload.categoryId) : null, - system: String(payload.system || '').trim(), - serverZone: String(payload.serverZone || '').trim(), - series: String(payload.series || '').trim(), - diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))), - brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))), - presetPriceFen: Math.max(0, Math.round(Number(payload.presetPriceFen || 0))), - inboundBatch: String(payload.inboundBatch || '').trim(), - buyerName: String(payload.buyerName || actorName || '').trim(), - templateJson: - payload.template && typeof payload.template === 'object' - ? (payload.template as Record) - : {}, - isMultiUse: payload.isMultiUse !== false, - expiresAt: payload.expiresAt ? String(payload.expiresAt) : null, - createdBy: actorName, - now, - }) - - return mapConsignmentCollectLink(row) -} - -export async function updateAdminConsignmentLink(id: number | string, payload: JsonObject) { - const now = nowIso() - const updated = await updateConsignmentCollectLink(id, { - title: payload.title !== undefined ? String(payload.title).trim() : undefined, - enabled: payload.enabled !== undefined ? Boolean(payload.enabled) : undefined, - now, - }) - if (!updated) { - throw createHttpError('收集链接不存在', { statusCode: 404, errorCode: 'link_not_found' }) - } - return mapConsignmentCollectLink(updated) -} - // ---------------- 提报单审核 ---------------- export async function fetchAdminConsignmentSubmissions(query: JsonObject = {}) { const status = query.status ? String(query.status).trim() : undefined - const linkId = query.linkId ? Number(query.linkId) : undefined const keyword = query.keyword ? String(query.keyword).trim() : undefined const page = query.page ? Number(query.page) : 1 const pageSize = query.pageSize ? Number(query.pageSize) : 20 const res = await listConsignmentSubmissions({ status, - linkId, keyword, page, pageSize, @@ -560,11 +527,8 @@ export async function approveAdminConsignmentSubmission( sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(), sellerPaymentInfo: String(payload.sellerPaymentInfo || submission.seller_payment_info).trim(), sellerPhone: phoneToLookup, - credentialsJson: - payload.credentials && typeof payload.credentials === 'object' - ? (payload.credentials as Record) - : undefined, - screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined, + credentialsJson: pickCredentialsJson(payload), + screenshotsJson: pickScreenshotsJson(payload), reviewNote: String(payload.reviewNote || '').trim(), workerId, workerName, @@ -600,8 +564,8 @@ export async function rejectAdminConsignmentSubmission( now, }) if (rejected?.worker_id) { - await releaseConsignmentPublishDepositWithClient(client, { - workerId: Number(rejected.worker_id), + // 释放走"按提报单"版本:押金归属以冻结核销行为准,不受审核改派影响。 + await releaseConsignmentPublishDepositBySubmissionWithClient(client, { submissionId: rejected.id, note: `寄卖提报驳回退还押金 ${rejected.submission_no}`, now, @@ -615,5 +579,8 @@ export async function rejectAdminConsignmentSubmission( errorCode: 'submission_already_reviewed', }) } + if (updated.worker_id) { + publishWorkerWalletRealtimeChange(Number(updated.worker_id)) + } return mapConsignmentSubmission(updated) } diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index a5d67cdc..f4724f5e 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -312,12 +312,19 @@ export async function assignWorkOrderWithConsignmentWorker(input: { } const now = nowIso() + const orderMaterial = safeParseJson(workOrder.material_json) + const orderSource = safeParseJson(orderMaterial.source) + const assignShopName = String(orderSource.shopName || orderSource.shopTitle || '').trim() // 1. 先原子占用库存:并发指派时只有一方能把 status 从 in_stock 改成 consumed, - // 避免同一件现货被两张工单同时认领。 + // 避免同一件现货被两张工单同时认领。同时写入工单绑定,便于撤单/超时按工单解绑回库。 const claimed = await claimConsignmentInventoryForWorker(inventory.id, { workerId: Number(targetWorkerId), now, + boundOrderId: workOrder.order_id ? Number(workOrder.order_id) : null, + boundWorkOrderId: Number(workOrder.id), + boundPlatformOrderId: String(workOrder.platform_order_id || ''), + shopName: assignShopName, }) if (!claimed) { throw createHttpError('寄卖库存已被其他操作占用,请刷新重试', { @@ -334,7 +341,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: { input.actorName, ) - const material = safeParseJson(workOrder.material_json) + const material = orderMaterial // 3. 将寄售手枪信息写入工单 material const updatedMaterial = { @@ -409,12 +416,36 @@ export async function assignWorkOrderWithConsignmentWorker(input: { } } +/** + * 允许「撤销寄售履约」的工单状态白名单。 + * + * 已验收(accepted)的工单代表钱已结算、货已交付,撤销会把工单改回未分配、把现货放回 + * 在库池并重新冻结已退还的押金 —— 等于同一件现货二次出货,因此一律禁止; + * 买家退款/纠纷请走售后流程。已取消(cancelled)的工单同样禁止撤销。 + */ +const CONSIGNMENT_REVOCABLE_WORK_ORDER_STATUSES: readonly string[] = [ + WORK_ORDER_STATUS.IN_PROGRESS, + WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + WORK_ORDER_STATUS.PROBLEM, +] + +export function assertConsignmentRevocableWorkOrderStatus(status: unknown): void { + const normalized = String(status || '').trim() + if (!CONSIGNMENT_REVOCABLE_WORK_ORDER_STATUSES.includes(normalized)) { + throw createHttpError('该工单已验收或已取消,不能撤销寄售履约;退款与纠纷请走售后流程', { + statusCode: 409, + errorCode: 'consignment_revoke_status_invalid', + }) + } +} + /** * 校验工单上的寄售信息是否可走「撤销寄售履约」流程,并返回关联库存 ID。 * - * 只有“内部现货占库”履约才能走本流程;押金履约模式(worker_deposit)由打手接单并 - * 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」, - * 否则打手押金不会释放,会造成资金挂账。 + * 只有"内部现货占库"履约才能走本流程;押金履约模式(worker_deposit)由打手接单并 + * 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」—— + * 这两条链路会调用 releaseConsignmentInventoryBindingForWorkOrderWithClient 自动解绑 + * 寄卖库存,打手押金由既有逻辑释放,避免资金挂账与库存悬空。 */ export function resolveRevocableConsignmentInventoryId( consignment: Record, @@ -457,6 +488,15 @@ export async function revokeWorkOrderConsignmentFulfillment(input: { if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } + assertConsignmentRevocableWorkOrderStatus(workOrder.status) + + const reason = String(input.reason || '').trim() + if (!reason) { + throw createHttpError('请填写撤销原因', { + statusCode: 400, + errorCode: 'consignment_revoke_reason_required', + }) + } const material = safeParseJson(workOrder.material_json) const consignmentInfo = safeParseJson(material.consignment) @@ -466,70 +506,85 @@ export async function revokeWorkOrderConsignmentFulfillment(input: { try { const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => { + // 0. 事务内重新加锁读单:避免与「验收 / 并发撤销」竞争时用陈旧快照覆盖状态。 + const lockedRes = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [workOrder.id], + ) + const lockedOrder = lockedRes.rows[0] + if (!lockedOrder) { + throw createHttpError('工单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + assertConsignmentRevocableWorkOrderStatus(lockedOrder.status) + const lockedMaterial = safeParseJson(lockedOrder.material_json) + resolveRevocableConsignmentInventoryId(safeParseJson(lockedMaterial.consignment)) + let inv: ConsignmentInventoryRow | null = null - // 1. 处理关联库存解绑与状态流转 - if (inventoryId) { - if (input.action === 'archive_inventory') { - // 物品异常作废 - const invSql = ` - UPDATE consignment_inventories - SET - status = 'archived', - allow_matching = FALSE, - bound_order_id = NULL, - bound_work_order_id = NULL, - bound_platform_order_id = '', - shop_name = '', - remark = remark || ' [交付异常作废: ' || $1 || ']', - updated_at = $2 - WHERE id = $3 - RETURNING * - ` - const invRes = await client.query(invSql, [ - input.reason || '异常作废', - now, - inventoryId, - ]) - inv = invRes.rows[0] || null - } else { - // 恢复在库 (in_stock) - const invSql = ` - UPDATE consignment_inventories - SET - status = 'in_stock', - allocation_status = '待安排', - bound_order_id = NULL, - bound_work_order_id = NULL, - bound_platform_order_id = '', - shop_name = '', - consumed_at = NULL, - remark = remark || ' [撤销回库: ' || $1 || ']', - updated_at = $2 - WHERE id = $3 - RETURNING * - ` - const invRes = await client.query(invSql, [ - input.reason || '撤单回库', - now, - inventoryId, - ]) - inv = invRes.rows[0] || null - } + // 1. 处理关联库存解绑与状态流转。 + // 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑, + // 造成同一件现货被多张工单履约。 + const invSql = + input.action === 'archive_inventory' + ? ` + UPDATE consignment_inventories + SET + status = 'archived', + allow_matching = FALSE, + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + remark = remark || ' [交付异常作废: ' || $1 || ']', + updated_at = $2 + WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4 + RETURNING * + ` + : ` + UPDATE consignment_inventories + SET + status = 'in_stock', + allocation_status = '待安排', + assigned_worker_id = NULL, + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + consumed_at = NULL, + remark = remark || ' [撤销回库: ' || $1 || ']', + updated_at = $2 + WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4 + RETURNING * + ` + const invRes = await client.query(invSql, [ + reason, + now, + inventoryId, + lockedOrder.id, + ]) + inv = invRes.rows[0] || null + if (!inv) { + throw createHttpError('该现货已被其他操作解绑或占用,请刷新工单后重试', { + statusCode: 409, + errorCode: 'consignment_inventory_binding_changed', + }) } - const submissionId = consignmentInfo.sourceSubmissionId || inv?.source_submission_id + const submissionId = consignmentInfo.sourceSubmissionId || inv.source_submission_id if (submissionId) { if (input.action === 'archive_inventory') { await releaseConsignmentPublishDepositBySubmissionWithClient(client, { submissionId, - note: `寄卖履约作废退还押金 ${inv?.inventory_no || submissionId}`, + note: `寄卖履约作废退还押金 ${inv.inventory_no || submissionId}`, now, }) } else { await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, { submissionId, - note: `寄卖撤销回库重新冻结押金 ${inv?.inventory_no || submissionId}`, + note: `寄卖撤销回库重新冻结押金 ${inv.inventory_no || submissionId}`, now, }) } @@ -541,46 +596,67 @@ export async function revokeWorkOrderConsignmentFulfillment(input: { nextOrderStatus = WORK_ORDER_STATUS.CANCELLED } - const { consignment: _removed, ...cleanMaterial } = material + const { consignment: _removed, ...cleanMaterial } = lockedMaterial const archiveHistory = [ - ...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []), + ...(Array.isArray(lockedMaterial.consignmentHistory) + ? lockedMaterial.consignmentHistory + : []), { ...consignmentInfo, revokedAt: now, revokedBy: input.actorName, - revokeReason: input.reason, + revokeReason: reason, }, ] + // 工单回到「未指派 / 已取消」前,要把上一次交付留下的指派、时限、验收草稿与 + // 问题单痕迹一并清干净;否则重新指派时新打手会看到上一手的 deadline 与验收内容。 const woSql = ` UPDATE work_orders SET status = $1, material_json = $2::jsonb, + assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + submitted_at = NULL, + accepted_at = NULL, + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + problem_note = '', + published_at = NULL, + pinned_at = NULL, ${RESET_GIFT_FLOW_SQL}, updated_at = $3 - WHERE id = $4 + WHERE id = $4 AND status = $5 RETURNING * ` const woRes = await client.query(woSql, [ nextOrderStatus, JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }), now, - workOrder.id, + lockedOrder.id, + lockedOrder.status, ]) - const wo = woRes.rows[0]! + const wo = woRes.rows[0] + if (!wo) { + throw createHttpError('工单状态已被其他操作变更,请刷新后重试', { + statusCode: 409, + errorCode: 'work_order_status_changed', + }) + } // 3. 记录事件 await createWorkOrderEventWithClient(client, { - workOrderId: workOrder.id, + workOrderId: lockedOrder.id, actorType: 'operator', actorId: input.actorName || 'admin', eventType: 'consignment_revoked', - fromStatus: workOrder.status, + fromStatus: lockedOrder.status, toStatus: nextOrderStatus, payloadJson: JSON.stringify({ action: input.action, - reason: input.reason, + reason, inventoryId, }), now, @@ -589,7 +665,10 @@ export async function revokeWorkOrderConsignmentFulfillment(input: { return { updatedOrder: wo, updatedInventory: inv } }) - publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true }) + publishWorkOrderRealtimeChange({ + workOrderId: Number(updatedOrder.id), + hallChanged: updatedOrder.status === WORK_ORDER_STATUS.UNASSIGNED, + }) return { order: mapWorkOrderAdmin(updatedOrder), diff --git a/apps/backend/src/services/worker-platform/consignment-public-service.ts b/apps/backend/src/services/worker-platform/consignment-public-service.ts deleted file mode 100644 index 0c06a136..00000000 --- a/apps/backend/src/services/worker-platform/consignment-public-service.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - createConsignmentSubmission, - getConsignmentCollectLinkByToken, -} from '../../repositories/worker-platform/consignment-repo.js' -import { getWorkerUserByPhone } from '../../repositories/worker-platform/index.js' -import type { JsonObject } from '../../types/json.js' -import { createHttpError } from '../../utils/http.js' -import { generateConsignmentSubmissionNo } from '../../utils/random.js' -import { nowIso } from '../../utils/time.js' -import { getConsignmentPublishDepositFen } from './worker-finance-config-service.js' - -export async function getPublicConsignmentLink(token: string) { - const cleanToken = String(token || '').trim() - if (!cleanToken) { - throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' }) - } - - const link = await getConsignmentCollectLinkByToken(cleanToken) - if (!link) { - throw createHttpError('收集链接不存在或已被关闭', { - statusCode: 404, - errorCode: 'link_not_found', - }) - } - - if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) { - throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' }) - } - - return { - title: link.title, - gameName: link.game_name, - categoryId: link.category_id, - categoryName: link.category_name, - system: link.system, - serverZone: link.server_zone, - series: link.series, - diamondLevel: link.diamond_level, - brillianceCount: link.brilliance_count, - presetPriceFen: link.preset_price_fen, - template: link.template_json, - } -} - -export async function submitPublicConsignment(token: string, payload: JsonObject) { - const cleanToken = String(token || '').trim() - if (!cleanToken) { - throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' }) - } - - const link = await getConsignmentCollectLinkByToken(cleanToken) - if (!link) { - throw createHttpError('收集链接不存在或已被关闭', { - statusCode: 404, - errorCode: 'link_not_found', - }) - } - - if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) { - throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' }) - } - - const itemName = String( - payload.itemName || - link.title || - `${link.system}${link.server_zone}${link.series}${link.diamond_level}钻`, - ).trim() - const contactInfo = String(payload.contactInfo || payload.sellerContact || '').trim() - if (!contactInfo) { - throw createHttpError('请填写微信号或QQ号以便联系与核实', { - statusCode: 400, - errorCode: 'contact_info_required', - }) - } - - const contentPayload = - payload.contentJson && typeof payload.contentJson === 'object' - ? (payload.contentJson as Record) - : payload.content && typeof payload.content === 'object' - ? (payload.content as Record) - : {} - - const characterName = String(contentPayload.characterName || payload.characterName || '').trim() - const characterId = String(contentPayload.characterId || payload.characterId || '').trim() - if (!characterName) { - throw createHttpError('请填写游戏内角色名', { - statusCode: 400, - errorCode: 'character_name_required', - }) - } - if (!characterId) { - throw createHttpError('请填写角色数字ID', { - statusCode: 400, - errorCode: 'character_id_required', - }) - } - - const screenshots = Array.isArray(payload.screenshotsJson) - ? payload.screenshotsJson - : Array.isArray(payload.screenshots) - ? payload.screenshots - : [] - if (screenshots.length === 0) { - throw createHttpError('请上传游戏主页截图以供核验', { - statusCode: 400, - errorCode: 'screenshot_required', - }) - } - - const now = nowIso() - const submissionNo = generateConsignmentSubmissionNo() - const askingPriceFen = Math.max( - 100, - Math.round(Number(payload.askingPriceFen || link.preset_price_fen || 0)), - ) - - const sellerPhone = String(payload.sellerPhone || '').trim() - if (!sellerPhone) { - throw createHttpError('请填写联系电话', { - statusCode: 400, - errorCode: 'seller_phone_required', - }) - } - - let workerId: number | null = null - let workerName = '' - let workerPhone = '' - - if (sellerPhone) { - const matchedWorker = await getWorkerUserByPhone(sellerPhone) - if (matchedWorker) { - workerId = Number(matchedWorker.id) - workerName = matchedWorker.display_name || matchedWorker.username || '' - workerPhone = matchedWorker.phone || sellerPhone - } - } - - const diamondLevel = Math.max( - 1, - Math.min(7, Number(payload.diamondLevel || link.diamond_level || 1)), - ) - const brillianceCount = Math.max( - 0, - Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)), - ) - - const sellerGameId = String(payload.sellerGameId || `${characterName} (${characterId})`).trim() - - const submission = await createConsignmentSubmission({ - submissionNo, - linkId: Number(link.id), - gameName: link.game_name, - categoryId: link.category_id ? Number(link.category_id) : null, - itemName, - system: String(payload.system || link.system || '').trim(), - serverZone: String(payload.serverZone || link.server_zone || '').trim(), - series: String(payload.series || link.series || '').trim(), - diamondLevel, - brillianceCount, - sellerGameId, - sellerPaymentInfo: String(payload.sellerPaymentInfo || '走接单平台钱包结算').trim(), - sellerPhone, - contactInfo, - contentJson: { - ...contentPayload, - characterName, - characterId, - sellerNote: String(contentPayload.sellerNote || payload.sellerNote || '').trim(), - channel: 'collect_link', - }, - screenshotsJson: screenshots, - askingPriceFen, - workerId, - workerName, - workerPhone, - depositAmountFen: Math.max( - 0, - Math.round(Number(payload.depositAmountFen ?? getConsignmentPublishDepositFen())), - ), - now, - }) - - return { - submissionNo: submission.submission_no, - status: submission.status, - submittedAt: submission.created_at, - } -} diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index 2c682652..802670f2 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -10,6 +10,7 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { generateConsignmentSubmissionNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' +import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js' import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js' export function getConsignmentPublishDepositFen() { @@ -106,7 +107,6 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb const submission = await withTransaction(async (client) => { const created = await createConsignmentSubmissionWithClient(client, { submissionNo, - linkId: null, gameName: '和平精英', categoryId: null, itemName, @@ -146,6 +146,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb return created }) + // 发布冻结押金后推送钱包变更,避免打手端余额展示滞后。 + publishWorkerWalletRealtimeChange(Number(worker.id)) + return { id: submission.id, submissionNo: submission.submission_no, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 3a7e9051..99874711 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -61,11 +61,17 @@ import { releaseConsignmentPublishDepositWithClient, } from '../../repositories/worker-platform/consignment-deposit-repo.js' import { + archiveConsignmentInventoryWithClient, claimConsignmentInventoryForWorkerWithClient, + releaseConsignmentInventoryBindingForWorkOrderWithClient, releaseConsignmentInventoryClaimWithClient, + returnConsignmentInventoryToStockWithClient, } from '../../repositories/worker-platform/consignment-repo.js' import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js' -import { resolveRevocableConsignmentInventoryId } from './consignment-fulfillment-service.js' +import { + assertConsignmentRevocableWorkOrderStatus, + resolveRevocableConsignmentInventoryId, +} from './consignment-fulfillment-service.js' test('后台权限白名单覆盖全部迁移登记的权限,避免保存角色权限时被静默清除', () => { const migrationsDir = fileURLToPath(new URL('../../db/migrations', import.meta.url)) @@ -94,11 +100,11 @@ test('后台权限白名单覆盖全部迁移登记的权限,避免保存角 }) test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用返回 null', async () => { - const statements: string[] = [] + const statements: Array<{ sql: string; values: unknown[] }> = [] let matched = false const client = { - query: async (sql: string) => { - statements.push(sql) + query: async (sql: string, values: unknown[]) => { + statements.push({ sql, values }) return { rows: matched ? [{ id: 9, inventory_no: 'INV2609150001' }] : [] } }, } as unknown as PoolClient @@ -113,11 +119,110 @@ test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用 const claimed = await claimConsignmentInventoryForWorkerWithClient(client, 9, { workerId: 3, now: '2026-09-15T00:00:00.000Z', + boundOrderId: 66, + boundWorkOrderId: 77, + boundPlatformOrderId: 'PT-77', + shopName: '旗舰店', }) assert.equal(claimed?.inventory_no, 'INV2609150001') - assert.ok(statements[0]!.includes("WHERE id = $4 AND status = 'in_stock'")) - assert.ok(statements[0]!.includes('RETURNING *')) + const claimSql = statements[1]!.sql + assert.ok(claimSql.includes("WHERE id = $8 AND status = 'in_stock'")) + assert.ok(claimSql.includes('RETURNING *')) + // 占库写入工单绑定;被指派打手写 assigned_worker_id,卖家 worker_id 不再被覆盖 + assert.ok(claimSql.includes('assigned_worker_id = $2')) + assert.ok(claimSql.includes('bound_work_order_id = $5')) + assert.deepEqual(statements[1]!.values.slice(3, 8), [66, 77, 'PT-77', '旗舰店', 9]) +}) + +test('撤销寄售履约的状态白名单:只允许进行中/待验收/问题单', () => { + for (const status of ['in_progress', 'pending_acceptance', 'problem']) { + assert.doesNotThrow(() => assertConsignmentRevocableWorkOrderStatus(status)) + } + for (const status of ['accepted', 'cancelled', 'unassigned', 'open', '']) { + assert.throws( + () => assertConsignmentRevocableWorkOrderStatus(status), + /不能撤销寄售履约/, + `状态 ${status} 必须被拒绝`, + ) + } +}) + +test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开匹配开关', async () => { + const statements: string[] = [] + const client = { + query: async (sql: string) => { + statements.push(sql) + return { rows: [] } + }, + } as unknown as PoolClient + + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 77, { + now: '2026-09-15T00:00:00.000Z', + reason: '后台撤单取消订单', + }) + const sql = statements[0]! + assert.ok(sql.includes('WHERE bound_work_order_id = $2')) + assert.ok(sql.includes("status = 'consumed'")) + assert.ok(!sql.includes('allow_matching')) +}) + +test('人工作废与退回复库在现货仍被进行中工单占用时不改状态', async () => { + const statements: string[] = [] + const client = { + query: async (sql: string) => { + statements.push(sql) + return { rows: [] } + }, + } as unknown as PoolClient + + await archiveConsignmentInventoryWithClient(client, 9, '2026-09-15T00:00:00.000Z') + await returnConsignmentInventoryToStockWithClient( + client, + 9, + '买家退货', + '2026-09-15T00:00:00.000Z', + ) + + for (const sql of statements) { + assert.ok( + sql.includes('FROM work_orders wo') && sql.includes("NOT IN ('accepted', 'cancelled')"), + `缺少进行中工单占用保护:${sql}`, + ) + } + // 退回复库不得再强制打开匹配开关(避免覆盖运营手动暂停) + assert.ok(!statements[1]!.includes('allow_matching')) +}) + +test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed', async () => { + const readFile = await import('node:fs/promises') + const cancelRepo = await readFile.readFile( + new URL('../../repositories/worker-platform/work-order-cancel-repo.ts', import.meta.url), + 'utf8', + ) + const issueRepo = await readFile.readFile( + new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url), + 'utf8', + ) + const cancelRequestRepo = await readFile.readFile( + new URL('../../repositories/worker-platform/worker-cancel-request-repo.ts', import.meta.url), + 'utf8', + ) + for (const [name, source] of [ + ['work-order-cancel-repo', cancelRepo], + ['work-order-issue-repo', issueRepo], + ['worker-cancel-request-repo', cancelRequestRepo], + ] as const) { + assert.ok( + source.includes('releaseConsignmentInventoryBindingForWorkOrderWithClient('), + `${name} 缺少寄卖解绑调用`, + ) + } + // 撤单仓库内 5 条终态链路:打手撤单 / 取消指派 / 退回大厅 / 撤单取消 / 重新启用 + assert.equal( + cancelRepo.match(/releaseConsignmentInventoryBindingForWorkOrderWithClient\(/g)?.length, + 5, + ) }) test('寄卖指派失败回滚只在物品仍为 consumed 时放回在库', async () => { diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 87e6145a..86fe17a1 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -35,7 +35,6 @@ const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage')) const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage')) const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage')) const CollectPage = lazy(() => import('@/pages/collect/CollectPage')) -const ConsignmentCollectPage = lazy(() => import('@/pages/collect/ConsignmentCollectPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage')) const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage')) @@ -131,7 +130,6 @@ function AdminApplication() { } /> } /> - } /> } /> diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 94293c39..4f407c35 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -305,7 +305,7 @@ export default function ConsignmentInventoryPanel() { if (!returnItem) return try { await returnAdminConsignmentInventory(returnItem.id, { reason: returnReason }) - message.success('物品已退单回库,重新进入待匹配池!') + message.success('物品已退单回库,可在列表中查看匹配开关状态') setReturnItem(null) setReturnReason('') await refresh() @@ -339,9 +339,13 @@ export default function ConsignmentInventoryPanel() { {record.inventoryNo} - {record.sourceType === 'collection_link' ? ( + {record.sourceType === 'worker_portal' ? ( - 链接收集 + 打手端提报 + + ) : record.sourceType === 'recycle' ? ( + + 回收 ) : ( @@ -1249,7 +1253,7 @@ export default function ConsignmentInventoryPanel() { 退回复库吗?
- 回库后,物品状态将重新变为【在库】,允许匹配开关将自动开启,可重新匹配履约其他工单。 + 回库后,物品状态将重新变为【在库】。允许匹配开关保持原设置:若该物品此前被手动暂停匹配,回库后需在列表里手动开启才会重新参与匹配。
diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index 04da6ff7..cbfb8d60 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -141,7 +141,6 @@ export default function ConsignmentSubmissionsPanel() { function resolveSubmissionSource(record: ConsignmentSubmission) { const channel = (record.contentJson as { channel?: string } | undefined)?.channel if (channel === 'worker_portal' || record.workerId) return '打手端提报' - if (record.linkTitle) return `旧收集链接: ${record.linkTitle}` return '其他渠道' } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index aecb4b69..c703184d 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -167,6 +167,18 @@ function isInternalConsignmentOrder(row: WorkOrder) { return Boolean(consignment.inventoryId) && !row.worker } +/** + * 只有「未验收且未取消」的工单可以撤销寄售履约。 + * 已验收工单撤销会造成同一件现货二次出货、并重新冻结已退还的押金,后端一律拒绝。 + */ +const CONSIGNMENT_REVOCABLE_ORDER_STATUSES = ['in_progress', 'pending_acceptance', 'problem'] + +function isConsignmentRevocableOrder(row: WorkOrder) { + return ( + isInternalConsignmentOrder(row) && CONSIGNMENT_REVOCABLE_ORDER_STATUSES.includes(row.status) + ) +} + export default function WorkOrdersPanel() { const { message, modal } = App.useApp() const navigate = useNavigate() @@ -216,6 +228,9 @@ export default function WorkOrdersPanel() { const canDeductDeposit = sessionPermissions ? sessionPermissions.includes('worker_order.deduct_deposit') : hasAdminPermission('worker_order.deduct_deposit') + const canFulfillConsignment = sessionPermissions + ? sessionPermissions.includes('consignment.fulfill') + : hasAdminPermission('consignment.fulfill') const canConfigureSharing = sessionPermissions ? sessionPermissions.includes('worker_order.configure_share') : hasAdminPermission('worker_order.configure_share') @@ -760,6 +775,10 @@ export default function WorkOrdersPanel() { async function handleRevokeConsignmentSubmit() { if (!revokeConsignmentModal.order) return + if (!revokeConsignmentModal.reason.trim()) { + message.warning('请填写撤单原因,便于后续对账与追溯') + return + } setRevokeConsignmentModal((prev) => ({ ...prev, submitting: true })) try { await revokeWorkOrderConsignmentFulfillment({ @@ -1454,7 +1473,7 @@ export default function WorkOrdersPanel() { 扣押金 ) : null} - {isInternalConsignmentOrder(row) ? ( + {isConsignmentRevocableOrder(row) && canFulfillConsignment ? ( , - ]} - /> - - - ) - } - - return ( -
-
- {/* 顶部标题区 */} - - -
- - - {linkInfo?.title || '苏尔南手枪 · 走打手接单平台寄卖'} - -
- - 平台保障 · 走接单平台押金履约 · 验货即打款 · 杜绝私下跑单 - - - {linkInfo ? ( -
- {linkInfo.system ? {linkInfo.system} : null} - {linkInfo.serverZone ? {linkInfo.serverZone} : null} - {linkInfo.series ? {linkInfo.series} : null} - {linkInfo.diamondLevel ? {linkInfo.diamondLevel} 钻 : null} - {linkInfo.brillianceCount > 0 ? ( - {linkInfo.brillianceCount} 璀璨 - ) : null} - {linkInfo.presetPriceFen > 0 ? ( - - 指导收购价 ¥{(linkInfo.presetPriceFen / 100).toFixed(0)} - - ) : null} -
- ) : null} -
-
- - {/* 押金保障卡片 */} - } - type="success" - message="走打手接单平台 · 押金保障机制" - description="寄售手枪统一通过接单平台派单交付。登记时可填写履约押金(默认跟寄售价一致);指派交枪时冻结押金,买家验货确认后解冻并结算货款。" - style={{ marginBottom: 16, borderRadius: 12, border: '1px solid #b7eb8f' }} - /> - - {/* 表单填写区 */} - - updateAutoItemName(changed)} - > - {/* 1. 枪系与规格 */} -
- - - 1. 苏尔南手枪规格与价格 - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* 2. 卖家联系方式与游戏凭据 */} -
- - - 2. 卖家联系方式与游戏凭据 - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - try { - const res = await uploadConsignmentImage(file as File) - onSuccess?.(res) - } catch (err: any) { - onError?.(err) - message.error(err.message || '上传失败') - } - }} - onChange={({ fileList: nextList }) => setFileList(nextList)} - > - {fileList.length < 4 ? ( -
- -
上传主页截图
-
- ) : null} -
-
- - - - - - - -
-
-
- ) -} diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index 71709f86..1c5da054 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -1,6 +1,5 @@ import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/http' import type { - ConsignmentCollectLink, ConsignmentInventory, ConsignmentStatsSummary, ConsignmentSubmission, @@ -62,25 +61,6 @@ export function returnAdminConsignmentInventory(id: number | string, payload: { ) } -// --- 收集链接 --- - -export function fetchAdminConsignmentLinks() { - return apiGet<{ items: ConsignmentCollectLink[] }>( - '/api/v1/admin/worker-platform/consignment/links', - ) -} - -export function createAdminConsignmentLink(payload: Record) { - return apiPost('/api/v1/admin/worker-platform/consignment/links', payload) -} - -export function updateAdminConsignmentLink(id: number | string, payload: Record) { - return apiPut( - `/api/v1/admin/worker-platform/consignment/links/${id}`, - payload, - ) -} - // --- 提报审核 --- export function fetchAdminConsignmentSubmissions(params?: Record) { diff --git a/apps/frontend/src/services/consignment-public.ts b/apps/frontend/src/services/consignment-public.ts deleted file mode 100644 index a7932dae..00000000 --- a/apps/frontend/src/services/consignment-public.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment' - -export function fetchPublicConsignmentLink(token: string) { - return apiGet(`/api/v1/consignment/${encodeURIComponent(token)}`) -} - -export function submitPublicConsignment(token: string, payload: Record) { - return apiPost( - `/api/v1/consignment/${encodeURIComponent(token)}/submit`, - payload, - ) -} - -export async function uploadConsignmentImage( - file: File, -): Promise<{ url: string; assetId?: string }> { - const formData = new FormData() - formData.append('file', file) - formData.append('scene', 'consignment-material') - - const response = await fetch('/api/v1/consignment/upload', { - method: 'POST', - body: formData, - }) - - if (!response.ok) { - const errJson = await response.json().catch(() => ({})) - throw new Error(errJson?.message || '图片上传失败') - } - - const result = await response.json() - return result?.data || result -} diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index a48c74d5..3f44fa0c 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -26,11 +26,13 @@ export type ConsignmentInventory = { allocationStatus: string boundWorkOrderId: number | null boundPlatformOrderId: string - sourceType: 'manual' | 'collection_link' | 'recycle' + sourceType: 'manual' | 'worker_portal' | 'recycle' sourceSubmissionId: number | null workerId?: number | null workerName?: string workerPhone?: string + /** 被指派执行本次履约的打手(卖家之外的第三人);workerId 始终是卖家。 */ + assignedWorkerId?: number | null depositAmountFen?: number inboundAt: string consumedAt: string | null @@ -38,36 +40,9 @@ export type ConsignmentInventory = { updatedAt: string } -export type ConsignmentCollectLink = { - id: number - token: string - title: string - gameName: string - categoryId: number | null - categoryName?: string - system: string - serverZone: string - series: string - diamondLevel: number - brillianceCount: number - presetPriceFen: number - inboundBatch: string - buyerName: string - templateJson: Record - isMultiUse: boolean - expiresAt: string | null - enabled: boolean - createdBy: string - createdAt: string - updatedAt: string - submissionCount?: number -} - export type ConsignmentSubmission = { id: number submissionNo: string - linkId: number | null - linkTitle?: string gameName: string categoryId: number | null categoryName?: string diff --git a/docs/寄卖库存功能设计与实施方案.md b/docs/寄卖库存功能设计与实施方案.md index 511d4652..511b728a 100644 --- a/docs/寄卖库存功能设计与实施方案.md +++ b/docs/寄卖库存功能设计与实施方案.md @@ -61,7 +61,10 @@ ## 三、 三大核心业务场景与操作流 -### 1. 定向收集链接生成与用户填报(自动化收货渠道) +### 1. ~~定向收集链接生成与用户填报(自动化收货渠道)~~ —— **已下线** +> 2026-09 起:寄卖提报统一走**接单平台打手端提报**(`/api/v1/worker/consignment/publish`), +> 对外收集链接(H5 填报页、`consignment_collect_links`、`consignment_submissions.link_id`) +> 已整条移除,详见迁移 `079_drop_consignment_collect_links.sql`。本节保留仅作历史记录。 ``` [客服后台] ──► 点击【新建收集链接】 @@ -169,25 +172,35 @@ ``` ### 1. 工单视角:【撤销寄售履约 / 重新选择】 -在工单列表或工单详情页,对已通过内部库存履约的工单(状态为 `completed` 且带有寄售出库标记),提供 **【撤销履约】** 操作: +在工单列表或工单详情页,对「已通过内部库存履约、且**尚未验收**」的工单(状态为 `in_progress` / `pending_acceptance` / `problem`)提供 **【撤销履约】** 操作: +* **前置约束(2027-02-21 加固)**:已验收(`accepted`)与已取消(`cancelled`)的工单**一律禁止撤销** —— 钱已结算、货已交付,撤销会造成同一件现货二次出货并重新冻结已退还的押金;买家退款/纠纷改走售后流程。服务端在事务内 `FOR UPDATE` 重新读单并校验状态白名单,撤销原因必填,前端按钮同样按状态 + `consignment.fulfill` 权限渲染。 * **分支 A:【回退为未分配(重新选库存 或 发大厅)】** - * **原库存处理**:状态从 `consumed` 自动恢复为 `in_stock`,清除绑定的订单号与售出店铺,重新进入待匹配池。 - * **工单处理**:状态从 `completed` 安全回退为 `unassigned`(未分配),清理本次发货卡密记录。 + * **原库存处理**:状态从 `consumed` 自动恢复为 `in_stock`,清除绑定的订单号与售出店铺,重新进入待匹配池(**仅当该行仍绑定本工单时**才允许改写,否则 409)。 + * **工单处理**:状态安全回退为 `unassigned`(未分配),清空指派人、时限、验收草稿与本次发货记录。 * **操作结果**:客服可以立即再次点击「发布并核销」选择其他匹配库存,或者直接发布到大厅由打手抢单! * **分支 B:【买家申请退款(取消工单并退库)】** * **原库存处理**:安全解绑并自动回退为在库 `in_stock`,避免资产报废损失。 * **工单处理**:状态流转为 `cancelled`(已取消),不再继续履约。 * **分支 C:【物资账号异常(作废该库存并重新选)】** - * **原库存处理**:该把手枪/账号密码失效或被封,直接变更为 `archived`(已作废),记录作废原因,不再参与匹配。 + * **原库存处理**:该把手枪/账号密码失效或被封,直接变更为 `archived`(已作废),记录作废原因,不再参与匹配,并释放发布押金。 * **工单处理**:工单回退为 `unassigned`,供客服挑选另一把正常手枪继续履约。 ### 2. 仓库视角:【退回复库 / 重新上架】 在「寄售仓库」管理列表中,对状态为 `consumed`(已出库)的物品行,提供 **【退回复库】** 按钮: -* 客服可手动将某件出库的物品解绑原订单,直接重置为 `in_stock` 在库待匹配状态。 -* 适合线下沟通中买家临时更换需求、跑单或线下对账异常时的人工修正。 +* 客服可手动将某件出库的物品解绑原订单,直接重置为 `in_stock` 在库待匹配状态;适合线下沟通中买家临时更换需求、跑单或线下对账异常时的人工修正。 +* **前置约束(2027-02-21 加固)**:若该物品仍被一张「进行中的工单」占用(`bound_work_order_id` 指向的工单状态不是 `accepted`/`cancelled`),【退回复库】与【下架作废】都会被拒绝,并提示先撤销履约或撤单 —— 避免把别的工单正在交付的现货抢走。 +* **匹配开关语义**:回库**不再**强制打开 `allow_matching`,而是保留运营的手动设置(该开关是"暂停匹配"的运营闸门,自动回库不应替运营做决定)。编辑接口也不再接受直接改 `status`,状态只能经由带守卫的动作变更。 + +### 2.1 工单终止时的自动解绑(2027-02-21 新增) +工单进入「不再交付」的状态时,会在**同一事务内**把占用的现货放回在库池(`releaseConsignmentInventoryBindingForWorkOrderWithClient`),不再依赖运营手工退库: +* 覆盖路径:打手撤单(`cancelled_by_worker`)、后台取消指派(`unassigned_by_admin`)、后台退回大厅(`returned_to_hall_by_admin`)、后台撤单取消订单(`cancelled_by_admin`)、打手退单申请审核通过、超时处置(`timeout_reopen` / `timeout_cancel_release` / `timeout_cancel_deduct`)、已取消工单重新启用(兜底清理历史绑定)。 +* **售后处置不在其列**:售后(`resolveAdminAfterSalesCase`)只对**已验收**工单做资金追缴/退赔,工单状态保持 `accepted`,货物已交付,无需解绑。若买家退回实物,由运营在寄售仓库执行【退回复库】(已验收工单不再是"进行中",守卫会放行)并填写原因。 +* 发布押金保持冻结:现货回到在库池等同于"重新挂卖",符合「发布冻结、驳回/验收释放」的既有口径;卖家要彻底取回物品,由运营执行【下架作废】后释放押金。 ### 3. 数据安全与事务保障 * 撤单过程全部置于数据库事务(`withTransaction`)中,确保库存状态与工单状态原子同步,绝不会出现“工单回退了但库存未恢复”的错乱。 +* 撤销时事务内对工单行 `SELECT ... FOR UPDATE`,库存 UPDATE 带 `status = 'consumed' AND bound_work_order_id = $工单` 条件,命中 0 行即 409,防止与「验收」并发时丢失更新或抢占其他工单的绑定。 +* 一件现货同一时刻最多绑定一张工单:迁移 `079` 增加部分唯一索引 `uniq_consignment_inventories_bound_work_order` 兜底。 * 每次撤单操作均在工单事件(`work_order_events`)中留下完整审计轨迹(记录撤销人、撤回时间、原绑定单号与原因)。 --- @@ -232,9 +245,8 @@ * 弹出确认弹窗,选择“选错重选 / 买家退款 / 物资作废”; * 点击确认后原子回退状态,工单即刻变回未分配状态,支持重新选或发大厅。 -### 3. 客服创建收集链接弹窗(`CreateCollectLinkModal`) -* 表单包含系统、区服、系列、钻级、璀璨下拉选择,支持输入协商收货价与批次号。 -* 生成后提供链接复制与二维码展示。 +### 3. ~~客服创建收集链接弹窗(`CreateCollectLinkModal`)~~ —— **已下线** +> 随收集链接渠道一并移除,客服不再生成对外链接;提报入口为接单平台打手端页面。 ### 4. 寄卖提报审核面板(`ConsignmentSubmissionsPanel`) * 展示卖家提交的待审单据。 @@ -254,8 +266,8 @@ * **实现工单撤销履约与库存回退重选服务(`revokeConsignmentFulfillment`)**。 3. **阶段三:前端管理后台** * 接单平台新增「寄售仓库」面板(包含多维筛选、彩色标签、Switch 开关、卡密查看、退回复库); - * 新增「寄售审核与收集链接」面板; + * 新增「寄售审核」面板(提报来源为接单平台打手端); * 改造工单发布拦截与履约弹窗; * 增加工单列表/详情中的「撤销寄售履约」弹窗与重选逻辑。 -4. **阶段四:移动端 H5 填报端** - * 实现移动端极简收集页,规格只读展示,支持截图直传。 +4. **阶段四:~~移动端 H5 填报端~~ —— 已下线** + * 原设计为移动端极简收集页;实际改为接单平台打手端提报,H5 页面已删除。 diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md new file mode 100644 index 00000000..f081a140 --- /dev/null +++ b/docs/寄卖系统优化方案.md @@ -0,0 +1,259 @@ +# 寄卖(寄存)系统 · 现状分析与下一步优化方案 + +> 基线:`e828e677`(main,工作区干净) +> 基线校验:后端 397 测试 / 394 通过 / 0 失败 / 3 skip;`typecheck`、`lint:check` 通过 +> 分析对象:`1aa6dba9`、`4a6215e8`、`fd1eb581`、`6b940796`、`e828e677` 及当前代码 +> 取证方式:逐行读代码(含 SQL 条件、前端调用点)+ 三路并行专项审计(状态机并发 / 前后端+RBAC / 资金闭环) +> 所有关键结论均已复核到 `文件:行` +> **本轮落地(2027-02-21,未提交)后校验**:后端 401 测试 / 398 通过 / 0 失败 / 3 skip;前端 16 通过;两侧 `typecheck`、`lint:check`、`format:check`、`check:sql`、`build` 全绿 + +--- + +## 一、最近提交在做什么 + +| 提交 | 内容 | 落点 | +| :--- | :--- | :--- | +| `1aa6dba9` | 初版:寄售仓库 / 定向收集链接 / 玩家提报审核 / 工单智能匹配与内部履约 | 迁移 `077`、`consignment-*` 全套、RBAC 四权限 | +| `4a6215e8` | 打手端押金履约(打手发布 → 冻结押金 → 指派打手交货) | 迁移 `078`、`consignment-worker-service`、`consignment-deposit-repo` | +| `fd1eb581` | 内部现货占库进入赠送冷静期;发布押金冻结/驳回退还/验收释放闭环 | `consignment-fulfillment-service`、`work-order-gift-repo`、结算联动 | +| `6b940796` | 样式修复(cooldownMinutes prefer-const) | — | +| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 | +| *(工作区未提交)* | 本轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 | + +**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。 +当前主要矛盾不是"缺功能",而是:**状态机不封闭 + 库存绑定无唯一性约束 + 工单终态对寄卖零感知**(本轮已处理);入口侧"H5 不可提交 / 手工录入丢凭据"两条断链已通过"下线 H5 + 修字段名"解决。 + +--- + +## 二、现状链路 + +``` +[打手接单平台提报] ── 唯一入口(H5 定向收集链接已整体下线) +[客服手工录入] ── 凭据/截图字段名已对齐(本轮修复) + │ + ▼ +提报审核 → consignment_inventories(in_stock, allow_matching) + │ +工单发布前拦截 ── 命中候选(exact 100 / partial 80,仅前端展示) + │ + ├─► 内部现货履约 (internal_stock):同一事务原子占库(含工单绑定)→ 工单 in_progress + 赠送冷静期 → 验收释放押金 + └─► 指派打手押金履约 (worker_deposit):先占库(独立事务,写 assigned_worker_id + 工单绑定)→ 再指派(另一事务冻结押金/核销凭证)→ 再改 material/reward/写事件 + │ + └─ 撤销寄售履约(仅内部现货,仅 in_progress/pending_acceptance/problem)→ 回库 / 作废 / 取消工单 + │ + └─ 工单终止(打手撤单 / 取消指派 / 退回大厅 / 撤单 / 打手退单申请 / 超时)→ 同事务解绑现货回库(本轮新增) +``` + +--- + +## 三、已验证问题清单 + +> **状态说明(2027-02-21 更新)**:下列清单为第一轮审计结论。本轮已落地批次 A/B 的核心内容, +> 逐条状态见每项标题后的标记;未完成项保留原文,作为下一轮输入。 +> 已交付的改动见 `docs/寄卖库存功能设计与实施方案.md` §五(状态机语义)与迁移 `079_drop_consignment_collect_links.sql`。 + +### ✅ 本轮已落地(2027-02-21) + +| 项 | 内容 | 落点 | +| :--- | :--- | :--- | +| H5 收集链接整条下线 | 删公开路由/服务/页面/服务端封装 + 迁移 `079` 删表 `consignment_collect_links`、删列 `link_id`,`source_type` 归并为 `worker_portal` | 前后端各 4 处删除 + `079` | +| 凭据/截图静默丢弃(P0-2) | 后端改读 `credentialsJson ?? credentials`、`screenshotsJson ?? screenshots` | `consignment-admin-service.ts` 三处入口 | +| 撤销状态机(P0-3 / P1-3 / P1-4) | 状态白名单(`in_progress`/`pending_acceptance`/`problem`)+ 事务内 `FOR UPDATE` + `UPDATE ... AND status=$n` + 原因必填 + 清空指派/时限/验收/问题痕迹 | `consignment-fulfillment-service.ts` | +| 库存绑定唯一性(P0-4) | 撤销/退回复库/作废统一 `AND status='consumed' AND bound_work_order_id=$工单`(作废与回库额外要求"未被进行中工单占用"),命中 0 行 409 且提示具体工单;部分唯一索引兜底 | `consignment-repo.ts`、`consignment-admin-service.ts`、`079` | +| 生命周期收口(P0-5) | 新增 `releaseConsignmentInventoryBindingForWorkOrderWithClient`,接入撤单仓库 5 条链路 + 打手退单申请 + 超时处置 | `work-order-cancel-repo.ts`、`work-order-issue-repo.ts`、`worker-cancel-request-repo.ts` | +| 占库写入工单绑定(P2-19) | 指派占库时写 `bound_order_id/bound_work_order_id/bound_platform_order_id/shop_name` | `consignment-fulfillment-service.ts`、`consignment-repo.ts` | +| 卖家/履约人拆列(P1-1) | 新增 `assigned_worker_id`,占库只写指派列,回滚不再把卖家置 NULL | `079` + `consignment-repo.ts` | +| 押金归属/金额取台账(P1-2、P2-1) | 冻结/释放的归属与金额改为读取不可变的冻结核销行,审核改派不再导致"钱退给错人/向错人扣款" | `consignment-deposit-repo.ts` | +| 编辑接口不再能改状态(P1-7) | `PUT /inventories/:id` 显式拒绝 `status`,仓库层移除 `status` 入参 | `consignment-admin-service.ts`、`consignment-repo.ts` | +| 打手端补审核门禁(P1-9) | `POST /consignment/publish` 加 `requireActiveWorker` | `routes/worker/consignment.ts` | +| 押金变动推送钱包(P2-4) | 发布冻结、驳回释放、作废归档、退回复库成功后推送钱包实时事件 | 三个 consignment service | +| 台账幂等键(P2-3) | 同一提报单的发布冻结加部分唯一索引 | `079` | +| 回归测试 | 新增 5 例:占库绑定断言、撤销状态白名单、解绑范围、作废/回库守卫、终态链路接线计数 | `worker-platform-service.test.ts` | + +**一处刻意偏离原始批次 B 表述**:原始方案写"所有回库路径恢复 `allow_matching`",实际实现为**保留运营的开关设置**(`e828e677` 已按此方向改过撤销路径)。理由:该开关是运营"暂停匹配"的闸门,自动回库替运营打开会静默解除风控;两条路径(撤销回库、退回复库)现在语义一致,UI 文案也已同步更正(不再宣称"重新进入待匹配池")。若业务坚持"回库即上架",改一处常量即可。 + +### P0 · 直接导致功能不可用或重复出货 + +**P0-1 H5 定向收集页 100% 无法提交(截图 URL 取错层级)—— ✅ 已随渠道下线废弃** + +- `services/consignment-public.ts:14-33`:`uploadConsignmentImage` 声明返回 `{url, assetId}`,实际 `return result?.data || result`。 +- 后端上传响应信封是 `{code,msg,time,data:{file:{url,...}}}`(`utils/http.ts:21-28`,`services/file-storage/file-storage-service.ts:100-135` 返回 `{ file: … }`),正确写法见 `services/files.ts:19`(`response.data.file`)。 +- 页面 `ConsignmentCollectPage.tsx:120-122` 取 `f.response?.url || f.url`;而 antd Upload 的 `customRequest` 只把结果塞进 `f.response`(`ConsignmentCollectPage.tsx:528-534` 传的是 `{file:{...}}`),且从不设置 `f.url`。 + ⇒ 上传成功、缩略图能看,但 `screenshots` 恒为空数组,点提交永远弹「请上传游戏主页截图以供核验!」,且文件已落 OSS 变成孤儿资源。 +- **结论**:H5 渠道已整条移除,此缺陷连同页面、服务、公开路由、`consignment_collect_links` 表与 `link_id` 列一并作废。 + +**P0-2 手工入库 / 编辑的账号密码与截图被静默丢弃(前后端字段名不一致)—— ✅ 已修复(本轮)** + +- 前端发 `credentialsJson` / `screenshotsJson`:`ConsignmentInventoryPanel.tsx:211-217`(新增)、`:264-269`(编辑)。 +- 后端读 `payload.credentials` / `payload.screenshots`:`consignment-admin-service.ts:255-259`、`:308-312`;全仓无 camelCase→snake 映射中间件。 + ⇒ 客服录入的登录方式/账号/密码/安全码**永不落库**,详情弹窗(`ConsignmentInventoryPanel.tsx:1206-1215`)永远是 `-`;打手履约写入 `material.consignment.credentials` 只能是 `{}`(`consignment-fulfillment-service.ts:214`),**无法上号交付**。 +- **修复**:新增 `pickCredentialsJson` / `pickScreenshotsJson`(`payload.credentialsJson ?? payload.credentials`、`screenshotsJson ?? screenshots`),统一用于手工入库、编辑、审核入库三个入口。 + +**P0-3 撤销寄售履约没有状态守卫,已验收(钱已结算、货已交付)的工单仍可被撤销 → 同一件现货二次出货 —— ✅ 已修复(本轮)** + +- 撤销入口只校验 material 里有没有 `inventoryId`、是不是押金模式:`consignment-fulfillment-service.ts:419-442`。 +- 事务内**不重新加锁、不校验工单状态**:`:456-463` → `:539-570`(`UPDATE work_orders ... WHERE id = $4`,无 status 条件)。 +- 库存写回 `in_stock` 的 UPDATE 同样无 status 条件:`:497-518`。 +- 验收结算只释放押金,**不清除 `material.consignment`**:`work-order-settlement-repo.ts:33-78`、`:92-103`。 +- 前端按钮无状态白名单:`WorkOrdersPanel.tsx:1457-1465`;无二次确认;撤单原因标 `required` 但没挂 `rules`(`:3042-3050`),空原因也能提交,后端也不校验。 +- **修复**:`assertConsignmentRevocableWorkOrderStatus`(白名单 `in_progress|pending_acceptance|problem`,事务内外各校验一次)+ 事务内 `SELECT ... FOR UPDATE` 锁单 + `UPDATE ... WHERE id=$4 AND status=$5` + 原因必填(前端同步校验)+ 清空指派/时限/验收草稿/问题单痕迹;前端 `isConsignmentRevocableOrder` + `consignment.fulfill` 权限门控。 + +**P0-4 库存被按 id 无条件改写 → 抢占其它工单的绑定、复活已作废件(一件现货被三张单履约)—— ✅ 已修复(本轮)** + +- 撤销分支 `WHERE id = $3` 且无条件清空 `bound_order_id / bound_work_order_id / bound_platform_order_id`:`consignment-fulfillment-service.ts:475-494`、`:497-518`。 +- 仓库侧【退回复库】只校验 `status='consumed'`:`consignment-repo.ts:640-669`;【作废】连状态都不校验:`:620-638` + `consignment-admin-service.ts:349-374`。 +- **修复**:撤销分支加 `AND status='consumed' AND bound_work_order_id = $工单`(0 行 → 409 `consignment_inventory_binding_changed`);【作废】/【退回复库】加 `NO_LIVE_BOUND_WORK_ORDER_SQL`(绑定工单状态不在 `accepted`/`cancelled` 即拒绝,提示具体工单号);迁移 `079` 加部分唯一索引 `uniq_consignment_inventories_bound_work_order` 兜底,并清理历史重复绑定。 + +**P0-5 工单生命周期对寄卖零感知:撤单 / 超时 / 售后 / 重开都不解绑 —— ✅ 已修复(本轮)** + +- 本轮新增 `releaseConsignmentInventoryBindingForWorkOrderWithClient(client, workOrderId, {now, reason})` 作为唯一出口,并接入:`work-order-cancel-repo.ts`(打手撤单 / 取消指派 / 退回大厅 / 撤单取消订单 / 已取消工单重新启用 共 5 处)、`worker-cancel-request-repo.ts`(打手退单申请审核通过)、`work-order-issue-repo.ts`(超时处置 `reopen`/`cancel_release`/`cancel_deduct`)。 +- 押金口径:现货回库等同于"重新挂卖",发布押金**保持冻结**(不释放也不重复冻结),与「发布冻结、驳回/验收释放」一致;卖家彻底取回物品走运营【下架作废】→ 释放押金。 +- 售后不需要接入:`resolveAdminAfterSalesCase` 只对已验收工单做资金追缴/退赔,不改工单状态(`fromStatus/toStatus` 均为 `accepted`),货物已交付、无待解绑库存;买家退回实物时由运营在仓库执行【退回复库】(已验收工单不再是"进行中",守卫放行)。 +- 效果:撤单/超时后现货立刻回到在库池(`in_stock`,保留运营的匹配开关设置),重开工单不会再重复消耗第二件库存(绑定在撤单事务内已清)。 + +### P1 · 数据正确性 / 权限 / 资金安全 + +**P1-1 `consignment_inventories.worker_id` 一列两义:既是"卖家"又是"被指派的打手" —— ✅ 已修复(本轮)** + +- 审核入库写卖家:`consignment-repo.ts:1095,1129`;指派占库**覆盖**为受指派打手:`:557-570`;回滚直接置 `NULL`:`:588-609`。 +- 打手"我的寄卖"(`consignment-worker-service.ts:185`)与后台筛选(`consignment-repo.ts:146`)都按这一列。 + ⇒ 客服指派**另一位**打手时卖家丢件、被指派者凭空多件;指派失败回滚(置 NULL)同样让卖家永久丢件。 +- **修复**:迁移 `079` 新增 `assigned_worker_id`(含注释与索引);占库只写 `assigned_worker_id`,回滚只清该列,`worker_id` 语义固定为卖家且入库后不再变更。 + +**P1-2 指派打手存在"部分成功"窗口:库存放回了,工单仍挂在该打手身上 —— ⏳ 未处理(下一轮批次 C)** + +- `consignment-fulfillment-service.ts:318-327` 先占库(**独立事务已提交**)→ `:331-335` 指派(另一独立事务:冻结打手押金 `work-order-grab-repo.ts:180-226`、核销凭证 `admin-work-order-assignment-service.ts:67`)→ `:368 updateWorkOrder`、`:374 createWorkOrderEvent`、`:390` 每一步都可能抛错。 +- `:397-409` 的 catch **只放回库存**,不撤销指派/押金/凭证/资料/金额。 + ⇒ 事件写入失败、死锁、超时或两次提交之间重启:工单 `in_progress` + 打手押金已冻结 + 凭证已核销,`material.consignment` 从未写入,现货却回到可匹配池。 +- **本轮已缓解**:占库现在会写入 `bound_work_order_id`,补偿回滚的放回 UPDATE 带 `WHERE status='consumed'`;但"指派事务化"本身未做。 +- **修复(待做)**:claim + assign + material/reward + event 合并同一事务(共用 `PoolClient`);指派后失败改补偿式撤销(解绑 + 解冻)。 + +**P1-2b 押金归属取自可变的 `consignment_submissions.worker_id` —— ✅ 已修复(本轮)** + +- 归属原本按提报单可变的 `worker_id` 反查钱包(`consignment-deposit-repo.ts:173-202`),而审核可覆盖它(`consignment-repo.ts:985`,前端有打手下拉)。 + ⇒ 审核改派后:释放按新 worker 算得 outstanding=0 → **静默返回 0**,发布人那笔押金永久冻结且无补救入口;反向地,回库重冻会向从未发布过的打手扣款。 +- **修复**:新增 `getConsignmentDepositLedgerSnapshot`,押金归属与金额以**不可变的冻结核销行**(`payload_json->>'consignmentSubmissionId'` + `ledger_type='deposit_freeze'`)为准;`ensure...` 重冻金额也改用台账历史金额,避免审核改额后按新金额扣款;提报驳回释放改走 `releaseConsignmentPublishDepositBySubmissionWithClient`。迁移 `079` 补部分唯一索引,防重复冻结。 + +**P1-3 撤销的工单 UPDATE 无锁无条件,用陈旧快照写回 → 覆盖并发验收(丢失更新)+ 双击双提交 —— ✅ 已修复(本轮)** + +- 事务外读工单与 material(`:456-463`),事务内整体覆写 `status` + 整个 `material_json` + `RESET_GIFT_FLOW_SQL`(`:555-570`)。 + ⇒ 与"验收"并发时把 `accepted`、`acceptance_json`、`submitted_at`、押金释放全部抹掉并回退为 `unassigned`。 +- **修复**:事务内 `SELECT ... FOR UPDATE` 重新读单并以锁行为准(material 也取锁行),状态白名单校验两次,`UPDATE ... WHERE id=$4 AND status=$5`(0 行 → 409 `work_order_status_changed`),库存 UPDATE 作为最终仲裁(0 行 → 409)。 + +**P1-4 撤销只重置冷静期字段,验收/时限/奖金残留 —— ✅ 已修复(本轮)** + +- 原 `:560` 只应用 `RESET_GIFT_FLOW_SQL`(`work-order-gift-repo.ts:34-39`)。 +- **修复**:撤销的 `UPDATE work_orders` 现同时清空 `assigned_worker_id / assigned_at / deadline_at / submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / published_at / pinned_at`,与 `reopenCancelledWorkOrder` 的清理口径一致。 + +**P1-5 电子凭证在事务外核销、失败被吞** + +- 指派路径在状态变更**之前**核销(`admin-work-order-assignment-service.ts:67-85`),逐张提交(`publish-voucher-service.ts:64-113`)⇒ 指派被拒时可能已有凭证被核销。 +- 履约路径在事务**之后**核销(`consignment-fulfillment-service.ts:248-264`),失败只写进响应体;前端 `WorkOrdersPanel.tsx:716-726` **完全不读** `voucherConsume`,无条件弹"成功"。 +- **修复**:核销并入状态变更事务(或异步编排 + 补偿);`!ok` 时接口失败并提示。 + +**P1-6 匹配引擎对空值放行,且履约时服务端不复检 → 错发武器** + +- `consignment-matching-service.ts:125-128`:订单侧为空时 `sameSpec` 直接 `return true`;`:150` 系列只在双方都有值时才强制相等;`:119` 钻级解析失败兜底为 1(`invDiamond >= 1` 恒真)。 + ⇒ 商品名没写"安卓/微信/暗潮/1钻"(例如只写"苏尔南手枪")时,**任意在库库存都会被推荐为 100 分"精确匹配"**。 +- `consignment-fulfillment-service.ts:109-153`:履约接口只校验"在库 + 允许匹配",不校验规格(匹配结果纯前端展示,`routes/admin/worker-platform/consignment.ts:239-274` 直接透传 `inventoryId`)。 +- **修复**:空规格不得给"精确匹配";履约事务内用 `matchPeacekeeperOrderWithInventory` 复检,非 exact 一律 409(partial 需显式覆盖参数)。 + +**P1-7 `PUT /inventories/:id` 可绕过整个状态机 —— ✅ 已修复(本轮)** + +- 原来透传 `status / workerId / depositAmountFen`;`consignment-repo.ts:404-540` 直接拼 SET:可把 `consumed` 改回 `in_stock`(不清绑定、不动押金、无审计),可改 `deposit_amount_fen`。 +- **修复**:服务层显式拒绝 `payload.status`(400 `consignment_status_update_forbidden`,前端编辑弹窗本就不提交 status),仓库层移除 `status` 入参 —— 状态只能经由占库/解绑/回库/作废/撤销这些带守卫的函数变更。 + +**P1-8 前端对四个 consignment 权限零引用 → 权限一旦被取消,按钮照旧显示、点击必然 403** + +- 19 个管理端接口已全部挂 `requireAdminPermission`(无遗漏),但前端全仓 grep 不到 `consignment.view/manage/review/fulfill`(引用数 0)。 +- 关键路径:工单列表发布按钮只需 `worker_order.publish` 即可打开拦截弹窗(`WorkOrdersPanel.tsx:1391-1402`、`:698-714`),弹窗内两个按钮**无条件渲染**并直接调 `/fulfill`、`/assign-worker`(需 `consignment.fulfill`,`:2952-2968`)。`AdminUsersPage.tsx:456-471` 的角色权限页可以取消任一 consignment 权限(本次 commit 修白名单的正是这一页),取消后 UI 毫无变化 → 点击必 403(`routes/admin/session.ts:83-102`)。 +- 同源:`ConsignmentInventoryPanel` / `ConsignmentSubmissionsPanel` 全部写按钮(`:192/:245/:172/:293/:307`、`CSP:97/:131`)也无校验;`consignment.view` 这个只读权限在 UI 层无法体现。 +- **修复**:像 `WorkOrdersPanel.tsx:182-221` 那样读 `fetchAdminSession().user.permissions`,把 canView/canManage/canReview/canFulfill 透传到面板与弹窗按钮。 + +**P1-9 未审核 / 已冻结打手仍可发布寄卖(绕过接单账号审核)—— ✅ 已修复(本轮)** + +- 前端菜单无条件渲染:`WorkerLayout.tsx:35,42,90`(⏳ UI 未收口);后端 `POST /consignment/publish` 原只用 `getRequiredWorkerSession`。 +- **修复**:发布接口已挂 `requireActiveWorker`(对齐 `routes/worker/hall.ts:17-20`);`GET /consignment/my-items` 保持仅需会话(只读自己的数据)。前端菜单项的后置处理留待批次 E。 + +**P1-10 收集链接没有后台管理 UI,服务层是死代码 —— ✅ 已随渠道下线废弃** + +- `services/admin/consignment.ts:67-82`(fetch/create/update link)零调用;后端三个接口(`routes/admin/worker-platform/consignment.ts:136-167`)无入口。 +- **结论**:收集链接整条线已删除,不再需要后台管理 UI。 + +### P2 · 运营体验 / 合规 / 性能(按影响排序) + +1. **搜索骗人**:`ConsignmentInventoryPanel.tsx:678` 占位符承诺"物品名/编号/收购人/店铺/卖家",后端 ILIKE 只覆盖部分字段(`consignment-repo.ts:184-189`)→ 按收购人/店铺搜恒空。 +2. **统计每次刷新跑 5 条全表聚合**(`consignment-repo.ts:215-303`),且列表无排序/无列筛选。 +3. **凭据无独立权限**:`mapConsignmentInventory` 直接返回 `credentialsJson`(游戏账号/密码,`consignment-admin-service.ts:77-80`),列表接口全量下发,仅需 `consignment.view`。 +4. **`is_multi_use=false` 从未生效**:字段只落库(`consignment-repo.ts:788`),`submitPublicConsignment` 全文不读 ⇒ 单次链接可无限提报。**✅ 随渠道下线废弃** +5. **公开提交按手机号自动绑定打手**(`consignment-public-service.ts:113-124`)⇒ 知道打手手机号的第三方可让其被动冻结押金。**✅ 随渠道下线废弃**(现只有打手端本人发布) +6. **押金可能长期冻结无人收口**:`pending_review` 无过期策略;打手端只有 `publish` + `my-items`(`routes/worker/consignment.ts`),没有"撤回我的提报"。**⏳ 未处理**(本轮已补:冻结归属取台账,运营手动驳回即可释放) +7. **统计报表与设计文档不符**:文档 §六/§七 的【累计节省赏金】【本月出库单数】【店铺节省赏金/人效】【批次回款】均未实现;`getConsignmentInventoryStats` 只返回数量与成本 + 4 个 GROUP BY,前端指标卡也只 4 项(`ConsignmentInventoryPanel.tsx:559-587`)。 +8. **列表无"匹配内部库存"角标 + 匹配懒加载 + 全表入内存**:`checkWorkOrderConsignmentMatches` 仅在点发布时调用(`WorkOrdersPanel.tsx:700`),且异常被空 catch 吞掉(`:710-712`,缺 view 权限时拦截永不出现);`listActiveConsignmentInventoriesForMatching`(`consignment-repo.ts:683-707`)全表读内存再 JS 匹配。 +9. **待审徽标只统计当前页**(`ConsignmentSubmissionsPanel.tsx:85` + `:316`)。 +10. **编辑无法解除打手关联**:`ConsignmentInventoryPanel.tsx:260` 的 `workerId || undefined` 撞后端 `!== undefined` 判定(`consignment-admin-service.ts:316-321`)。 +11. **编辑弹窗押金被静默改成 ¥100**(`ConsignmentInventoryPanel.tsx:513`),而该值会被"回库重新冻结"按金额使用(`consignment-deposit-repo.ts:242-251`)。 +12. **冷静期文案硬编码"默认 3 天"**(`WorkOrdersPanel.tsx:2852-2853`),与产品规则(`work-order-gift-repo.ts:101-105`)及同页自算的 `cooldownLabel`(`:1040-1042`)自相矛盾。 +13. **冷静期倒计时时区错位**:后端把时间统一格式化为 Asia/Shanghai 无时区串(`utils/http.ts:4,212-235`),前端按浏览器本地时区 `Date.parse`(`worker-order-utils.tsx:344`、`DeadlineCountdown.tsx:18`)⇒ 非 UTC+8 环境显示偏移 8 小时(真实门禁在 DB,仅显示错)。 +14. **接口失败静默成 0/空**:两个面板只看 `isLoading` 无 `isError`(`ConsignmentInventoryPanel.tsx:129-163`、`ConsignmentSubmissionsPanel.tsx:66-85`);打手下拉(`:117-120`)失败后指派按钮消失。 +15. **批量入库最多 50 次串行 POST,无回滚无 loading**(`ConsignmentInventoryPanel.tsx:191-222`),失败重试会重复建单(无幂等键)。 +16. **破坏性弹窗缺 `confirmLoading` → 双提交**(`:1235-1242`、`CSP:361-369/518-525`)。 +17. **两条回库路径 `allow_matching` 语义分叉**:`consignment-repo.ts:657` 强制 TRUE,撤销回库保留原值,但两处 UI 文案都宣称"重新进入待匹配池"。**✅ 已统一(本轮)**:所有回库路径一律**保留**运营的开关设置,UI 文案同步更正(决策理由见本节开头的"刻意偏离"说明)。 +18. **类型/契约漂移**:~~`services/consignment-public.ts:5-11` 声明 `ConsignmentSubmission`~~(文件已删除);`types/consignment.ts` 与后端返回结构不符、`services/worker.ts:375-391` 缺 `depositAmountFen`。**⏳ 部分未处理**(本轮已将 `sourceType` 收敛为 `manual|worker_portal|recycle`、新增 `assignedWorkerId`)。 +19. **契约约束缺失**:`allocation_status` 中文自由文本无 CHECK(`077_consignment_inventory.sql:27`);指派占库**完全不写 `bound_work_order_id`**(`consignment-repo.ts:552-572`)。**✅ 占库已写绑定(本轮)**;`allocation_status` 的 CHECK 约束仍未加。 +20. **小项**:`archive` 不记录操作人与原因;`remark` 拼接式审计无长度上限;履约成功后广播 `hallChanged: true`(`consignment-fulfillment-service.ts:264`)但工单从未进大厅(无效广播);RBAC 体系混用(寄卖下拉依赖的 `/worker-platform/workers` 仍是 `requireAdminRoles`,`workers.ts:20-22`)。 + +--- + +## 四、已核实为"正确"的部分(改动时不要破坏) + +- 内部现货履约事务是原子的:事务内重新锁单(`consignment-fulfillment-service.ts:157-168`)+ 条件更新占库(`:170-199`),并发下不会两单占一件;占库同时写入工单绑定(本轮新增,含 `bound_work_order_id`)。 +- 打手指派占库是单条条件 UPDATE(`consignment-repo.ts` claim 函数),回滚带 `WHERE status='consumed'`(有测试覆盖)。 +- 押金账务并发安全:先锁钱包 `FOR UPDATE` 再读流水,缺口时拒绝部分退款,重复冻结幂等(`consignment-deposit-repo.ts`;本轮另加"归属/金额以冻结核销行为准"与部分唯一索引)。 +- 审核入库/驳回原子且带状态条件。 +- 押金履约模式禁止走撤销(`resolveRevocableConsignmentInventoryId`),且该模式下的库存已改为按工单绑定,撤单时由工单终态链路自动解绑。 +- RBAC 权限链正确:四权限已在 `077` 注册并授予 admin/operator/support,`requireAdminPermission` 对 `admin` 直通;19 个接口迁移无遗漏,没有写接口只挂 view。 +- 上传有服务端大小/类型限制(`routes/file-upload.ts` 10MB + magic-byte 白名单)。 +- 订单级押金与寄卖发布押金互不误伤:前者按 `related_work_order_id` 匹配,后者 `related_work_order_id IS NULL` 且按 `payload_json.consignmentSubmissionId` 匹配。 +- 手工钱包调整只写 `available_amount`,不减少 `frozen_deposit_amount`,不破坏冻结池不变量。 + +--- + +## 五、优化路线(分批,每批可独立发布) + +### 批次 A · 修复两条断掉的入口链路 —— ✅ 已完成(性质已变) +1. ~~H5 上传返回值取 `data.file`~~ → **H5 渠道整体下线**(表、路由、页面、服务、`link_id` 列一并删除;迁移 `079`),提报统一走接单平台打手端。 +2. ✅ 手工入库/编辑兼容 `credentialsJson/screenshotsJson`,三处入口统一走 `pickCredentialsJson` / `pickScreenshotsJson`。 +3. 验收:`npm test` 401 例(398 通过 / 3 skip)、`typecheck`/`lint:check`/`format:check`/`check:sql` 全绿。 + +### 批次 B · 状态机封闭与库存绑定唯一性 —— ✅ 已完成 +1. ✅ 撤销:`FOR UPDATE` 锁单 + 状态白名单(`in_progress|pending_acceptance|problem`)+ `UPDATE ... AND status=$n` + 原因必填 + 前端按状态与 `consignment.fulfill` 权限渲染。 +2. ✅ 库存绑定收口:撤销加 `bound_work_order_id = $工单`;退回复库/作废加"未被进行中工单占用"守卫;0 行 409 且给出具体工单号;`079` 加部分唯一索引兜底并清理历史重复绑定。 +3. ✅ 生命周期收口:`releaseConsignmentInventoryBindingForWorkOrderWithClient` 接入撤单仓库 5 条链路 + 打手退单申请 + 超时处置(重开/取消)。 +4. ✅ 回归测试 5 例(占库绑定、状态白名单、解绑范围、作废/回库守卫、终态链路接线)。 +5. 验收偏差:原始表述"回库路径一律恢复 `allow_matching`"改为"保留运营开关设置",理由见第三节"刻意偏离"。 + +### 批次 C · 指派事务化、资金/凭证一致性(P1)—— 下一批 +1. claim + assign + material/reward + event 合并事务,失败改补偿式撤销(P1-2)。 +2. 凭证核销并入状态变更事务或显式失败(P1-5)。 +3. 押金没收路径(P2-2 的 `deposit_deduction` 死分支):补"卖家违约扣没发布押金"或移除该死分支。 +4. 编辑接口的 `depositAmountFen` 与台账一致性校验(审核改额的剩余风险)。 + +### 批次 D · 匹配准确性与防错发(P1) +1. 空规格不放行 + 钻级不再兜底 1(P1-6)。 +2. 履约事务内复检规格,非 exact 一律 409。 +3. 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)。 + +### 批次 E · 权限门控与运营体验(P1/P2) +1. 前端四权限门控(P1-8 剩余部分:库存/提报面板尚无 `consignment.view/manage/review` 门控);打手端菜单收口(P1-9 剩余)。 +2. 报表按文档 §六补齐节省赏金等指标(P2-7);`allocation_status` CHECK 约束(P2-19 剩余)。 +3. 面板错误态、双提交、徽标、搜索、押金回显、文案/时区等体验项(P2-1~P2-18)。 + +--- + +## 六、策略问题(原待确认项 → 已定案) + +1. **撤销边界**:✅ 定案 —— **已验收一律禁止撤销**,历史数据不做兼容(用户:"这是测试阶段,不用管历史记录")。买家退款/纠纷走售后流程;若日后确有"验收后退款"场景,另开"售后回收库存"流程(回库或作废 + 押金重算)。 +2. **`worker_id` 拆分**:✅ 定案 —— 直接新增 `assigned_worker_id` 并让 `worker_id` 固定为卖家(迁移 `079`,无兼容期,不回填历史)。 +3. **押金策略**:⏳ 未定 —— `pending_review` 超期自动驳回退押金的时限(如 7 天)、以及打手自助撤回是否允许,涉及运营对账口径,需业务确认。 +4. **批次顺序**:✅ 定案 —— 先 A(入口可用)→ 再 B(资金安全);两者本轮均已落地,C/D/E 待排期。