diff --git a/apps/backend/src/db/migrations/146_consignment_share_gift.sql b/apps/backend/src/db/migrations/146_consignment_share_gift.sql new file mode 100644 index 00000000..5cc1a11d --- /dev/null +++ b/apps/backend/src/db/migrations/146_consignment_share_gift.sql @@ -0,0 +1,13 @@ +-- 146_consignment_share_gift.sql +-- +-- 拼单寄卖:每个卖家的库存对应一条 work_order_share,好友赠送的“加好友时间/冷却截止” +-- 必须落在份额上,不能再用 work_orders.friend_added_at 这种整单字段。 +-- 这样 A 确认加好友只影响 A 的份额,B 仍可独立确认、独立提交赠送凭证。 +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS friend_added_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS gift_available_at TIMESTAMPTZ; + +COMMENT ON COLUMN work_order_shares.friend_added_at IS + '寄卖拼单份额:卖家确认加好友时间(冷却起点);非寄卖份额为 NULL'; +COMMENT ON COLUMN work_order_shares.gift_available_at IS + '寄卖拼单份额:赠送冷却结束时间;到点后按时间实时推导为可赠送'; diff --git a/apps/backend/src/domain/consignment-item-stage.test.ts b/apps/backend/src/domain/consignment-item-stage.test.ts index 24cc850b..93a56b5e 100644 --- a/apps/backend/src/domain/consignment-item-stage.test.ts +++ b/apps/backend/src/domain/consignment-item-stage.test.ts @@ -205,3 +205,61 @@ test('标准验收的寄卖单:履约中 / 待验收,按钮文案是提交 }) assert.equal(pending.stage, CONSIGNMENT_ITEM_STAGE.PENDING_ACCEPTANCE) }) + +test('拼单寄卖份额:加好友/冷却/提交按份额独立推导', () => { + const waiting = deriveConsignmentItemStage({ + submissionStatus: 'approved', + inventoryStatus: 'consumed', + workOrder: friendGiftOrder(), + share: { status: 'joined', friend_added_at: null, gift_available_at: null }, + viewer: SELLER, + now: NOW, + }) + assert.equal(waiting.stage, CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND) + assert.equal(waiting.canConfirmFriend, true) + assert.equal(waiting.canSubmitEvidence, false) + + const cooldown = deriveConsignmentItemStage({ + submissionStatus: 'approved', + inventoryStatus: 'consumed', + workOrder: friendGiftOrder(), + share: { + status: 'joined', + friend_added_at: '2026-09-17T10:00:00.000Z', + gift_available_at: '2026-09-17T13:00:00.000Z', + }, + viewer: SELLER, + now: NOW, + }) + assert.equal(cooldown.stage, CONSIGNMENT_ITEM_STAGE.GIFT_COOLDOWN) + assert.equal(cooldown.canSubmitEvidence, false) + + const ready = deriveConsignmentItemStage({ + submissionStatus: 'approved', + inventoryStatus: 'consumed', + workOrder: friendGiftOrder(), + share: { + status: 'joined', + friend_added_at: '2026-09-17T10:00:00.000Z', + gift_available_at: '2026-09-17T11:00:00.000Z', + }, + viewer: SELLER, + now: NOW, + }) + assert.equal(ready.stage, CONSIGNMENT_ITEM_STAGE.GIFT_READY) + assert.equal(ready.canSubmitEvidence, true) + + const submitted = deriveConsignmentItemStage({ + submissionStatus: 'approved', + inventoryStatus: 'consumed', + workOrder: friendGiftOrder(), + share: { + status: 'submitted', + friend_added_at: '2026-09-17T10:00:00.000Z', + gift_available_at: '2026-09-17T11:00:00.000Z', + }, + viewer: SELLER, + now: NOW, + }) + assert.equal(submitted.stage, CONSIGNMENT_ITEM_STAGE.GIFT_SUBMITTED) +}) diff --git a/apps/backend/src/domain/consignment-item-stage.ts b/apps/backend/src/domain/consignment-item-stage.ts index 13f859ff..d2238b17 100644 --- a/apps/backend/src/domain/consignment-item-stage.ts +++ b/apps/backend/src/domain/consignment-item-stage.ts @@ -92,10 +92,18 @@ export type ConsignmentItemWorkOrderInput = { assignedWorkerId?: number | null } +export type ConsignmentItemShareInput = { + status: string + friend_added_at?: string | null + gift_available_at?: string | null +} + export type DeriveConsignmentItemStageInput = { submissionStatus: string inventoryStatus?: string | null workOrder?: ConsignmentItemWorkOrderInput | null + /** 拼单寄卖:这一行对应的卖家份额;有值时按份额推导,不再看整单赠送阶段 */ + share?: ConsignmentItemShareInput | null /** 当前查看者身份:卖家端要判断"是不是我在履约"才能给出按钮 */ viewer?: { role: 'seller'; workerId: number } | { role: 'admin' } /** 便于测试注入"现在" */ @@ -186,6 +194,46 @@ export function deriveConsignmentItemStage( const viewerWorkerId = input.viewer?.role === 'seller' ? Number(input.viewer.workerId) : 0 const selfFulfilling = viewerWorkerId > 0 && Number(workOrder.assignedWorkerId || 0) === viewerWorkerId + const nowMs = input.now ?? Date.now() + + // 拼单寄卖:每个卖家一条份额,加好友/冷却/提交凭证互不影响。 + if (input.share) { + const shareStatus = String(input.share.status || '').trim() + if (shareStatus === 'accepted') { + return build(CONSIGNMENT_ITEM_STAGE.COMPLETED, '该份额已验收结算') + } + if (shareStatus === 'cancelled') { + return build(CONSIGNMENT_ITEM_STAGE.CANCELLED, '该份额已取消') + } + if (shareStatus === 'submitted') { + return isFriendGift + ? build(CONSIGNMENT_ITEM_STAGE.GIFT_SUBMITTED, '赠送凭证已提交,等待客服审核') + : build(CONSIGNMENT_ITEM_STAGE.PENDING_ACCEPTANCE, '验收材料已提交,等待客服验收') + } + if (isFriendGift) { + const friendAddedAt = Date.parse(String(input.share.friend_added_at || '')) + if (!Number.isFinite(friendAddedAt)) { + return build( + CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND, + '请先加上买家好友,加好后点「确认加好友」:确认即进入赠送冷却期,未完成赠送押金不退', + { canConfirmFriend: true }, + ) + } + const availableAt = Date.parse(String(input.share.gift_available_at || '')) + if (!Number.isFinite(availableAt) || availableAt > nowMs) { + return build(CONSIGNMENT_ITEM_STAGE.GIFT_COOLDOWN, '本份额的赠送冷却未结束,请耐心等待') + } + return build( + CONSIGNMENT_ITEM_STAGE.GIFT_READY, + '本份额冷却已结束,请用账号赠送并上传赠送截图给客服审核(未完成赠送押金不退)', + { canSubmitEvidence: true, evidenceButtonLabel: '提交赠送截图' }, + ) + } + return build(CONSIGNMENT_ITEM_STAGE.FULFILLING, '请提交本份额的验收材料,等待客服验收', { + canSubmitEvidence: true, + evidenceButtonLabel: '提交验收材料', + }) + } if (isFriendGift) { const now = input.now ?? Date.now() diff --git a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts index 9d4aaa5a..da1763fb 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts @@ -1,5 +1,7 @@ import type { PoolClient } from 'pg' +import { query } from '../../db/client.js' + export type ConsignmentAllocationRow = { id: number inventory_id: number @@ -134,6 +136,34 @@ async function releaseAllocationRows( `, [now, row.inventory_id], ) + } else { + // 还有其它占用:绑定字段必须指向剩下的最新一张工单,否则客服侧会看到已释放的旧单。 + await client.query( + ` + UPDATE consignment_inventories ci + SET + bound_order_id = wo.order_id, + bound_work_order_id = wo.id, + bound_platform_order_id = COALESCE(wo.platform_order_id, ''), + shop_name = COALESCE( + NULLIF(wo.material_json -> 'source' ->> 'shopName', ''), + NULLIF(wo.material_json -> 'source' ->> 'shopTitle', ''), + '' + ), + allocation_status = '已安排', + updated_at = $2 + FROM work_orders wo + WHERE ci.id = $1 + AND wo.id = ( + SELECT work_order_id + FROM consignment_inventory_allocations + WHERE inventory_id = $1 AND status IN ('reserved', 'consumed') + ORDER BY id DESC + LIMIT 1 + ) + `, + [row.inventory_id, now], + ) } } } @@ -144,14 +174,58 @@ export async function releaseConsignmentAllocationForShareWithClient( shareId: number, now: string, ): Promise { - const result = await client.query( - `SELECT * FROM consignment_inventory_allocations WHERE work_order_share_id = $1 AND status = 'reserved' FOR UPDATE`, + const result = await client.query( + `SELECT ca.*, wos.friend_added_at + FROM consignment_inventory_allocations ca + LEFT JOIN work_order_shares wos ON wos.id = ca.work_order_share_id + WHERE ca.work_order_share_id = $1 AND ca.status = 'reserved' + FOR UPDATE OF ca`, [shareId], ) + if (result.rows.length === 0) return 0 + + // 已确认加好友的赠礼份额:货可能已经送给买家,撤销时只能作废,不能回池二次出售。 + if (result.rows.some((row) => Boolean(row.friend_added_at))) { + for (const row of result.rows) { + await client.query( + ` + UPDATE consignment_inventories + SET + status = 'archived', + remaining_quantity = 0, + allow_matching = FALSE, + allocation_status = '待安排', + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + consumed_at = NULL, + updated_at = $1 + WHERE id = $2 + `, + [now, row.inventory_id], + ) + await client.query( + `UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`, + [now, row.id], + ) + } + return result.rows.length + } + await releaseAllocationRows(client, result.rows, now) return result.rows.length } +/** 供其它释放路径复用:按分配行回补数量/释放绑定。 */ +export async function releaseConsignmentAllocationRowsWithClient( + client: PoolClient, + rows: ConsignmentAllocationRow[], + now: string, +): Promise { + await releaseAllocationRows(client, rows, now) +} + /** 释放整个工单占用的全部库存(整单取消 / 退回大厅 / 撤销履约)。 */ export async function releaseConsignmentAllocationsForWorkOrderWithClient( client: PoolClient, @@ -177,3 +251,22 @@ export async function markConsignmentAllocationsConsumedWithClient( [now, workOrderId], ) } + +/** + * 一条库存当前的有效占用(一库存可拆给多张订单)。 + * 卖家操作必须据此定位具体订单/份额,不能再只看 bound_work_order_id。 + */ +export async function listActiveConsignmentAllocationsForInventory( + inventoryId: number | string, +): Promise { + const result = await query( + ` + SELECT * + FROM consignment_inventory_allocations + WHERE inventory_id = $1 AND status IN ('reserved', 'consumed') + ORDER BY id DESC + `, + [Number(inventoryId)], + ) + return result.rows +} 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 d218f19a..603f0746 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts @@ -248,6 +248,37 @@ export async function releaseConsignmentPublishDepositBySubmissionWithClient( now: string }, ): Promise { + // 一条提报单可能对应多件库存 / 一份库存拆给多张订单:只有全部售出或作废后才能退发布押金。 + const active = await client.query<{ active: boolean }>( + ` + SELECT ( + EXISTS ( + SELECT 1 FROM consignment_inventories ci + WHERE ci.source_submission_id = $1 + AND ci.status = 'in_stock' + AND ci.remaining_quantity > 0 + ) + OR EXISTS ( + SELECT 1 + FROM consignment_inventory_allocations ca + JOIN consignment_inventories ci2 ON ci2.id = ca.inventory_id + LEFT JOIN work_order_shares wos ON wos.id = ca.work_order_share_id + LEFT JOIN work_orders wo ON wo.id = ca.work_order_id + WHERE ci2.source_submission_id = $1 + AND ca.status = 'reserved' + AND ( + (ca.work_order_share_id IS NOT NULL + AND COALESCE(wos.status, 'joined') NOT IN ('accepted', 'cancelled')) + OR (ca.work_order_share_id IS NULL + AND COALESCE(wo.status, '') NOT IN ('accepted', 'completed', 'cancelled')) + ) + ) + ) AS active + `, + [input.submissionId], + ) + if (active.rows[0]?.active === true) return 0 + const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId) if (!target) return 0 return releaseConsignmentPublishDepositWithClient(client, { diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts index 487794fe..b7e13c4e 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts @@ -152,11 +152,24 @@ test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/ assert.ok(body.includes(field), `关键字查询缺少可搜字段:${field}`) } - // 兜底行(没有归属提报单的库存)里不能引用提报单别名,否则真库直接 42703 报错 - const orphanStart = body.indexOf('orphanKeywordFilter = `') - const orphanEnd = body.indexOf('`', orphanStart + 'orphanKeywordFilter = `'.length) - const orphanBody = body.slice(orphanStart, orphanEnd) - assert.ok(!orphanBody.includes('cs.'), '兜底库存行的关键字条件引用了提报单别名 cs.') + // 待审核行还没有库存,关键字条件不能引用 ci./wo.,否则真库直接 42703 报错 + const pendingStart = body.indexOf('pendingKeywordFilter = `') + assert.ok(pendingStart > 0, '缺少待审核行的关键字条件') + const pendingEnd = body.indexOf('`', pendingStart + 'pendingKeywordFilter = `'.length) + const pendingBody = body.slice(pendingStart, pendingEnd) + assert.ok( + !pendingBody.includes('ci.') && !pendingBody.includes('wo.'), + '待审核行没有库存/工单别名,关键字条件不能引用 ci./wo.', + ) + + // 一库存拆给多张订单后,基表必须同时产出“占用行”和“剩余库存行” + assert.ok(body.includes('ca.id AS allocation_id'), '缺少占用行') + assert.ok(body.includes('ca.quantity AS row_quantity'), '占用行必须带出本单数量') + assert.ok(body.includes("ca.status IN ('reserved', 'consumed')"), '占用行口径错误') + assert.ok( + body.includes('SELECT 1 FROM consignment_inventory_allocations ca0'), + '剩余库存行必须排除已有占用的库存', + ) }) test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记录捞回前端', async () => { diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index fd609b85..284b5f12 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -1,5 +1,10 @@ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' +import { + releaseConsignmentAllocationRowsWithClient, + releaseConsignmentAllocationsForWorkOrderWithClient, + type ConsignmentAllocationRow, +} from './consignment-allocation-repo.js' import { toJsonString } from './shared.js' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' @@ -883,7 +888,63 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient( }>('SELECT acceptance_mode, friend_added_at FROM work_orders WHERE id = $1', [workOrderId]) const order = orderRes.rows[0] if (!order) return null - const archiveOnly = isWorkOrderGiftDepositForfeitable(order) + const confirmedShare = await client.query<{ id: number }>( + `SELECT ca.id + FROM consignment_inventory_allocations ca + JOIN work_order_shares wos ON wos.id = ca.work_order_share_id + WHERE ca.work_order_id = $1 AND wos.friend_added_at IS NOT NULL + LIMIT 1`, + [Number(workOrderId)], + ) + const archiveOnly = isWorkOrderGiftDepositForfeitable(order) || Boolean(confirmedShare.rows[0]) + if (archiveOnly) { + // 已确认加好友的份额作废下架;未确认的份额正常回库。整单级 friend_added_at 只作为兜底。 + const allocationResult = await client.query< + ConsignmentAllocationRow & { friend_added_at: string | null } + >( + `SELECT ca.*, wos.friend_added_at + FROM consignment_inventory_allocations ca + LEFT JOIN work_order_shares wos ON wos.id = ca.work_order_share_id + WHERE ca.work_order_id = $1 AND ca.status IN ('reserved', 'consumed') + FOR UPDATE OF ca`, + [Number(workOrderId)], + ) + for (const row of allocationResult.rows) { + if (row.friend_added_at) { + await client.query( + ` + UPDATE consignment_inventories + SET + status = 'archived', + remaining_quantity = 0, + allow_matching = FALSE, + allocation_status = '待安排', + bound_order_id = NULL, + bound_work_order_id = NULL, + bound_platform_order_id = '', + shop_name = '', + consumed_at = NULL, + updated_at = $1 + WHERE id = $2 + `, + [input.now, row.inventory_id], + ) + await client.query( + `UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`, + [input.now, row.id], + ) + } else { + await releaseConsignmentAllocationRowsWithClient(client, [row], input.now) + } + } + } else { + // 普通回库:先按分配记录回补数量,再走下面的单绑定兜底(兼容没有分配记录的历史数据)。 + await releaseConsignmentAllocationsForWorkOrderWithClient( + client, + Number(workOrderId), + input.now, + ) + } const res = await client.query( archiveOnly @@ -891,6 +952,7 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient( UPDATE consignment_inventories SET status = 'archived', + remaining_quantity = 0, allocation_status = '待安排', assigned_worker_id = NULL, bound_order_id = NULL, @@ -1064,6 +1126,12 @@ export type WorkerConsignmentGroup = 'all' | 'reviewing' | 'active' | 'finished' export type WorkerConsignmentRowKey = { submissionId: number | null inventoryId: number | null + /** 一条库存拆分给多张订单时,按占用记录拆行;库存剩余行/待审核行没有 */ + allocationId: number | null + shareId: number | null + workOrderId: number | null + /** 本行对应的数量:占用行为占用数,库存剩余行为剩余数,待审核行为 1 */ + rowQuantity: number createdAt: string } @@ -1096,13 +1164,27 @@ export async function listWorkerConsignmentPage(input: { }): Promise { const values: unknown[] = [input.workerId] const keyword = String(input.keyword || '').trim() - let submissionKeywordFilter = '' - let orphanKeywordFilter = '' + let pendingKeywordFilter = '' + let inventoryKeywordFilter = '' if (keyword) { values.push(`%${keyword}%`) const p = `$${values.length}` - // 提报单行:提报单 + 它产出的库存 + 绑定工单上的买家资料 - submissionKeywordFilter = ` + // 待审核行还没有库存,只能搜提报单字段 + pendingKeywordFilter = ` + AND ( + cs.submission_no ILIKE ${p} + OR cs.item_name ILIKE ${p} + OR cs.spec_text ILIKE ${p} + OR cs.system ILIKE ${p} + OR cs.server_zone ILIKE ${p} + OR cs.series ILIKE ${p} + OR cs.diamond_level::text || '钻' ILIKE ${p} + OR cs.seller_game_id ILIKE ${p} + OR cs.contact_info ILIKE ${p} + OR cs.seller_phone ILIKE ${p} + )` + // 库存行 / 占用行:提报单 + 库存 + 绑定工单上的买家资料 + inventoryKeywordFilter = ` AND ( cs.submission_no ILIKE ${p} OR cs.item_name ILIKE ${p} @@ -1127,63 +1209,77 @@ export async function listWorkerConsignmentPage(input: { OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p} OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p} )` - // 兜底行(没有归属提报单的库存):只能搜库存与买家资料,不能引用提报单别名 - orphanKeywordFilter = ` - AND ( - ci.inventory_no ILIKE ${p} - OR ci.item_name ILIKE ${p} - OR ci.system ILIKE ${p} - OR ci.server_zone ILIKE ${p} - OR ci.series ILIKE ${p} - OR ci.diamond_level::text || '钻' ILIKE ${p} - OR ci.seller_game_id ILIKE ${p} - OR ci.seller_contact ILIKE ${p} - OR ci.seller_phone ILIKE ${p} - OR wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname' ILIKE ${p} - OR wo.material_json -> 'collect' -> 'fields' ->> 'roleName' ILIKE ${p} - OR wo.material_json -> 'collect' -> 'fields' ->> 'nickname' ILIKE ${p} - OR wo.material_json -> 'collect' -> 'fields' ->> 'gameId' ILIKE ${p} - OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p} - OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p} - )` } - // 提报单行(连带它产出的库存与绑定工单)与「没有归属提报单的库存」兜底行 + // 三类行:待审核提报单(无库存)、库存剩余数量、库存占用记录(一库存可拆给多张订单) const baseSql = ` WITH base AS ( SELECT cs.id AS submission_id, - ci.id AS inventory_id, + NULL::bigint AS inventory_id, + NULL::bigint AS allocation_id, + NULL::bigint AS share_id, + NULL::bigint AS work_order_id, + 1::integer AS row_quantity, cs.created_at AS sort_at, CASE WHEN cs.status = 'pending_review' THEN 'reviewing' + WHEN cs.status = 'rejected' THEN 'finished' + ELSE 'active' + END AS group_key + FROM consignment_submissions cs + WHERE cs.worker_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM consignment_inventories ci0 WHERE ci0.source_submission_id = cs.id + ) + ${pendingKeywordFilter} + UNION ALL + SELECT + cs.id AS submission_id, + ci.id AS inventory_id, + NULL::bigint AS allocation_id, + NULL::bigint AS share_id, + NULL::bigint AS work_order_id, + GREATEST(ci.remaining_quantity, 0) AS row_quantity, + ci.created_at AS sort_at, + CASE + WHEN cs.status = 'rejected' THEN 'finished' + WHEN ci.status = 'archived' THEN 'finished' + ELSE 'active' + END AS group_key + FROM consignment_inventories ci + LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id + WHERE ci.worker_id = $1 + AND ( + ci.remaining_quantity > 0 + OR NOT EXISTS ( + SELECT 1 FROM consignment_inventory_allocations ca0 + WHERE ca0.inventory_id = ci.id AND ca0.status IN ('reserved', 'consumed') + ) + ) + ${inventoryKeywordFilter} + UNION ALL + SELECT + cs.id AS submission_id, + ci.id AS inventory_id, + ca.id AS allocation_id, + ca.work_order_share_id AS share_id, + ca.work_order_id AS work_order_id, + ca.quantity AS row_quantity, + ca.created_at AS sort_at, + CASE WHEN cs.status = 'rejected' THEN 'finished' WHEN ci.status = 'archived' THEN 'finished' WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished' ELSE 'active' END AS group_key - FROM consignment_submissions cs - LEFT JOIN consignment_inventories ci ON ci.source_submission_id = cs.id - LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id - WHERE cs.worker_id = $1 - ${submissionKeywordFilter} - UNION ALL - SELECT - NULL AS submission_id, - ci.id AS inventory_id, - ci.created_at AS sort_at, - CASE - WHEN ci.status = 'archived' THEN 'finished' - WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished' - ELSE 'active' - END AS group_key - FROM consignment_inventories ci - LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id + FROM consignment_inventory_allocations ca + JOIN consignment_inventories ci ON ci.id = ca.inventory_id + LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + JOIN work_orders wo ON wo.id = ca.work_order_id WHERE ci.worker_id = $1 - AND NOT EXISTS ( - SELECT 1 FROM consignment_submissions cs2 - WHERE cs2.id = ci.source_submission_id AND cs2.worker_id = $1 - ) - ${orphanKeywordFilter} + AND ca.status IN ('reserved', 'consumed') + ${inventoryKeywordFilter} ) ` @@ -1226,13 +1322,20 @@ export async function listWorkerConsignmentPage(input: { const listRes = await query<{ submission_id: string | number | null inventory_id: string | number | null + allocation_id: string | number | null + share_id: string | number | null + work_order_id: string | number | null + row_quantity: number | string sort_at: string }>( `${baseSql} - SELECT submission_id, inventory_id, sort_at + SELECT submission_id, inventory_id, allocation_id, share_id, work_order_id, row_quantity, sort_at FROM base ${groupFilter} - ORDER BY sort_at DESC, COALESCE(submission_id, 0) DESC, COALESCE(inventory_id, 0) DESC + ORDER BY sort_at DESC, + COALESCE(submission_id, 0) DESC, + COALESCE(inventory_id, 0) DESC, + COALESCE(allocation_id, 0) DESC LIMIT $${listValues.length - 1} OFFSET $${listValues.length}`, listValues, ) @@ -1241,6 +1344,10 @@ export async function listWorkerConsignmentPage(input: { rows: listRes.rows.map((row) => ({ submissionId: row.submission_id == null ? null : Number(row.submission_id), inventoryId: row.inventory_id == null ? null : Number(row.inventory_id), + allocationId: row.allocation_id == null ? null : Number(row.allocation_id), + shareId: row.share_id == null ? null : Number(row.share_id), + workOrderId: row.work_order_id == null ? null : Number(row.work_order_id), + rowQuantity: Math.max(0, Number(row.row_quantity || 0)), createdAt: row.sort_at, })), total, diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index b1a70100..27585c0c 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -383,6 +383,9 @@ export type WorkOrderShareRow = { request_id?: string /** 拼单份额对应的寄卖库存;非寄卖份额为 NULL */ consignment_inventory_id?: number | null + /** 寄卖拼单份额:卖家确认加好友时间 / 赠送冷却结束时间 */ + friend_added_at?: string | null + gift_available_at?: string | null worker_username?: string worker_display_name?: string } diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 895f4b48..2b0e1f73 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -2,6 +2,11 @@ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' import { resolveShareReduceTargetQuantity } from '../../domain/work-order-share-quantity.js' +import { + isFriendGiftAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, + normalizeWorkOrderGiftCooldownMinutes, +} from '../../domain/work-order-acceptance-mode.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, @@ -136,6 +141,95 @@ export async function getWorkOrderShareById( return result.rows[0] || null } +/** + * 寄卖拼单份额:卖家确认已加买家好友。 + * 冷却起止只写这一条份额,不影响同单其他卖家。 + */ +export async function confirmWorkOrderShareFriendAdded(input: { + workOrderId: number + shareId: number + actorType?: 'admin' | 'worker' + actorId?: string + now: string +}): Promise<{ + share: WorkOrderShareRow | null + failureReason: + | 'work_order_not_found' + | 'work_order_gift_mode_invalid' + | 'share_not_found' + | 'share_status_invalid' + | 'share_not_consignment' + | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [Number(input.workOrderId)], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder) return { share: null, failureReason: 'work_order_not_found' } + if (!isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) { + return { share: null, failureReason: 'work_order_gift_mode_invalid' } + } + + const lockResult = await client.query<{ id: number }>( + `SELECT id FROM work_order_shares + WHERE id = $1 AND work_order_id = $2 + FOR UPDATE`, + [Number(input.shareId), Number(input.workOrderId)], + ) + if (!lockResult.rows[0]) return { share: null, failureReason: 'share_not_found' } + + const shareResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [Number(input.shareId)], + ) + const share = shareResult.rows[0] || null + if (!share) return { share: null, failureReason: 'share_not_found' } + if (share.status !== 'joined') return { share, failureReason: 'share_status_invalid' } + if (!Number(share.consignment_inventory_id || 0)) { + return { share, failureReason: 'share_not_consignment' } + } + + const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours) + const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes) + await client.query( + ` + UPDATE work_order_shares + SET friend_added_at = $1::timestamptz, + gift_available_at = $1::timestamptz + + ($2::int * INTERVAL '1 hour') + + ($3::int * INTERVAL '1 minute'), + updated_at = $1::timestamptz + WHERE id = $4 + `, + [input.now, cooldownHours, cooldownMinutes, Number(input.shareId)], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(input.workOrderId), + actorType: input.actorType || 'worker', + actorId: input.actorId || '', + eventType: 'consignment_share_gift_countdown_started', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + shareId: Number(input.shareId), + workerId: Number(share.worker_id), + inventoryId: Number(share.consignment_inventory_id), + cooldownHours, + cooldownMinutes, + friendAddedAt: input.now, + }), + now: input.now, + }) + const updated = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [Number(input.shareId)], + ) + return { share: updated.rows[0] || null, failureReason: null } + }) +} + /** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */ export function resolveWorkOrderShareJoinQuantity(input: { quantity: number @@ -546,6 +640,8 @@ export async function submitWorkOrderShareAcceptance(input: { shareId: number acceptanceJson: string now: string + /** 寄卖拼单:母单是 in_progress,提交份额后不能按大厅规则把母单改回 open */ + preserveWorkOrderStatus?: boolean }): Promise<{ share: WorkOrderShareRow | null failureReason: 'share_not_found' | 'share_status_invalid' | 'work_order_status_invalid' | null @@ -562,9 +658,6 @@ export async function submitWorkOrderShareAcceptance(input: { if (!workOrder) { return { share: null, failureReason: 'share_not_found' } } - if (!['open', 'pending_acceptance'].includes(workOrder.status)) { - return { share: null, failureReason: 'work_order_status_invalid' } - } const lockResult = await client.query<{ id: number }>( ` @@ -587,6 +680,12 @@ export async function submitWorkOrderShareAcceptance(input: { if (!share) { return { share: null, failureReason: 'share_not_found' } } + const consignmentShare = Number(share.consignment_inventory_id || 0) > 0 + const allowedStatuses = ['open', 'pending_acceptance'] + if (consignmentShare || input.preserveWorkOrderStatus) allowedStatuses.push('in_progress') + if (!allowedStatuses.includes(workOrder.status)) { + return { share: null, failureReason: 'work_order_status_invalid' } + } if (share.status !== 'joined') { return { share, failureReason: 'share_status_invalid' } } @@ -599,7 +698,9 @@ export async function submitWorkOrderShareAcceptance(input: { [input.acceptanceJson, input.now, Number(share.id)], ) - await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) + if (!consignmentShare && !input.preserveWorkOrderStatus) { + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) + } await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, @@ -612,6 +713,7 @@ export async function submitWorkOrderShareAcceptance(input: { workerId: input.workerId, shareId: Number(share.id), quantity: share.quantity, + consignmentShare, }), now: input.now, }) diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 99099d45..ba398412 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -42,7 +42,7 @@ import { listInventoryMatchableOrders, listWorkOrderInventoryMatchOptions, } from '../../../services/worker-platform/consignment-fulfillment-service.js' -import { listPendingPistolMatches } from '../../../services/worker-platform/consignment-pending-match-service.js' +import { listPendingConsignmentMatches } from '../../../services/worker-platform/consignment-pending-match-service.js' import { listConsignmentMatchCandidatesForWorkOrder, matchConsignmentWorkOrderManually, @@ -366,20 +366,20 @@ router.post( // ---------------- 匹配与履约 ---------------- -// 全局待抢单匹配看板:只看非拼单的待抢单工单 × 在库待匹配现货,只读。 +// 全局待抢单匹配看板:按寄卖配置匹配待抢单工单 × 在库待匹配现货,只读。 router.get( '/worker-platform/consignment/pending-pistol-matches', requireAdminPermission('consignment.view'), createJsonHandler( (req) => - listPendingPistolMatches({ + listPendingConsignmentMatches({ keyword: String(req.query.keyword || ''), includeConflicts: String(req.query.includeConflicts || '') === 'true', limit: Number(req.query.limit || 0) || undefined, }), { successMessage: 'ok', - errorMessage: '读取待匹配手枪订单失败', + errorMessage: '读取待匹配订单失败', scope: '[admin/consignment/pending-pistol-matches]', }, ), diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 12da7a25..b7f9de34 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -8,6 +8,7 @@ import { cancelAdminWorkOrder, completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriendAdded, + confirmAdminWorkOrderShareFriendAdded, createAdminMockWorkOrder, deleteAdminMockCustomShop, deleteAdminWorkOrder, @@ -529,6 +530,30 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/confirm-friend', + requireAdminPermission('worker_order.manage_problem'), + createJsonHandler( + (req) => + confirmAdminWorkOrderShareFriendAdded( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '已确认该份额加好友,赠送冷却开始', + errorMessage: '确认份额加好友失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/confirm-friend]', + audit: (req, data) => ({ + action: 'work_order_share_friend_confirmed', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/reset-gift-material', requireAdminPermission('worker_order.manage_problem'), diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index 437cb66a..870b6c82 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -147,6 +147,11 @@ router.post( session.workerId, String(req.params.submissionId || ''), session.username || '', + { + workOrderId: req.body?.workOrderId, + shareId: req.body?.shareId, + allocationId: req.body?.allocationId, + }, ) res.json({ ok: true, data: result }) } catch (error) { diff --git a/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts index 1dadbe80..0a8409c7 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts @@ -1,10 +1,11 @@ import { completeWorkOrderGiftCooldown, confirmWorkOrderFriendAdded, + confirmWorkOrderShareFriendAdded, resetWorkOrderGiftMaterial, } from '../../repositories/worker-platform/index.js' import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' -import { mapWorkOrderAdmin } from './mappers.js' +import { mapWorkOrderAdmin, mapWorkOrderShare } from './mappers.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' import { nowIso } from '../../utils/time.js' import { createHttpError } from '../../utils/http.js' @@ -46,6 +47,45 @@ export async function confirmAdminWorkOrderFriendAdded( return { order: mapWorkOrderAdmin(order) } } +/** 后台按拼单份额确认加好友:只影响该卖家的份额,不动同单其他卖家。 */ +export async function confirmAdminWorkOrderShareFriendAdded( + workOrderId: number | string, + shareId: number | string, + actorName = '', +) { + const result = await confirmWorkOrderShareFriendAdded({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + actorType: 'admin', + actorId: actorName, + now: nowIso(), + }) + if (result.failureReason) { + const failure = result.failureReason + const message = + failure === 'work_order_not_found' + ? '订单不存在' + : failure === 'work_order_gift_mode_invalid' + ? '该订单不是好友赠送模式' + : failure === 'share_status_invalid' + ? '该份额已提交或已验收,不能重复确认' + : failure === 'share_not_consignment' + ? '该份额不是寄卖份额' + : '未找到该拼单份额' + throw createHttpError(message, { + statusCode: failure === 'work_order_not_found' || failure === 'share_not_found' ? 404 : 409, + errorCode: `work_order_share_friend_${failure}`, + }) + } + if (result.share) { + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: [Number(result.share.worker_id)], + }) + } + return { share: result.share ? mapWorkOrderShare(result.share) : null } +} + /** 客服手动结束好友赠送冷却,让打手立即可以提交赠送凭证。 */ export async function completeAdminWorkOrderGiftCooldown( workOrderId: number | string, diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 9034eb12..0b565b7c 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -2,6 +2,7 @@ import type { PoolClient } from 'pg' import { withTransaction, query } from '../../db/client.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js' import { createWorkOrderEventWithClient, getWorkOrderById, @@ -59,7 +60,7 @@ function inventorySalePriceFen(inventory: ConsignmentInventoryRow): number { return Math.max(0, Math.round(Number(inventory.sale_price_fen || inventory.cost_price_fen || 0))) } -function inventoryMatchesProfile( +export function inventoryMatchesProfile( inventory: ConsignmentInventoryRow, profile: ConsignmentProfileSnapshot | null, ): boolean { @@ -162,7 +163,7 @@ function resolveOrderSpecValues(workOrder: WorkOrderRow): ConsignmentSpecValues return values } -function evaluateInventoryMatch( +export function evaluateInventoryMatch( workOrder: WorkOrderRow, inventory: ConsignmentInventoryRow, profile: ConsignmentProfileSnapshot | null, @@ -208,7 +209,7 @@ function evaluateInventoryMatch( } } -async function resolveProfileForWorkOrder( +export async function resolveProfileForWorkOrder( workOrder: WorkOrderRow, ): Promise { if (workOrder.consignment_profile_id) { @@ -327,6 +328,7 @@ function pickCandidateInventories( const preferLowReward = options.preferLowReward !== false return inventories .filter((inventory) => inventoryMatchesProfile(inventory, profile)) + .filter((inventory) => Number(inventory.remaining_quantity ?? 1) > 0) .map((inventory) => ({ inventory, evaluation: evaluateInventoryMatch(workOrder, inventory, profile), @@ -362,11 +364,23 @@ async function applyWholeOrderMatch( const material = parseMaterial(current.material_json) const source = parseMaterial(material.source) const shopName = String(source.shopName || source.shopTitle || '').trim() + // 整单也必须按订单总数量占用:数量 > 1 的非拼单订单以前只扣 1 件, + // 剩余件会留在 in_stock 且绑定字段被下一单覆盖。 + const requiredQuantity = Math.max(1, Number(current.sharing_total_quantity || 1)) + const unitPriceFen = inventorySalePriceFen(inventory) + // 好友赠送单要求“一份库存一次性送完”:不允许把一份库存拆给多张赠礼单, + // 否则同一个账号的加好友/冷却/赠送凭证无法按订单归属。 + if ( + isFriendGiftAcceptanceMode(current.acceptance_mode) && + Number(inventory.remaining_quantity ?? 1) !== requiredQuantity + ) { + return false + } const allocation = await reserveConsignmentInventoryWithClient(client, { inventoryId: Number(inventory.id), workOrderId: Number(current.id), - quantity: 1, - unitPriceFen: inventorySalePriceFen(inventory), + quantity: requiredQuantity, + unitPriceFen, now, boundOrderId: current.order_id ? Number(current.order_id) : null, boundPlatformOrderId: current.platform_order_id || '', @@ -375,7 +389,8 @@ async function applyWholeOrderMatch( if (!allocation) return false const config = profile?.config - const grossRewardFen = inventorySalePriceFen(inventory) || Number(current.reward_amount || 0) + const grossRewardFen = + (unitPriceFen || Math.max(0, Number(current.reward_amount || 0))) * requiredQuantity const serviceFeeFen = config ? buildServiceFeeFen(config, grossRewardFen) : 0 const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen) const timeoutMinutes = config?.timeout.minutes ?? 30 @@ -393,7 +408,8 @@ async function applyWholeOrderMatch( brillianceCount: inventory.brilliance_count, profileId: profile?.profileId ?? null, profileVersionId: profile?.versionId ?? null, - salePriceFen: inventorySalePriceFen(inventory), + salePriceFen: unitPriceFen, + quantity: requiredQuantity, grossRewardFen, serviceFeeFen, netRewardFen, @@ -517,23 +533,36 @@ async function applySharingMatch( const unitRewardFen = Math.max(0, Number(current.sharing_unit_reward || 0)) || Math.max(0, Number(current.reward_amount || 0)) / totalQuantity - const shareGrossFen = Math.max(0, Math.round(unitRewardFen)) - const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0 - const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen) + const shareGrossFenPerUnit = Math.max(0, Math.round(unitRewardFen)) + const shareFeeFenPerUnit = config ? buildServiceFeeFen(config, shareGrossFenPerUnit) : 0 + const shareNetFenPerUnit = Math.max(0, shareGrossFenPerUnit - shareFeeFenPerUnit) const material = parseMaterial(current.material_json) const source = parseMaterial(material.source) const shopName = String(source.shopName || source.shopTitle || '').trim() let createdCount = 0 + let createdQuantity = 0 let firstInventoryId: number | null = null + let firstInventoryQuantity = 0 for (const { inventory } of candidates) { if (remaining <= 0) break if (!inventory.worker_id) continue + // 一条库存可以拆给多张订单:本次最多吃掉“订单还缺的份数”, + // 剩下的留在 remaining_quantity 里等下一单。好友赠送单必须整份匹配,避免同一份库存跨单赠礼。 + const available = Math.max(0, Math.round(Number(inventory.remaining_quantity ?? 1))) + if (isFriendGiftAcceptanceMode(current.acceptance_mode) && available > remaining) continue + const take = isFriendGiftAcceptanceMode(current.acceptance_mode) + ? available + : Math.min(available, remaining) + if (take <= 0) continue + const shareGrossFen = shareGrossFenPerUnit * take + const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0 + const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen) const allocation = await reserveConsignmentInventoryWithClient(client, { inventoryId: Number(inventory.id), workOrderId: Number(current.id), - quantity: 1, + quantity: take, unitPriceFen: inventorySalePriceFen(inventory), now, boundOrderId: current.order_id ? Number(current.order_id) : null, @@ -549,7 +578,7 @@ async function applySharingMatch( status, acceptance_json, draft_acceptance_json, deadline_at, timeout_minutes, timeout_policy, request_id, consignment_inventory_id, created_at, updated_at ) VALUES ( - $1, $2, 1, $3, $4, 0, + $1, $2, $10, $3, $4, 0, 'joined', '{}'::jsonb, '{}'::jsonb, $5, $6, $7, '', $8, $9, $9 ) @@ -558,13 +587,14 @@ async function applySharingMatch( [ current.id, Number(inventory.worker_id), - shareGrossFen, + shareGrossFenPerUnit, shareNetFen, deadline, timeoutMinutes, timeoutPolicy, Number(inventory.id), now, + take, ], ) const shareId = Number(share.rows[0]?.id || 0) @@ -574,12 +604,16 @@ async function applySharingMatch( [shareId, now, allocation.id], ) createdCount += 1 - if (!firstInventoryId) firstInventoryId = Number(inventory.id) - remaining -= 1 + createdQuantity += take + if (!firstInventoryId) { + firstInventoryId = Number(inventory.id) + firstInventoryQuantity = take + } + remaining -= take } if (createdCount > 0) { - active += createdCount + active += createdQuantity const nextStatus = active >= totalQuantity ? WORK_ORDER_STATUS.IN_PROGRESS @@ -604,6 +638,7 @@ async function applySharingMatch( ? { consignment: { inventoryId: firstInventoryId, + quantity: firstInventoryQuantity, profileId: profile?.profileId ?? null, matchedAt: now, matchType: manual ? 'manual_sharing' : 'auto_sharing', @@ -642,10 +677,11 @@ async function applySharingMatch( toStatus: nextStatus, payloadJson: JSON.stringify({ createdCount, + createdQuantity, activeQuantity: active, totalQuantity, - shareRewardFen: shareNetFen, - serviceFeeFenPerShare: shareFeeFen, + shareRewardFenPerUnit: shareNetFenPerUnit, + serviceFeeFenPerUnit: shareFeeFenPerUnit, matchType: manual ? 'manual_sharing' : 'auto_sharing', ...(manual ? { @@ -704,7 +740,14 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number } const created = await applySharingMatch({ workOrder, profile }, candidates, context) if (created > 0) matched += 1 } else { - const chosen = candidates[0]! + // 整单必须由一条库存一次性满足订单总数量,不能只占 1 件。 + const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const friendGift = isFriendGiftAcceptanceMode(workOrder.acceptance_mode) + const chosen = candidates.find((candidate) => { + const available = Number(candidate.inventory.remaining_quantity ?? 1) + return available >= requiredQuantity && (!friendGift || available === requiredQuantity) + }) + if (!chosen) continue const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, { matchType: 'auto', partial: !chosen.evaluation.exact, @@ -918,6 +961,19 @@ export async function matchConsignmentWorkOrderManually(input: { return { matched: created > 0, matchedCount: created } } + const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const chosenInventory = selected[0]!.inventory + const availableQuantity = Number(chosenInventory.remaining_quantity ?? 1) + if ( + availableQuantity < requiredQuantity || + (isFriendGiftAcceptanceMode(workOrder.acceptance_mode) && + availableQuantity !== requiredQuantity) + ) { + throw createHttpError( + `该库存仅剩 ${availableQuantity} 件,订单需要 ${requiredQuantity} 件,请改选库存`, + { statusCode: 409, errorCode: 'consignment_inventory_quantity_insufficient' }, + ) + } const applied = await applyWholeOrderMatch( { workOrder: appliedWorkOrder, profile }, selected[0]!, diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index 539022bc..70ea1a38 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -29,11 +29,14 @@ import { resolveWorkerConsignmentPrice, } from './consignment-spec-service.js' import { + confirmWorkerConsignmentFriendAdded, getWorkerConsignmentPriceSuggestion, listWorkerConsignments, publishWorkerConsignment, + submitWorkerConsignmentGiftEvidence, updateWorkerConsignmentSalePrice, } from './consignment-worker-service.js' +import type { WorkerSession } from './worker-session-context-service.js' const TEST_DATABASE_URL = String(process.env.CONSIGNMENT_MATCHING_TEST_DATABASE_URL || '').trim() @@ -61,6 +64,18 @@ function errorCode(error: unknown): string { return String((error as { errorCode?: string })?.errorCode || '') } +function workerSession(workerId: number, username: string): WorkerSession { + return { + sessionId: `ctest-session-${workerId}`, + workerId, + username, + status: 'active', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + absoluteExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + sessionVersion: 1, + } +} + async function expectError(fn: () => Promise, code: string) { try { await fn() @@ -291,6 +306,7 @@ async function main() { fields?: Record sharing?: { total: number; unitReward: number } internalDeadline?: string | null + acceptanceMode?: string }) { const material = { collect: { @@ -323,6 +339,13 @@ async function main() { input.internalDeadline || null, ], ) + if (input.acceptanceMode) { + await query('UPDATE work_orders SET acceptance_mode = $1 WHERE id = $2', [ + input.acceptanceMode, + Number(res.rows[0]!.id), + ]) + res.rows[0]!.acceptance_mode = input.acceptanceMode + } return res.rows[0]! } @@ -924,6 +947,261 @@ async function main() { assert(item2.workOrderId, `卖家2 workOrderId=${item2.workOrderId}`) }) + // ---------- 7.1 多数量库存 ---------- + const multiInv = await createInventory({ + no: `CT-INV-MULTI-${suffix}`, + workerId: sellerId, + quantity: 3, + saleFen: 6000, + capFen: 6000, + }) + const multiShareWo = await createWorkOrder({ + no: `CT-WO-MULTI-${suffix}`, + status: 'internal_matching', + sharing: { total: 3, unitReward: 7000 }, + internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }) + await check('多数量库存:拼单 3 件由一份库存一次吃完,份额数量=3', async () => { + const result = await matchConsignmentWorkOrderManually({ + workOrderId: Number(multiShareWo.id), + inventoryIds: [Number(multiInv.id)], + actorName: 'ctest-admin', + }) + assert(result.matched, JSON.stringify(result)) + + const shares = await query>( + 'SELECT * FROM work_order_shares WHERE work_order_id = $1 ORDER BY id', + [Number(multiShareWo.id)], + ) + assert(shares.rows.length === 1, `shares=${shares.rows.length}`) + assert(Number(shares.rows[0]!.quantity) === 3, `share quantity=${shares.rows[0]!.quantity}`) + assert( + Number(shares.rows[0]!.share_reward) === 19950, + `share reward=${shares.rows[0]!.share_reward},应按 3×7000 扣 5% 服务费`, + ) + + const inv = await query>( + 'SELECT * FROM consignment_inventories WHERE id = $1', + [Number(multiInv.id)], + ) + assert(Number(inv.rows[0]!.remaining_quantity) === 0, '库存剩余必须扣到 0') + assert(inv.rows[0]!.status === 'consumed', `inventory=${inv.rows[0]!.status}`) + + const allocations = await query>( + 'SELECT * FROM consignment_inventory_allocations WHERE work_order_id = $1', + [Number(multiShareWo.id)], + ) + assert(allocations.rows.length === 1, `allocations=${allocations.rows.length}`) + assert(Number(allocations.rows[0]!.quantity) === 3, '分配数量必须=3') + assert(Number(allocations.rows[0]!.work_order_share_id) > 0, '分配未绑定份额') + }) + + const splitInv = await createInventory({ + no: `CT-INV-SPLIT-${suffix}`, + workerId: sellerId, + quantity: 3, + saleFen: 5000, + capFen: 5000, + }) + const splitWo1 = await createWorkOrder({ + no: `CT-WO-SPLIT1-${suffix}`, + status: 'unassigned', + }) + const splitWo2 = await createWorkOrder({ + no: `CT-WO-SPLIT2-${suffix}`, + status: 'unassigned', + }) + await check('多数量库存:拆给两张订单后,卖家列表出现两条占用行 + 一条剩余行', async () => { + for (const workOrder of [splitWo1, splitWo2]) { + const result = await matchConsignmentWorkOrderManually({ + workOrderId: Number(workOrder.id), + inventoryIds: [Number(splitInv.id)], + actorName: 'ctest-admin', + }) + assert(result.matched, `${workOrder.work_order_no} 匹配失败`) + } + const inv = await query>( + 'SELECT * FROM consignment_inventories WHERE id = $1', + [Number(splitInv.id)], + ) + assert(Number(inv.rows[0]!.remaining_quantity) === 1, '两张单各占 1 件后应剩 1 件') + assert(inv.rows[0]!.status === 'in_stock', `inventory=${inv.rows[0]!.status}`) + + const page = await listWorkerConsignments(sellerId, { pageSize: 50 }) + const rows = page.items.filter((item) => Number(item.inventoryId) === Number(splitInv.id)) + const allocationRows = rows.filter((item) => Number(item.allocationId || 0) > 0) + assert(allocationRows.length === 2, `占用行=${allocationRows.length}`) + assert( + allocationRows.every((item) => Number(item.allocatedQuantity) === 1), + '每条占用行应各占 1 件', + ) + const remainderRow = rows.find((item) => !item.allocationId) + assert(remainderRow, '缺少剩余库存行') + assert(Number(remainderRow.remainingQuantity) === 1, '剩余行应显示 1 件') + }) + + const giftInv = await createInventory({ + no: `CT-INV-GIFT-${suffix}`, + workerId: sellerId, + quantity: 3, + saleFen: 5000, + capFen: 5000, + }) + const giftSplitWo = await createWorkOrder({ + no: `CT-WO-GIFTSINGLE-${suffix}`, + status: 'internal_matching', + acceptanceMode: 'friend_gift', + internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }) + await check('多数量库存:好友赠送单必须整份匹配,不能把一份库存拆给赠礼单', async () => { + await expectError( + () => + matchConsignmentWorkOrderManually({ + workOrderId: Number(giftSplitWo.id), + inventoryIds: [Number(giftInv.id)], + actorName: 'ctest-admin', + }), + 'consignment_inventory_quantity_insufficient', + ) + await query("UPDATE work_orders SET status = 'cancelled' WHERE id = $1", [ + Number(giftSplitWo.id), + ]) + }) + + // ---------- 7.2 拼单好友赠送:按份额独立加好友/冷却/凭证 ---------- + async function publishGiftInventory(workerId: number, label: string) { + const published = await publishWorkerConsignment(workerId, { + profileId, + quantity: 1, + specValues: { + system: '苹果', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + sellerContact: `wx_gift_${label}_${suffix}`, + characterName: `赠礼卖家${label}`, + characterId: `GIFT${label}${suffix}`.slice(0, 20), + screenshotsJson: ['https://example.com/gift-item.png'], + askingPriceFen: 9000, + contactSupporterId: contactId, + }) + const approved = await approveAdminConsignmentSubmission( + Number(published.id), + { costPriceFen: 7000, buyerName: `gift-${label}` }, + 'ctest-admin', + ) + return { + submissionId: Number(published.id), + inventoryId: Number(approved.inventory.id), + } + } + + const giftA = await publishGiftInventory(sellerId, 'a') + const giftB = await publishGiftInventory(seller2Id, 'b') + const giftShareWo = await createWorkOrder({ + no: `CT-WO-GIFTSHARE-${suffix}`, + status: 'internal_matching', + sharing: { total: 2, unitReward: 7000 }, + acceptanceMode: 'friend_gift', + internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }) + await check('拼单好友赠送:A 确认加好友只影响 A 的份额,B 仍待加好友', async () => { + const matched = await matchConsignmentWorkOrderManually({ + workOrderId: Number(giftShareWo.id), + inventoryIds: [giftA.inventoryId, giftB.inventoryId], + actorName: 'ctest-admin', + }) + assert(matched.matched && matched.matchedCount === 2, JSON.stringify(matched)) + + const pageA = await listWorkerConsignments(sellerId, { pageSize: 50 }) + const rowA = pageA.items.find( + (item) => + Number(item.inventoryId) === giftA.inventoryId && Number(item.allocationId || 0) > 0, + ) + assert(rowA, '卖家 A 看不到拼单占用行') + assert(rowA.canConfirmFriend, `A canConfirmFriend=${rowA.canConfirmFriend}`) + assert(Number(rowA.shareId || 0) > 0, 'A 行缺少 shareId') + + await confirmWorkerConsignmentFriendAdded(sellerId, giftA.submissionId, 'seller-a', { + shareId: Number(rowA.shareId), + }) + const shares = await query>( + 'SELECT * FROM work_order_shares WHERE work_order_id = $1 ORDER BY id', + [Number(giftShareWo.id)], + ) + const shareA = shares.rows.find( + (share) => Number(share.consignment_inventory_id) === giftA.inventoryId, + ) + const shareB = shares.rows.find( + (share) => Number(share.consignment_inventory_id) === giftB.inventoryId, + ) + assert(shareA?.friend_added_at, 'A 份额没有写加好友时间') + assert(!shareB?.friend_added_at, 'A 确认加好友不能影响 B') + + const pageB = await listWorkerConsignments(seller2Id, { pageSize: 50 }) + const rowB = pageB.items.find( + (item) => + Number(item.inventoryId) === giftB.inventoryId && Number(item.allocationId || 0) > 0, + ) + assert(rowB, '卖家 B 看不到拼单占用行') + assert(rowB.canConfirmFriend, 'B 仍应可以独立确认加好友') + assert(!rowB.canSubmitEvidence, 'B 尚未加好友,不应能提交赠送凭证') + assert(!rowB.friendAddedAt, 'B 的加好友时间被 A 污染') + }) + + await check('拼单好友赠送:A 冷却未结束时提交被拒绝,到期后可独立提交', async () => { + const pageA = await listWorkerConsignments(sellerId, { pageSize: 50 }) + const rowA = pageA.items.find( + (item) => + Number(item.inventoryId) === giftA.inventoryId && Number(item.allocationId || 0) > 0, + ) + assert(rowA?.shareId, 'A 行缺少 shareId') + const shareId = Number(rowA.shareId) + + await expectError( + () => + submitWorkerConsignmentGiftEvidence( + sellerId, + giftA.submissionId, + { shareId, imageUrls: ['https://example.com/gift-a.png'] }, + workerSession(sellerId, 'seller-a'), + ), + 'consignment_share_gift_cooldown', + ) + + await query( + "UPDATE work_order_shares SET gift_available_at = NOW() - INTERVAL '1 minute' WHERE id = $1", + [shareId], + ) + await submitWorkerConsignmentGiftEvidence( + sellerId, + giftA.submissionId, + { shareId, imageUrls: ['https://example.com/gift-a.png'] }, + workerSession(sellerId, 'seller-a'), + ) + const share = await query>( + 'SELECT * FROM work_order_shares WHERE id = $1', + [shareId], + ) + assert(share.rows[0]!.status === 'submitted', `A share=${share.rows[0]!.status}`) + const order = await query>('SELECT * FROM work_orders WHERE id = $1', [ + Number(giftShareWo.id), + ]) + assert( + order.rows[0]!.status === 'in_progress', + `A 提交后母单不能回退成大厅状态,实际 ${order.rows[0]!.status}`, + ) + const pageB = await listWorkerConsignments(seller2Id, { pageSize: 50 }) + const rowB = pageB.items.find( + (item) => + Number(item.inventoryId) === giftB.inventoryId && Number(item.allocationId || 0) > 0, + ) + assert(rowB?.canConfirmFriend, 'A 提交后 B 仍应能确认加好友') + assert(!rowB?.canSubmitEvidence, 'A 提交不能把 B 的按钮也点亮') + }) + // ---------- 8. 超时回大厅 ---------- const expireWo = await createWorkOrder({ no: `CT-WO-EXPIRE-${suffix}`, diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts index 51bb2504..91fb26c5 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -8,9 +8,14 @@ import { scoreConsignmentMatchDetails, } from './consignment-matching-service.js' import { + buildPendingConsignmentMatchPairs, buildPendingPistolMatchPairs, type PendingPistolMatchPair, } from './consignment-pending-match-service.js' +import { + DEFAULT_CONSIGNMENT_PROFILE_CONFIG, + type ConsignmentProfileSnapshot, +} from './consignment-profile-service.js' function buildInventory(overrides: Partial = {}): ConsignmentInventoryRow { return { @@ -71,6 +76,57 @@ function pairByInventory(result: { pairs: PendingPistolMatchPair[] }, inventoryI return result.pairs.find((pair) => pair.inventoryId === inventoryId) } +const CUSTOM_SPEC = { + key: 'weaponSkin', + label: '枪皮', + type: 'text' as const, + required: true, + options: [], + placeholder: '', + helpText: '', + matchMode: 'exact' as const, + wildcardValues: [], + showInItemName: true, + sortOrder: 1, +} + +function buildProfile( + overrides: Partial = {}, +): ConsignmentProfileSnapshot { + return { + profileId: 99, + categoryId: 9, + categoryName: '自定义品类', + versionId: 1, + versionNo: 1, + config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: [CUSTOM_SPEC] }, + ...overrides, + } +} + +function buildCustomOrder(overrides: Partial = {}): WorkOrderRow { + return buildOrder({ + id: 101, + work_order_no: 'WK101', + product_name: '地铁逃生 龙纹枪皮', + material_json: JSON.stringify({ collect: { fields: { weaponSkin: '龙纹' } } }), + ...overrides, + }) +} + +function buildCustomInventory( + overrides: Partial = {}, +): ConsignmentInventoryRow { + return buildInventory({ + id: 201, + category_id: 9, + profile_id: 99, + item_name: '地铁逃生 龙纹枪皮', + spec_values_json: { weaponSkin: '龙纹' }, + ...overrides, + }) +} + test('细粒度评分:全部规格一致为 100 分', () => { const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻') const scored = scoreConsignmentMatchDetails(order, buildInventory()) @@ -245,3 +301,82 @@ test('匹配看板:配对明细包含平台号/工单号/商品名/寄售单 assert.equal(pair.itemName, '苹果微信区 暗潮 1钻 苏尔南') assert.equal(pair.score, 100) }) + +test('看板新口径:自定义分类按寄卖配置出现,并带出逐字段明细', () => { + const result = buildPendingConsignmentMatchPairs({ + orders: [buildCustomOrder()], + inventories: [buildCustomInventory(), buildInventory({ id: 202, category_id: 1 })], + profileByOrderId: new Map([[101, buildProfile()]]), + }) + + assert.equal(result.pendingOrderCount, 1) + assert.equal(result.pairCount, 1) + const pair = result.pairs[0]! + assert.equal(pair.inventoryId, 201) + assert.equal(pair.matchType, 'exact') + assert.equal(pair.profileId, 99) + assert.equal(pair.matchDetails.find((detail) => detail.key === 'weaponSkin')?.status, 'match') +}) + +test('看板新口径:自定义规格冲突默认隐藏,勾选后按 0 分展示', () => { + const conflictInventory = buildCustomInventory({ + id: 203, + spec_values_json: { weaponSkin: '虎纹' }, + }) + const hidden = buildPendingConsignmentMatchPairs({ + orders: [buildCustomOrder()], + inventories: [conflictInventory], + profileByOrderId: new Map([[101, buildProfile()]]), + }) + assert.equal(hidden.pairCount, 0) + assert.equal(hidden.conflictCount, 0) + + const included = buildPendingConsignmentMatchPairs({ + orders: [buildCustomOrder()], + inventories: [conflictInventory], + profileByOrderId: new Map([[101, buildProfile()]]), + includeConflicts: true, + }) + assert.equal(included.pairCount, 1) + assert.equal(included.conflictCount, 1) + assert.equal(included.pairs[0]?.matchType, 'conflict') + assert.equal(included.pairs[0]?.score, 0) +}) + +test('看板新口径:拼单订单展示剩余份数,而不是直接排除', () => { + const order = buildCustomOrder({ + sharing_enabled: true, + sharing_total_quantity: 5, + active_share_quantity: 2, + }) + const result = buildPendingConsignmentMatchPairs({ + orders: [order], + inventories: [buildCustomInventory()], + profileByOrderId: new Map([[101, buildProfile()]]), + }) + assert.equal(result.pairCount, 1) + assert.equal(result.pairs[0]?.remainingQuantity, 3) + assert.equal(result.pairs[0]?.totalQuantity, 5) +}) + +test('看板新口径:库存 profile 与订单配置不一致时跳过', () => { + const otherProfileInventory = buildCustomInventory({ id: 204, profile_id: 100 }) + const result = buildPendingConsignmentMatchPairs({ + orders: [buildCustomOrder()], + inventories: [otherProfileInventory], + profileByOrderId: new Map([[101, buildProfile()]]), + }) + assert.equal(result.pairCount, 0) + assert.equal(result.inStockInventoryCount, 0) +}) + +test('看板新口径:无寄卖配置的订单仍按旧手枪口径兜底', () => { + const result = buildPendingConsignmentMatchPairs({ + orders: [buildOrder({ id: 301 })], + inventories: [buildInventory({ id: 302 })], + profileByOrderId: new Map([[301, null]]), + }) + assert.equal(result.pairCount, 1) + assert.equal(result.pairs[0]?.profileId, null) + assert.equal(result.pairs[0]?.score, 100) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index af3a0730..70be396d 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -10,6 +10,12 @@ import { extractPeacekeeperSpecs, scoreConsignmentMatchDetails, } from './consignment-matching-service.js' +import { + evaluateInventoryMatch, + inventoryMatchesProfile, + resolveProfileForWorkOrder, +} from './consignment-match-engine-service.js' +import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js' import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js' export type PendingPistolMatchPair = { @@ -171,14 +177,243 @@ export function buildPendingPistolMatchPairs(input: { } } -/** 后台「寄售仓库 → 匹配」:待抢单手枪单 × 在库待匹配现货。 */ -export async function listPendingPistolMatches( +export type PendingConsignmentMatchDetail = { + key: string + label: string + orderValue: string + inventoryValue: string + status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored' +} + +/** 看板配对:在旧字段基础上补充规格明细、订单剩余数量与命中的寄卖配置。 */ +export type PendingConsignmentMatchPair = PendingPistolMatchPair & { + matchDetails: PendingConsignmentMatchDetail[] + remainingQuantity: number + totalQuantity: number + profileId: number | null +} + +export type PendingConsignmentMatchResult = { + pendingOrderCount: number + inStockInventoryCount: number + pairCount: number + exactCount: number + partialCount: number + conflictCount: number + truncated: boolean + pairs: PendingConsignmentMatchPair[] +} + +function scoreFromDetails(details: PendingConsignmentMatchDetail[]): number { + const effective = details.filter((detail) => detail.status !== 'ignored') + if (effective.length === 0) return 0 + const matched = effective.reduce((sum, detail) => { + if (detail.status === 'match') return sum + 1 + if (detail.status === 'partial') return sum + 0.85 + if (detail.status === 'unknown') return sum + 0.5 + return sum + }, 0) + return Math.round((matched / effective.length) * 100) +} + +function buildEvaluationReason(evaluation: ReturnType): string { + if (evaluation.exact) return '规格完全一致' + const parts = [...evaluation.conflicts, ...evaluation.unknowns.map((item) => `订单缺少:${item}`)] + if (parts.length > 0) { + return `${evaluation.conflicts.length > 0 ? '不匹配' : '待核对'}:${parts.join(';')}` + } + if (evaluation.partial) return '规格高于要求(非精确)' + return '需人工核对' +} + +function scoreLegacy( + order: WorkOrderRow, + inventory: ConsignmentInventoryRow, +): ReturnType { + const fields = resolveCollectSubmittedFieldValues(order) + const specs = extractPeacekeeperSpecs(order.product_name, { + system: String(fields.system || ''), + serverZone: String(fields.serverZone || ''), + series: String(fields.series || ''), + }) + return scoreConsignmentMatchDetails(specs, inventory) +} + +/** + * 新口径的待抢单看板:与内部匹配引擎完全一致。 + * + * - 有寄卖配置的工单:按 profile 过滤库存、跑 `evaluateInventoryMatch`,支持自定义规格; + * - 拼单工单:按 `sharing_total_quantity - active_share_quantity` 展示剩余份数; + * - 没有寄卖配置的工单:保留旧手枪分类器兜底,避免配置缺失时看板直接空掉。 + */ +export function buildPendingConsignmentMatchPairs(input: { + orders: WorkOrderRow[] + inventories: ConsignmentInventoryRow[] + profileByOrderId: Map + keyword?: string | undefined + includeConflicts?: boolean | undefined + limit?: number | undefined + ordersTruncated?: boolean | undefined +}): PendingConsignmentMatchResult { + const keyword = String(input.keyword || '') + .trim() + .toLowerCase() + const includeConflicts = input.includeConflicts === true + const limit = Math.min( + Math.max(Math.floor(Number(input.limit) || DEFAULT_RESULT_LIMIT), 1), + MAX_RESULT_LIMIT, + ) + + const preparedOrders: Array<{ + order: WorkOrderRow + profile: ConsignmentProfileSnapshot | null + remainingQuantity: number + totalQuantity: number + }> = [] + for (const order of input.orders) { + if (String(order.status || '') !== WORK_ORDER_STATUS.OPEN) continue + const profile = input.profileByOrderId.get(Number(order.id)) ?? null + const totalQuantity = Math.max(1, Number(order.sharing_total_quantity || 1)) + const activeQuantity = Math.max(0, Number(order.active_share_quantity || 0)) + if (!profile) { + // 兜底:没有寄卖配置的订单仍按旧手枪口径展示,且保持“已有份额不参与”的旧规则。 + if (activeQuantity > 0) continue + const fields = resolveCollectSubmittedFieldValues(order) + const text = [ + String(order.product_name || ''), + String(fields.system || ''), + String(fields.serverZone || ''), + String(fields.series || ''), + ].join(' ') + if (detectItemCategory(text) !== 'pistol_suernan') continue + } + const remainingQuantity = + profile && order.sharing_enabled ? Math.max(0, totalQuantity - activeQuantity) : 1 + if (remainingQuantity <= 0) continue + preparedOrders.push({ order, profile, remainingQuantity, totalQuantity }) + } + + const inventories = input.inventories.filter((inventory) => { + if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false + return Number(inventory.remaining_quantity ?? 1) > 0 + }) + + const pairs: PendingConsignmentMatchPair[] = [] + const matchedInventoryIds = new Set() + for (const { order, profile, remainingQuantity, totalQuantity } of preparedOrders) { + for (const inventory of inventories) { + let matchType: 'exact' | 'partial' | 'conflict' + let score: number + let reason: string + let conflicts: string[] + let matchDetails: PendingConsignmentMatchDetail[] + + if (profile) { + if (!inventoryMatchesProfile(inventory, profile)) continue + const evaluation = evaluateInventoryMatch(order, inventory, profile) + if (evaluation.details.length === 0) { + // 内置五字段沿用旧评分,保证存量口径与 0-100 分不变。 + const legacy = scoreLegacy(order, inventory) + matchType = legacy.matchType + score = legacy.score + reason = legacy.reason + conflicts = legacy.conflicts + matchDetails = [] + } else { + matchType = evaluation.exact + ? 'exact' + : evaluation.conflicts.length > 0 + ? 'conflict' + : 'partial' + score = scoreFromDetails(evaluation.details) + reason = buildEvaluationReason(evaluation) + conflicts = evaluation.conflicts + matchDetails = evaluation.details + } + } else { + if ( + detectItemCategory( + `${inventory.item_name} ${inventory.series || ''} ${inventory.category_name || ''}`, + ) !== 'pistol_suernan' + ) { + continue + } + const legacy = scoreLegacy(order, inventory) + matchType = legacy.matchType + score = legacy.score + reason = legacy.reason + conflicts = legacy.conflicts + matchDetails = [] + } + + matchedInventoryIds.add(Number(inventory.id)) + if (matchType === 'conflict' && !includeConflicts) continue + + const pair: PendingConsignmentMatchPair = { + workOrderId: Number(order.id), + workOrderNo: String(order.work_order_no || ''), + platformOrderId: String(order.platform_order_id || ''), + productName: String(order.product_name || ''), + workOrderCreatedAt: order.created_at ? String(order.created_at) : null, + inventoryId: Number(inventory.id), + consignmentNo: String(inventory.source_submission_no || inventory.inventory_no || ''), + inventoryNo: String(inventory.inventory_no || ''), + itemName: String(inventory.item_name || ''), + inventoryInboundAt: inventory.inbound_at + ? String(inventory.inbound_at) + : inventory.created_at + ? String(inventory.created_at) + : null, + matchType, + score, + reason, + conflicts, + matchDetails, + remainingQuantity, + totalQuantity, + profileId: profile?.profileId ?? null, + } + + if (keyword && !buildPairHaystack(pair).includes(keyword)) continue + pairs.push(pair) + } + } + + const exactCount = pairs.filter((pair) => pair.matchType === 'exact').length + const partialCount = pairs.filter((pair) => pair.matchType === 'partial').length + const conflictCount = pairs.filter((pair) => pair.matchType === 'conflict').length + + pairs.sort((left, right) => { + if (right.score !== left.score) return right.score - left.score + if (right.workOrderId !== left.workOrderId) { + return right.workOrderId - left.workOrderId + } + const leftTime = Date.parse(left.inventoryInboundAt || '') || left.inventoryId + const rightTime = Date.parse(right.inventoryInboundAt || '') || right.inventoryId + if (leftTime !== rightTime) return leftTime - rightTime + return left.inventoryId - right.inventoryId + }) + + return { + pendingOrderCount: preparedOrders.length, + inStockInventoryCount: matchedInventoryIds.size, + pairCount: pairs.length, + exactCount, + partialCount, + conflictCount, + truncated: pairs.length > limit || input.ordersTruncated === true, + pairs: pairs.slice(0, limit), + } +} + +/** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货。 */ +export async function listPendingConsignmentMatches( input: { keyword?: string | undefined includeConflicts?: boolean | undefined limit?: number | undefined } = {}, -): Promise { +): Promise { const { items, total } = await listWorkOrders({ statuses: [WORK_ORDER_STATUS.OPEN], page: 1, @@ -188,12 +423,32 @@ export async function listPendingPistolMatches( }) const inventories = await listActiveConsignmentInventoriesForMatching() - return buildPendingPistolMatchPairs({ + const profileCache = new Map>() + const profileByOrderId = new Map() + await Promise.all( + items.map(async (order) => { + const cacheKey = order.consignment_profile_id + ? `profile:${Number(order.consignment_profile_id)}` + : `category:${Number(order.category_id || 0)}` + let pendingProfile = profileCache.get(cacheKey) + if (!pendingProfile) { + pendingProfile = resolveProfileForWorkOrder(order) + profileCache.set(cacheKey, pendingProfile) + } + profileByOrderId.set(Number(order.id), await pendingProfile) + }), + ) + + return buildPendingConsignmentMatchPairs({ orders: items, inventories, + profileByOrderId, keyword: input.keyword, includeConflicts: input.includeConflicts, limit: input.limit, ordersTruncated: total > items.length, }) } + +/** 兼容旧调用方:看板语义已改为按寄卖配置匹配,不再局限于手枪。 */ +export const listPendingPistolMatches = listPendingConsignmentMatches diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index 608e2e51..16806e88 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -84,8 +84,11 @@ test('我的寄卖以提报单号为唯一锚点聚合,不再返回 submission test('列表阶段由统一状态机推导,工单信息批量取而不是逐件查', async () => { const serviceSource = await readServiceSource() assert.ok(serviceSource.includes('deriveConsignmentItemStage(')) - assert.ok(serviceSource.includes('listWorkOrdersByIds(boundWorkOrderIds)')) + assert.ok(serviceSource.includes('listWorkOrdersByIds([...new Set(boundWorkOrderIds)])')) assert.ok(serviceSource.includes('hasInternalConsignmentFulfillment(workOrder)')) + // 一条库存拆给多张订单时,阶段要按占用行/份额推导,不能只看库存状态 + assert.ok(serviceSource.includes('buildAllocationItem(')) + assert.ok(serviceSource.includes('listWorkOrderSharesByOrderIds(')) }) test('卖家确认加好友复用既有链路,归属只认"这张提报单是这个卖家的"', async () => { @@ -106,13 +109,16 @@ test('赠送截图复用打手验收提交链路(冷却校验、必须带图 assert.ok(serviceSource.includes("errorCode: 'consignment_item_not_found'")) }) -test('卖家动作只按提报单号定位:手动入库停用后,按库存号的入口必须不存在', async () => { +test('卖家动作按提报单号 + 占用记录定位:不再保留按库存号的旧入口', async () => { const serviceSource = await readServiceSource() // 手动入库已停用 → 每件货必然由提报单产出 → 不该再保留 inventoryId 入口(含"一个 id 两义"的旧设计) assert.ok(!serviceSource.includes('confirmWorkerConsignmentInventoryFriendAdded')) assert.ok(!serviceSource.includes('submitWorkerConsignmentInventoryGiftEvidence')) assert.ok(!serviceSource.includes('inventoryId: number | string')) - assert.ok(serviceSource.includes('resolveSellerConsignmentWorkOrder(workerId, submissionId)')) + // 一库存可拆给多张订单:必须按提报单 + allocation/share 定位,不能再只看 bound_work_order_id + assert.ok(serviceSource.includes('resolveSellerConsignmentWorkOrder(')) + assert.ok(serviceSource.includes('listActiveConsignmentAllocationsForInventory(')) + assert.ok(serviceSource.includes("errorCode: 'consignment_allocation_selection_required'")) }) test('没有提报单的兜底行不给卖家操作能力,也不暴露内部仓库号', async () => { 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 9eaab268..7f972086 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -1,5 +1,6 @@ import { withTransaction } from '../../db/client.js' import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js' +import { listActiveConsignmentAllocationsForInventory } from '../../repositories/worker-platform/consignment-allocation-repo.js' import { createConsignmentSubmissionWithClient, getConsignmentInventoryById, @@ -11,10 +12,15 @@ import { updateConsignmentInventory, type WorkerConsignmentGroup, } from '../../repositories/worker-platform/consignment-repo.js' -import { getWorkerUserById } from '../../repositories/worker-platform/index.js' +import { + getWorkerUserById, + getWorkOrderById, + confirmWorkOrderShareFriendAdded, +} from '../../repositories/worker-platform/index.js' import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/worker-platform/product-match-repo.js' import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-order-query-repo.js' -import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' +import { listWorkOrderSharesByOrderIds } from '../../repositories/worker-platform/work-order-share-repo.js' +import type { WorkOrderRow, WorkOrderShareRow } from '../../repositories/worker-platform/types.js' import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js' import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js' import { resolveEnabledConsignmentContact } from './consignment-contact-service.js' @@ -86,15 +92,8 @@ function parseSpecValues(value: unknown): ConsignmentSpecValues { return result } -function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) { - if (!boundWo) { - return { - giftEvidenceImages: [] as string[], - giftEvidenceNote: '', - giftEvidenceSubmittedAt: null as string | null, - } - } - const acceptance = toRecord(boundWo.acceptance_json) +function extractConsignmentGiftEvidenceFromAcceptance(value: unknown) { + const acceptance = toRecord(value) const files = Array.isArray(acceptance?.files) ? (acceptance.files as unknown[]) : [] const fileUrls = files.map((file) => { const record = toRecord(file) @@ -119,6 +118,28 @@ function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | nu } } +function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) { + if (!boundWo) { + return { + giftEvidenceImages: [] as string[], + giftEvidenceNote: '', + giftEvidenceSubmittedAt: null as string | null, + } + } + return extractConsignmentGiftEvidenceFromAcceptance(boundWo.acceptance_json) +} + +function extractConsignmentGiftEvidenceFromShare(share?: WorkOrderShareRow | null) { + if (!share) { + return { + giftEvidenceImages: [] as string[], + giftEvidenceNote: '', + giftEvidenceSubmittedAt: null as string | null, + } + } + return extractConsignmentGiftEvidenceFromAcceptance(share.acceptance_json) +} + export type ConsignmentSellerInfo = { characterName: string characterId: string @@ -564,11 +585,23 @@ export async function listWorkerConsignments( const submissionById = new Map(submissions.map((sub) => [Number(sub.id), sub])) const inventoryById = new Map(inventories.map((inv) => [Number(inv.id), inv])) - const boundWorkOrderIds = inventories - .map((inv) => Number(inv.bound_work_order_id || 0)) - .filter((id) => id > 0) - const workOrders = await listWorkOrdersByIds(boundWorkOrderIds) + const boundWorkOrderIds = [ + ...inventories.map((inv) => Number(inv.bound_work_order_id || 0)), + ...page.rows.map((row) => Number(row.workOrderId || 0)), + ].filter((id) => id > 0) + const workOrders = await listWorkOrdersByIds([...new Set(boundWorkOrderIds)]) const workOrderById = new Map(workOrders.map((wo) => [Number(wo.id), wo])) + const shareOrderIds = [ + ...new Set( + page.rows + .filter((row) => Number(row.shareId || 0) > 0 && Number(row.workOrderId || 0) > 0) + .map((row) => Number(row.workOrderId)), + ), + ] + const shares = shareOrderIds.length > 0 ? await listWorkOrderSharesByOrderIds(shareOrderIds) : [] + const shareById = new Map( + shares.map((share) => [Number(share.id), share]), + ) const viewer = { role: 'seller', workerId: Number(workerId) } as const const resolveStage = ( @@ -664,6 +697,93 @@ export async function listWorkerConsignments( } } + const buildAllocationItem = ( + row: (typeof page.rows)[number], + sub: (typeof submissions)[number] | undefined, + inv: (typeof inventories)[number] | undefined, + wo: WorkOrderRow, + share: WorkOrderShareRow | undefined, + ) => { + const submissionStatus = String(sub?.status || 'approved') + // 这一行代表“被本单占用的那部分”,即使库存还有剩余,也必须按已出库推导阶段。 + const stage = deriveConsignmentItemStage({ + submissionStatus, + inventoryStatus: 'consumed', + viewer, + share: share + ? { + status: share.status, + friend_added_at: share.friend_added_at ?? null, + gift_available_at: share.gift_available_at ?? null, + } + : null, + workOrder: { + status: String(wo.status || ''), + acceptance_mode: wo.acceptance_mode, + gift_phase: wo.gift_phase, + gift_available_at: wo.gift_available_at, + friend_added_at: wo.friend_added_at, + internalStock: hasInternalConsignmentFulfillment(wo), + assignedWorkerId: Number(wo.assigned_worker_id || 0) || null, + }, + }) + const showingMatchedBuyer = !['open', 'unassigned', 'cancelled'].includes( + String(wo.status || ''), + ) + const evidence = share + ? extractConsignmentGiftEvidenceFromShare(share) + : showingMatchedBuyer + ? extractConsignmentGiftEvidenceFromWorkOrder(wo) + : { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' } + const contactSupporter = inv?.contact_supporter_id ? inv : sub + return { + consignmentNo: sub?.submission_no || '客服手工入库', + submissionId: sub?.id ?? null, + inventoryId: inv?.id ?? null, + allocationId: row.allocationId, + shareId: row.shareId, + workOrderId: Number(wo.id), + itemName: sub?.item_name || inv?.item_name || '', + system: sub?.system || inv?.system || '', + serverZone: sub?.server_zone || inv?.server_zone || '', + series: sub?.series || inv?.series || '', + diamondLevel: sub?.diamond_level || inv?.diamond_level || 1, + brillianceCount: sub?.brilliance_count ?? inv?.brilliance_count ?? 0, + specValues: parseSpecValues(inv?.spec_values_json ?? sub?.spec_values_json), + specText: inv?.spec_text || sub?.spec_text || '', + askingPriceFen: Number(sub?.asking_price_fen ?? inv?.cost_price_fen ?? 0), + salePriceFen: Number( + inv?.sale_price_fen ?? sub?.requested_sale_price_fen ?? sub?.asking_price_fen ?? 0, + ), + priceCapFen: Number(inv?.price_cap_fen ?? sub?.price_cap_fen ?? 0), + quantity: Math.max(1, Number(inv?.quantity ?? sub?.quantity ?? 1)), + allocatedQuantity: row.rowQuantity, + remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0, + listedAt: inv?.listed_at || null, + canEditPrice: false, + priceDropSuggestion: null, + depositAmountFen: Number(sub?.deposit_amount_fen ?? inv?.deposit_amount_fen ?? 0), + ...mapConsignmentContactSupporter(contactSupporter || {}), + reviewNote: sub?.review_note || '', + screenshotsJson: (Array.isArray(sub?.screenshots_json) ? sub.screenshots_json : []) + .filter((url): url is string => typeof url === 'string' && url.trim().length > 0) + .map((url) => refreshStoredFileUrl(url)), + giftEvidenceImages: evidence.giftEvidenceImages, + giftEvidenceNote: evidence.giftEvidenceNote, + giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt, + createdAt: row.createdAt, + ...stage, + giftAvailableAt: share?.gift_available_at ?? wo.gift_available_at ?? null, + friendAddedAt: share?.friend_added_at ?? wo.friend_added_at ?? null, + sellerInfo: sub + ? extractConsignmentSellerInfoFromSubmission(sub) + : inv + ? extractConsignmentSellerInfoFromInventory(inv) + : null, + buyerInfo: showingMatchedBuyer ? extractConsignmentBuyerInfoFromWorkOrder(wo) : null, + } + } + const buildOrphanInventoryItem = (inv: (typeof inventories)[number]) => { // 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力) const boundWo = inv.bound_work_order_id @@ -728,6 +848,13 @@ export async function listWorkerConsignments( .map((row) => { const sub = row.submissionId ? submissionById.get(Number(row.submissionId)) : undefined const inv = row.inventoryId ? inventoryById.get(Number(row.inventoryId)) : undefined + if (row.allocationId && inv) { + const wo = workOrderById.get(Number(row.workOrderId || 0)) + if (wo) { + const share = row.shareId ? shareById.get(Number(row.shareId)) : undefined + return buildAllocationItem(row, sub, inv, wo, share) + } + } if (sub) return buildSubmissionItem(sub, inv) return inv ? buildOrphanInventoryItem(inv) : null }) @@ -749,10 +876,17 @@ export async function listWorkerConsignments( * 两个动作本质上就是工单赠送流程里的既有环节,这里只做**归属校验**后复用同一套仓库/服务: * 谁在履约谁才能操作,避免卖家去动别人的订单。 */ +type ConsignmentActionTarget = { + workOrderId?: number | string | undefined + shareId?: number | string | undefined + allocationId?: number | string | undefined +} + async function resolveSellerConsignmentWorkOrder( workerId: number, submissionId: number | string, -): Promise<{ workOrderId: number; inventoryId: number }> { + target: ConsignmentActionTarget = {}, +): Promise<{ workOrderId: number; inventoryId: number; shareId: number | null }> { const submission = await getConsignmentSubmissionById(submissionId) if (!submission || Number(submission.worker_id) !== Number(workerId)) { throw createHttpError('寄卖单不存在或不属于当前账号', { @@ -770,7 +904,34 @@ async function resolveSellerConsignmentWorkOrder( }) } - const workOrderId = Number(inventory.bound_work_order_id || 0) + // 一条库存可能拆给多张订单:优先按前端传来的订单/份额/占用记录定位; + // 没有传且只有一笔占用时自动选中,多笔时必须让卖家明确选哪一单。 + const allocations = await listActiveConsignmentAllocationsForInventory(inventory.id) + const allocationIdInput = Number(target.allocationId || 0) + const shareIdInput = Number(target.shareId || 0) + const workOrderIdInput = Number(target.workOrderId || 0) + let allocation = allocations[0] || null + if (allocationIdInput > 0) { + allocation = allocations.find((item) => Number(item.id) === allocationIdInput) ?? null + } else if (shareIdInput > 0) { + allocation = + allocations.find((item) => Number(item.work_order_share_id || 0) === shareIdInput) ?? null + } else if (workOrderIdInput > 0) { + allocation = allocations.find((item) => Number(item.work_order_id) === workOrderIdInput) ?? null + } else if (allocations.length > 1) { + throw createHttpError('该寄卖单有多笔订单占用,请指定要操作的订单', { + statusCode: 400, + errorCode: 'consignment_allocation_selection_required', + }) + } + if ((allocationIdInput > 0 || shareIdInput > 0 || workOrderIdInput > 0) && !allocation) { + throw createHttpError('未找到该寄卖单对应的订单占用,请刷新后重试', { + statusCode: 404, + errorCode: 'consignment_allocation_not_found', + }) + } + + const workOrderId = Number(allocation?.work_order_id || inventory.bound_work_order_id || 0) if (!workOrderId) { throw createHttpError('该寄卖单当前没有关联订单,无需操作', { statusCode: 409, @@ -788,7 +949,11 @@ async function resolveSellerConsignmentWorkOrder( // 归属只认"这张提报单就是这个卖家的"(上面已校验),不再要求订单接单人等于卖家: // 寄卖的货是卖家自己的,赠送由卖家完成;内部现货履约的单子根本没有接单打手, // 按接单人卡会把卖家自己的货挡在外面。客服侧保留同样的按钮,谁先点谁生效。 - return { workOrderId, inventoryId: Number(inventory.id) } + return { + workOrderId, + inventoryId: Number(inventory.id), + shareId: allocation?.work_order_share_id ? Number(allocation.work_order_share_id) : null, + } } /** @@ -801,8 +966,43 @@ export async function confirmWorkerConsignmentFriendAdded( workerId: number, submissionId: number | string, workerName = '', + target: ConsignmentActionTarget = {}, ) { - const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId) + const { workOrderId, shareId } = await resolveSellerConsignmentWorkOrder( + workerId, + submissionId, + target, + ) + if (shareId) { + const result = await confirmWorkOrderShareFriendAdded({ + workOrderId, + shareId, + actorType: 'worker', + actorId: `worker:${workerId}`, + now: nowIso(), + }) + if (result.failureReason) { + const failure = result.failureReason + const message = + failure === 'work_order_not_found' + ? '关联订单不存在' + : failure === 'work_order_gift_mode_invalid' + ? '该订单不是好友赠送模式' + : failure === 'share_status_invalid' + ? '该拼单份额已提交或已验收,不能重复确认' + : failure === 'share_not_consignment' + ? '该拼单份额不是寄卖份额' + : '未找到该拼单份额' + throw createHttpError(message, { + statusCode: failure === 'work_order_not_found' || failure === 'share_not_found' ? 404 : 409, + errorCode: `consignment_share_${failure}`, + }) + } + return { + order: await getWorkOrderById(workOrderId), + share: result.share, + } + } return confirmAdminWorkOrderFriendAdded(workOrderId, workerName || `worker:${workerId}`) } @@ -818,8 +1018,15 @@ export async function submitWorkerConsignmentGiftEvidence( payload: JsonObject, session: WorkerSession, ) { - const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId) - return submitWorkerOrderAcceptance(workOrderId, payload, session, { asConsignmentSupplier: true }) + const { workOrderId, shareId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId, { + workOrderId: payload.workOrderId as number | string | undefined, + shareId: payload.shareId as number | string | undefined, + allocationId: payload.allocationId as number | string | undefined, + }) + return submitWorkerOrderAcceptance(workOrderId, payload, session, { + asConsignmentSupplier: true, + ...(shareId ? { consignmentShareId: shareId } : {}), + }) } /** diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 20c7a034..47660f02 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -950,6 +950,12 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) { cancelNote: share.cancel_note || '', /** 被撤掉的份数:撤份额累计,单人撤单为整份份数;0 = 未撤过 */ cancelQuantity: Math.max(0, Number(share.cancel_quantity || 0)), + /** 寄卖拼单:本份额的加好友/赠送冷却状态 */ + consignmentInventoryId: share.consignment_inventory_id + ? Number(share.consignment_inventory_id) + : null, + friendAddedAt: share.friend_added_at || null, + giftAvailableAt: share.gift_available_at || null, worker: share.worker_id ? { workerId: Number(share.worker_id), diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts index 25e4e1a3..b73cb91c 100644 --- a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -13,6 +13,7 @@ import { acceptWorkOrderShareAndSettle, countWorkOrderPendingSharingSubmissions, createWorkOrderEvent, + getWorkOrderShareById, listWorkOrderSharesByWorkerInOrder, markWorkOrderAcceptanceSubmitted, settleOverdueWorkOrder, @@ -79,12 +80,30 @@ export async function submitWorkerOrderAcceptance( * 调用方必须先校验"这张提报单就是这个卖家的",这里就不再按接单人来卡。 */ asConsignmentSupplier?: boolean + /** 寄卖拼单:明确落到某一条份额上,按份额写验收/赠送凭证 */ + consignmentShareId?: number } = {}, ) { requireActiveWorkerSession(session) let workOrder = await getRequiredWorkOrder(workOrderId) const workerId = Number(session.workerId) const worker = await getRequiredWorker(session.workerId) + if (options.consignmentShareId) { + const share = await getWorkOrderShareById(options.consignmentShareId, workOrder.id) + if ( + !share || + Number(share.worker_id) !== workerId || + !Number(share.consignment_inventory_id || 0) + ) { + throw createHttpError('未找到该寄卖拼单份额,请刷新后重试', { + statusCode: 404, + errorCode: 'consignment_share_not_found', + }) + } + return submitWorkerSharingAcceptance(workOrder, share, payload, session, { + consignmentGift: true, + }) + } if (!options.asConsignmentSupplier) { const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload) if (target.error === 'required') { @@ -591,6 +610,7 @@ async function submitWorkerSharingAcceptance( sharingShare: WorkOrderShareRow, payload: JsonObject = {}, session: WorkerSession, + options: { consignmentGift?: boolean } = {}, ) { if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) { throw createHttpError('该拼单已验收完成', { @@ -630,7 +650,25 @@ async function submitWorkerSharingAcceptance( }) } const worker = await getRequiredWorker(session.workerId) - const canAutoAcceptWithoutEvidence = resolveWorkerPermissions(worker).autoAcceptWithoutEvidence + // 寄卖拼单的好友赠送:每个卖家独立确认加好友、独立冷却、独立提交凭证。 + if (options.consignmentGift && isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) { + const friendAddedAt = Date.parse(String(sharingShare.friend_added_at || '')) + if (!Number.isFinite(friendAddedAt)) { + throw createHttpError('请先在「我的寄卖」确认已加好买家好友', { + statusCode: 409, + errorCode: 'consignment_share_friend_required', + }) + } + const giftAvailableAt = Date.parse(String(sharingShare.gift_available_at || '')) + if (!Number.isFinite(giftAvailableAt) || giftAvailableAt > Date.now()) { + throw createHttpError('赠送冷却未结束,暂不能提交赠送凭证', { + statusCode: 409, + errorCode: 'consignment_share_gift_cooldown', + }) + } + } + const canAutoAcceptWithoutEvidence = + !options.consignmentGift && resolveWorkerPermissions(worker).autoAcceptWithoutEvidence const { files, imageUrls, note } = resolveAcceptanceImages( payload, sharingShare.draft_acceptance_json, @@ -664,6 +702,7 @@ async function submitWorkerSharingAcceptance( shareId: Number(sharingShare.id), acceptanceJson: JSON.stringify(acceptance), now, + preserveWorkOrderStatus: options.consignmentGift === true, }) if (result.failureReason === 'share_status_invalid') { throw createHttpError('当前拼单份额已提交,请勿重复提交', { diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx index 14d71e83..713d918a 100644 --- a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -95,6 +95,11 @@ export default function ConsignmentPendingMatchModal({ 录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'} + {Number(record.totalQuantity || 1) > 1 ? ( + + 拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份 + + ) : null} ), }, @@ -166,6 +171,15 @@ export default function ConsignmentPendingMatchModal({ {record.conflicts.join(';')} ) : null} + {(record.matchDetails || []) + .filter((detail) => detail.status !== 'match' && detail.status !== 'ignored') + .slice(0, 4) + .map((detail) => ( + + {detail.label}:订单「{detail.orderValue || '-'}」/ 库存「 + {detail.inventoryValue || '-'}」 + + ))} ), }, @@ -173,7 +187,7 @@ export default function ConsignmentPendingMatchModal({ return ( diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 9d8a4cdd..a13db607 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -52,6 +52,7 @@ import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/ import { acceptAdminWorkOrder, acceptAdminWorkOrderShare, + confirmAdminWorkOrderShareFriendAdded, acceptAdminWorkOrders, assignAdminWorkOrderToWorker, cancelAdminWorkOrder, @@ -3734,6 +3735,22 @@ export default function WorkOrdersPanel() { ), }, + { + title: '寄卖赠送', + width: 120, + render: (_, share) => { + if (!share.consignmentInventoryId) return '-' + if (share.status === 'accepted') return 已完成 + if (share.status === 'submitted') + return 待审核 + if (!share.friendAddedAt) return 待加好友 + const availableAt = Date.parse(String(share.giftAvailableAt || '')) + if (Number.isFinite(availableAt) && availableAt > Date.now()) { + return 冷却中 + } + return 待赠送 + }, + }, { title: '问题', width: 120, @@ -3906,6 +3923,29 @@ export default function WorkOrdersPanel() { ) ) : null + const consignmentGiftAction = + sharingOrder?.acceptanceMode === 'friend_gift' && + share.consignmentInventoryId && + share.status === 'joined' && + !share.friendAddedAt ? ( + + ) : null + return ( {share.status === 'submitted' ? ( @@ -3969,6 +4009,7 @@ export default function WorkOrdersPanel() { 撤份额 ) : null} + {consignmentGiftAction} {problemAction} ) diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 69562aa6..0b79466f 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -530,6 +530,14 @@ export default function WorkerConsignmentPage() { return null } + function resolveActionTarget(item: WorkerConsignmentRecord) { + return { + workOrderId: item.workOrderId, + shareId: item.shareId ?? null, + allocationId: item.allocationId ?? null, + } + } + function handleConfirmFriend(item: WorkerConsignmentRecord) { const submissionId = resolveActionSubmissionId(item) if (submissionId == null) return @@ -554,7 +562,7 @@ export default function WorkerConsignmentPage() { centered: true, onOk: async () => { try { - await confirmWorkerConsignmentFriendAdded(submissionId) + await confirmWorkerConsignmentFriendAdded(submissionId, resolveActionTarget(item)) message.success('已确认加好友,赠送冷却开始') await myConsignmentsQuery.refetch() } catch (err: any) { @@ -578,7 +586,8 @@ export default function WorkerConsignmentPage() { } async function handleEvidenceSubmit() { - const submissionId = evidenceItem ? resolveActionSubmissionId(evidenceItem) : null + if (!evidenceItem) return + const submissionId = resolveActionSubmissionId(evidenceItem) if (submissionId == null) return const urls = evidenceFiles.map((file) => file.url).filter(Boolean) if (urls.length === 0) { @@ -594,7 +603,10 @@ export default function WorkerConsignmentPage() { imageUrls: urls, ...(note ? { note } : {}), } - await submitWorkerConsignmentGiftEvidence(submissionId, payload) + await submitWorkerConsignmentGiftEvidence(submissionId, { + ...resolveActionTarget(evidenceItem), + ...payload, + }) message.success('赠送截图已提交,等待客服验收') closeEvidenceModal() await myConsignmentsQuery.refetch() @@ -1137,7 +1149,7 @@ export default function WorkerConsignmentPage() {
{records.map((record) => ( ¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)} + {record.allocationId && Number(record.allocatedQuantity || 0) > 0 ? ( + + 本单占用 {record.allocatedQuantity} 件 + + ) : null} {Number(record.quantity || 1) > 1 ? ( 数量{' '} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index d8b9e370..4a6168e6 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -981,6 +981,14 @@ export function acceptAdminWorkOrderShare(workOrderId: number, shareId: number) ) } +/** 寄卖拼单:按份额确认已加好友,只影响该卖家的冷却。 */ +export function confirmAdminWorkOrderShareFriendAdded(workOrderId: number, shareId: number) { + return apiPost<{ share: WorkOrderShare | null }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/confirm-friend`, + {}, + ) +} + export function cancelAdminWorkOrderShare( workOrderId: number, shareId: number, diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index c034752a..04b7e5f5 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -447,7 +447,13 @@ export type WorkerConsignmentRecord = { consignmentNo: string submissionId: number | null inventoryId: number | null + /** 一库存拆给多张订单时,本行对应的占用记录 */ + allocationId?: number | null + /** 拼单份额 id(本行对应的卖家份额) */ + shareId?: number | null workOrderId: number | null + /** 本行占用的数量;库存剩余行为剩余数量 */ + allocatedQuantity?: number itemName: string system: string serverZone: string @@ -558,21 +564,30 @@ export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {} ) } +export type WorkerConsignmentActionTarget = { + workOrderId?: number | null + shareId?: number | null + allocationId?: number | null +} + /** * 卖家确认已在游戏内加好买家好友 → 进入赠送冷却期。 * 注意:确认后若未完成赠送,履约押金不退。 */ -export function confirmWorkerConsignmentFriendAdded(submissionId: number | string) { +export function confirmWorkerConsignmentFriendAdded( + submissionId: number | string, + target: WorkerConsignmentActionTarget = {}, +) { return apiPost<{ order: WorkOrder }>( `/api/v1/worker/consignment/items/${submissionId}/confirm-friend`, - {}, + target, ) } /** 卖家提交赠送完成截图(后端复用打手验收链路,工单转「待验收」交客服审核)。 */ export function submitWorkerConsignmentGiftEvidence( submissionId: number | string, - payload: { imageUrls: string[]; note?: string }, + payload: { imageUrls: string[]; note?: string } & WorkerConsignmentActionTarget, ) { return apiPost<{ order: WorkOrder }>( `/api/v1/worker/consignment/items/${submissionId}/gift-evidence`, diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 89633306..eaa450e9 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -300,7 +300,7 @@ export type WorkOrderInventoryMatchCheck = { matches: ConsignmentMatchResult[] } -/** 后台「寄售仓库 → 匹配」:待抢单手枪 × 在库待匹配现货的配对明细 */ +/** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货的配对明细 */ export type PendingPistolMatchPair = { workOrderId: number workOrderNo: string @@ -316,6 +316,20 @@ export type PendingPistolMatchPair = { score: number reason: string conflicts: string[] + /** 自定义规格逐字段对比;内置五字段为空,沿用旧评分原因 */ + matchDetails?: Array<{ + key: string + label: string + orderValue: string + inventoryValue: string + status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored' + }> + /** 拼单订单还差多少份;非拼单为 1 */ + remainingQuantity?: number + /** 拼单订单总份数;非拼单为 1 */ + totalQuantity?: number + /** 命中的寄卖配置 id;无配置时为 null(旧手枪兜底) */ + profileId?: number | null } export type PendingPistolMatchResult = { diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 694dd614..3d55f854 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -608,6 +608,10 @@ export type WorkOrderShare = { cancelNote?: string /** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */ cancelQuantity?: number + /** 寄卖拼单:本份额对应的库存与加好友/赠送冷却状态 */ + consignmentInventoryId?: number | null + friendAddedAt?: string | null + giftAvailableAt?: string | null /** 份额级问题单:只影响该份额持有人,母单状态不变 */ problemMarked?: boolean problemNote?: string