diff --git a/apps/backend/src/db/migrations/129_consignment_matching.sql b/apps/backend/src/db/migrations/129_consignment_matching.sql new file mode 100644 index 00000000..560b92df --- /dev/null +++ b/apps/backend/src/db/migrations/129_consignment_matching.sql @@ -0,0 +1,167 @@ +-- 129_consignment_matching.sql +-- 寄卖配置化 + 库存数量/售价 + 内部匹配/自动匹配 + 拼单库存分配。 +-- 全部语句幂等,可安全重跑;不改动历史迁移。 + +-- =========================================================================== +-- 1. 分类级寄卖配置(参考 order_service_profiles 的草稿/发布版本模式) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS consignment_profiles ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL REFERENCES work_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + tab_title TEXT NOT NULL DEFAULT '', + config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'draft', + current_version_id BIGINT, + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT consignment_profiles_status_check + CHECK (status IN ('draft', 'published', 'archived')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_consignment_profiles_category + ON consignment_profiles (category_id); +CREATE INDEX IF NOT EXISTS idx_consignment_profiles_status + ON consignment_profiles (status); + +CREATE TABLE IF NOT EXISTS consignment_profile_versions ( + id BIGSERIAL PRIMARY KEY, + profile_id BIGINT NOT NULL REFERENCES consignment_profiles(id) ON DELETE CASCADE, + version_no INTEGER NOT NULL, + config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + published_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_consignment_profile_versions_no UNIQUE (profile_id, version_no) +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_consignment_profiles_current_version' + ) THEN + ALTER TABLE consignment_profiles + ADD CONSTRAINT fk_consignment_profiles_current_version + FOREIGN KEY (current_version_id) + REFERENCES consignment_profile_versions(id) ON DELETE SET NULL; + END IF; +END $$; + +-- =========================================================================== +-- 2. 提报与库存:配置快照、数量、寄卖售价 +-- =========================================================================== +ALTER TABLE consignment_submissions + ADD COLUMN IF NOT EXISTS profile_id BIGINT REFERENCES consignment_profiles(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS profile_version_id BIGINT + REFERENCES consignment_profile_versions(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS profile_snapshot_json JSONB, + ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS requested_sale_price_fen BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE consignment_inventories + ADD COLUMN IF NOT EXISTS profile_id BIGINT REFERENCES consignment_profiles(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS profile_version_id BIGINT + REFERENCES consignment_profile_versions(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS profile_snapshot_json JSONB, + ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS remaining_quantity INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS sale_price_fen BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS listed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS price_updated_at TIMESTAMPTZ; + +-- 存量库存:一件一条,剩余数量 = 总数量,寄卖售价回填收购价(后续可单独改价)。 +UPDATE consignment_inventories +SET remaining_quantity = GREATEST(0, COALESCE(quantity, 1)), + sale_price_fen = CASE WHEN COALESCE(sale_price_fen, 0) = 0 THEN COALESCE(cost_price_fen, 0) ELSE sale_price_fen END, + listed_at = COALESCE(listed_at, inbound_at) +WHERE remaining_quantity IS DISTINCT FROM GREATEST(0, COALESCE(quantity, 1)) + OR listed_at IS NULL + OR (COALESCE(sale_price_fen, 0) = 0 AND COALESCE(cost_price_fen, 0) > 0); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_profile_matching + ON consignment_inventories (profile_id, status, allow_matching, remaining_quantity) + WHERE status = 'in_stock'; + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_worker_active + ON consignment_inventories (worker_id, status, remaining_quantity) + WHERE worker_id IS NOT NULL; + +-- =========================================================================== +-- 3. 工单:寄卖配置、内部匹配截止、大厅可见时间 +-- =========================================================================== +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS consignment_profile_id BIGINT + REFERENCES consignment_profiles(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS internal_match_deadline_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS hall_visible_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS internal_match_attempts INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_work_orders_internal_matching + ON work_orders (internal_match_deadline_at) + WHERE status = 'internal_matching'; + +CREATE INDEX IF NOT EXISTS idx_work_orders_hall_visible + ON work_orders (hall_visible_at) + WHERE hall_visible_at IS NOT NULL; + +-- =========================================================================== +-- 4. 库存-工单分配(一个工单/份额可占用一条或多条库存) +-- =========================================================================== +CREATE TABLE IF NOT EXISTS consignment_inventory_allocations ( + id BIGSERIAL PRIMARY KEY, + inventory_id BIGINT NOT NULL REFERENCES consignment_inventories(id) ON DELETE CASCADE, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + work_order_share_id BIGINT REFERENCES work_order_shares(id) ON DELETE SET NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price_fen BIGINT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'reserved', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT consignment_inventory_allocations_status_check + CHECK (status IN ('reserved', 'consumed', 'released')), + CONSTRAINT consignment_inventory_allocations_quantity_check + CHECK (quantity > 0) +); + +CREATE INDEX IF NOT EXISTS idx_consignment_allocations_work_order + ON consignment_inventory_allocations (work_order_id, status); +CREATE INDEX IF NOT EXISTS idx_consignment_allocations_inventory + ON consignment_inventory_allocations (inventory_id, status); +CREATE INDEX IF NOT EXISTS idx_consignment_allocations_share + ON consignment_inventory_allocations (work_order_share_id) + WHERE work_order_share_id IS NOT NULL; + +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS consignment_inventory_id BIGINT + REFERENCES consignment_inventories(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_work_order_shares_consignment_inventory + ON work_order_shares (consignment_inventory_id) + WHERE consignment_inventory_id IS NOT NULL; + +-- =========================================================================== +-- 5. 注释 +-- =========================================================================== +COMMENT ON TABLE consignment_profiles IS + '分类级寄卖配置:打手端 tab、发布字段、价格、保证金、服务费、数量限制、匹配与超时策略'; +COMMENT ON TABLE consignment_profile_versions IS + '寄卖配置发布版本快照;提报/库存/工单固化 profile_version_id,后续改配置不影响在途数据'; +COMMENT ON COLUMN consignment_inventories.quantity IS + '该寄卖批次总数量;同批次共享卖家账号资料与截图'; +COMMENT ON COLUMN consignment_inventories.remaining_quantity IS + '剩余可匹配数量;部分匹配后继续保留 in_stock,扣到 0 才 consumed'; +COMMENT ON COLUMN consignment_inventories.sale_price_fen IS + '寄卖售价(分):匹配工单时的结算价,可由打手/后台修改'; +COMMENT ON COLUMN consignment_inventories.listed_at IS + '开始计算未售出时长的时间点(审核入库/发布)'; +COMMENT ON COLUMN work_orders.internal_match_deadline_at IS + '内部匹配截止时间;超过后调度任务把工单发回大厅'; +COMMENT ON COLUMN work_orders.hall_visible_at IS + '大厅可见时间;NULL = 立即可见,未来时间 = 延迟进入大厅'; +COMMENT ON TABLE consignment_inventory_allocations IS + '库存占用明细:整单履约一条,拼单时每个份额一条;释放时回补 remaining_quantity'; +COMMENT ON COLUMN work_order_shares.consignment_inventory_id IS + '拼单份额对应的寄卖库存;非寄卖份额为 NULL'; diff --git a/apps/backend/src/domain/work-order-status.ts b/apps/backend/src/domain/work-order-status.ts index e55ecca9..b0b70f0b 100644 --- a/apps/backend/src/domain/work-order-status.ts +++ b/apps/backend/src/domain/work-order-status.ts @@ -1,6 +1,7 @@ export const WORK_ORDER_STATUS = { PENDING_MATERIAL: 'pending_material', UNASSIGNED: 'unassigned', + INTERNAL_MATCHING: 'internal_matching', OPEN: 'open', IN_PROGRESS: 'in_progress', PENDING_ACCEPTANCE: 'pending_acceptance', diff --git a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts new file mode 100644 index 00000000..dcf4c151 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts @@ -0,0 +1,140 @@ +import type { PoolClient } from 'pg' + +export type ConsignmentAllocationRow = { + id: number + inventory_id: number + work_order_id: number + work_order_share_id: number | null + quantity: number + unit_price_fen: number + status: 'reserved' | 'consumed' | 'released' + created_at: string + updated_at: string +} + +/** + * 占用库存(部分占用时 remaining_quantity 递减,扣到 0 才 consumed)。 + * 返回是否成功;并发下由 UPDATE 的条件兜底。 + */ +export async function reserveConsignmentInventoryWithClient( + client: PoolClient, + input: { + inventoryId: number + workOrderId: number + workOrderShareId?: number | null + quantity?: number + unitPriceFen: number + now: string + }, +): Promise { + const quantity = Math.max(1, Math.round(Number(input.quantity || 1))) + const inventory = await client.query<{ + id: number + remaining_quantity: number + status: string + allow_matching: boolean + }>( + ` + UPDATE consignment_inventories + SET + remaining_quantity = remaining_quantity - $1, + status = CASE WHEN remaining_quantity - $1 <= 0 THEN 'consumed' ELSE status END, + allocation_status = '已安排', + consumed_at = CASE WHEN remaining_quantity - $1 <= 0 THEN $2 ELSE consumed_at END, + updated_at = $2 + WHERE id = $3 + AND status = 'in_stock' + AND allow_matching = TRUE + AND remaining_quantity >= $1 + RETURNING id, remaining_quantity, status, allow_matching + `, + [quantity, input.now, input.inventoryId], + ) + if (!inventory.rows[0]) return null + + const allocation = await client.query( + ` + INSERT INTO consignment_inventory_allocations ( + inventory_id, work_order_id, work_order_share_id, quantity, unit_price_fen, + status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, 'reserved', $6, $6) + RETURNING * + `, + [ + input.inventoryId, + input.workOrderId, + input.workOrderShareId || null, + quantity, + Math.max(0, Math.round(Number(input.unitPriceFen || 0))), + input.now, + ], + ) + return allocation.rows[0] || null +} + +async function releaseAllocationRows( + client: PoolClient, + rows: ConsignmentAllocationRow[], + now: string, +): Promise { + for (const row of rows) { + // 已被下架作废的库存不再恢复可匹配,只回补数量便于审计。 + await client.query( + ` + UPDATE consignment_inventories + SET + remaining_quantity = remaining_quantity + $1, + status = CASE WHEN status = 'consumed' THEN 'in_stock' ELSE status END, + consumed_at = CASE WHEN status = 'consumed' THEN NULL ELSE consumed_at END, + allocation_status = CASE WHEN status = 'consumed' THEN '待安排' ELSE allocation_status END, + updated_at = $2 + WHERE id = $3 + `, + [row.quantity, now, row.inventory_id], + ) + await client.query( + `UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`, + [now, row.id], + ) + } +} + +/** 释放某个拼单份额占用的库存(后台撤份额 / 超时 / 退单统一走这里)。 */ +export async function releaseConsignmentAllocationForShareWithClient( + client: PoolClient, + 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`, + [shareId], + ) + await releaseAllocationRows(client, result.rows, now) + return result.rows.length +} + +/** 释放整个工单占用的全部库存(整单取消 / 退回大厅 / 撤销履约)。 */ +export async function releaseConsignmentAllocationsForWorkOrderWithClient( + client: PoolClient, + workOrderId: number, + now: string, +): Promise { + const result = await client.query( + `SELECT * FROM consignment_inventory_allocations WHERE work_order_id = $1 AND status = 'reserved' FOR UPDATE`, + [workOrderId], + ) + await releaseAllocationRows(client, result.rows, now) + return result.rows.length +} + +/** 匹配成功后把分配标记为已消耗(仅用于统计;释放时按 reserved 处理)。 */ +export async function markConsignmentAllocationsConsumedWithClient( + client: PoolClient, + workOrderId: number, + now: string, +): Promise { + await client.query( + `UPDATE consignment_inventory_allocations SET status = 'consumed', updated_at = $1 WHERE work_order_id = $2 AND status = 'reserved'`, + [now, workOrderId], + ) +} diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index bf3c41a8..91d3e800 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -50,6 +50,17 @@ export type ConsignmentInventoryRow = { worker_phone?: string assigned_worker_id?: string | number | null deposit_amount_fen?: number + /** 分类级寄卖配置与发布快照 */ + profile_id?: string | number | null + profile_version_id?: string | number | null + profile_snapshot_json?: string | Record | null + /** 该批次总数量 / 剩余可匹配数量 */ + quantity?: number + remaining_quantity?: number + /** 寄卖售价(分):匹配工单时的结算价 */ + sale_price_fen?: number + listed_at?: string | null + price_updated_at?: string | null /** 对接客服(发布/审核入库时选定,用于提成归属与联系) */ contact_supporter_id?: string | number | null contact_supporter_name?: string @@ -82,6 +93,13 @@ export type ConsignmentSubmissionRow = { worker_name?: string worker_phone?: string deposit_amount_fen?: number + /** 分类级寄卖配置与发布快照 */ + profile_id?: string | number | null + profile_version_id?: string | number | null + profile_snapshot_json?: string | Record | null + /** 本次提报数量与期望售价 */ + quantity?: number + requested_sale_price_fen?: number /** 对接客服(发布时选定,用于提成归属与联系) */ contact_supporter_id?: string | number | null contact_supporter_name?: string @@ -394,6 +412,7 @@ export async function updateConsignmentInventory( diamondLevel?: number | undefined brillianceCount?: number | undefined costPriceFen?: number | undefined + salePriceFen?: number | undefined buyerName?: string | undefined inboundBatch?: string | undefined sellerContact?: string | undefined @@ -444,6 +463,10 @@ export async function updateConsignmentInventory( values.push(input.costPriceFen) updates.push(`cost_price_fen = $${values.length}`) } + if (input.salePriceFen !== undefined) { + values.push(input.salePriceFen) + updates.push(`sale_price_fen = $${values.length}`) + } if (input.buyerName !== undefined) { values.push(input.buyerName) updates.push(`buyer_name = $${values.length}`) @@ -1312,6 +1335,11 @@ export async function createConsignmentSubmissionWithClient( workerPhone?: string depositAmountFen?: number contactSupporterId?: number | null + profileId?: number | null + profileVersionId?: number | null + profileSnapshotJson?: Record | null + quantity?: number + requestedSalePriceFen?: number now: string }, ): Promise { @@ -1322,14 +1350,18 @@ export async function createConsignmentSubmissionWithClient( seller_game_id, seller_payment_info, seller_phone, contact_info, content_json, screenshots_json, asking_price_fen, worker_id, worker_name, worker_phone, deposit_amount_fen, - contact_supporter_id, status, created_at, updated_at + contact_supporter_id, + profile_id, profile_version_id, profile_snapshot_json, quantity, requested_sale_price_fen, + status, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, $15::jsonb, $16, $17, $18, $19, $20, - $21, 'pending_review', $22, $22 + $21, + $22, $23, $24::jsonb, $25, $26, + 'pending_review', $27, $27 ) RETURNING * ` @@ -1355,6 +1387,11 @@ export async function createConsignmentSubmissionWithClient( input.workerPhone || '', input.depositAmountFen || 0, input.contactSupporterId || null, + input.profileId || null, + input.profileVersionId || null, + input.profileSnapshotJson ? toJsonString(input.profileSnapshotJson) : null, + Math.max(1, Math.round(Number(input.quantity || 1))), + Math.max(0, Math.round(Number(input.requestedSalePriceFen || input.askingPriceFen || 0))), input.now, ]) return res.rows[0]! @@ -1423,6 +1460,11 @@ export async function approveConsignmentSubmissionWithInbound(input: { workerPhone?: string | undefined depositAmountFen?: number | undefined contactSupporterId?: number | null | undefined + profileId?: number | null | undefined + profileVersionId?: number | null | undefined + profileSnapshotJson?: string | Record | null | undefined + quantity?: number | undefined + salePriceFen?: number | undefined now: string }): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> { return withTransaction(async (client: PoolClient) => { @@ -1456,7 +1498,10 @@ export async function approveConsignmentSubmissionWithInbound(input: { seller_phone, credentials_json, screenshots_json, remark, allow_matching, status, allocation_status, source_type, source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen, - contact_supporter_id, inbound_at, created_at, updated_at + contact_supporter_id, + profile_id, profile_version_id, profile_snapshot_json, quantity, remaining_quantity, + sale_price_fen, listed_at, + inbound_at, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, @@ -1464,10 +1509,14 @@ export async function approveConsignmentSubmissionWithInbound(input: { $16, $17::jsonb, $18::jsonb, $19, TRUE, 'in_stock', '待安排', 'worker_portal', $20, $21, $22, $23, $24, - $25, $26, $26, $26 + $25, + $26, $27, $28::jsonb, $29, $29, + $30, $31, + $31, $31, $31 ) RETURNING * ` + const quantity = Math.max(1, Math.round(Number(input.quantity ?? submission.quantity ?? 1))) const invValues = [ input.inventoryNo, submission.game_name || '和平精英', @@ -1494,6 +1543,25 @@ export async function approveConsignmentSubmissionWithInbound(input: { workerPhone, depositAmountFen, contactSupporterId, + input.profileId ?? submission.profile_id ?? null, + input.profileVersionId ?? submission.profile_version_id ?? null, + input.profileSnapshotJson + ? toJsonString(input.profileSnapshotJson) + : submission.profile_snapshot_json + ? toJsonString(submission.profile_snapshot_json) + : null, + quantity, + Math.max( + 0, + Math.round( + Number( + input.salePriceFen ?? + submission.requested_sale_price_fen ?? + submission.asking_price_fen ?? + 0, + ), + ), + ), input.now, ] const invRes = await client.query(invSql, invValues) diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 163bffe0..b1a70100 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -381,6 +381,8 @@ export type WorkOrderShareRow = { problem_marked_by?: string /** 客户端幂等键 */ request_id?: string + /** 拼单份额对应的寄卖库存;非寄卖份额为 NULL */ + consignment_inventory_id?: number | null worker_username?: string worker_display_name?: string } @@ -415,6 +417,11 @@ export type WorkOrderRow = { service_profile_id?: number | null service_profile_version_id?: number | null service_profile_snapshot_json?: string | Record | null + /** 命中的寄卖配置与内部匹配截止/大厅可见时间 */ + consignment_profile_id?: number | null + internal_match_deadline_at?: string | null + hall_visible_at?: string | null + internal_match_attempts?: number acceptance_json: string | Record draft_acceptance_json: string | Record admin_note: string diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 81e87951..4bb6ccdb 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -3,6 +3,7 @@ import type { PoolClient } from 'pg' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js' import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js' +import { releaseConsignmentAllocationsForWorkOrderWithClient } from './consignment-allocation-repo.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, @@ -698,6 +699,13 @@ export async function settleOverdueWorkOrder(input: { now: input.now, reason: `超时处置(${effectivePolicy})`, }) + // 新版寄卖分配(支持数量/拼单):回补剩余数量,并按平台规则没收寄卖保证金。 + await releaseConsignmentAllocationsForWorkOrderWithClient(client, input.workOrderId, input.now) + await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { + workOrderId: input.workOrderId, + note: `寄卖履约超时,扣除寄卖保证金(${effectivePolicy})`, + now: input.now, + }) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index a24f0a45..0df2ddf9 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -25,9 +25,14 @@ import { import { maybeUpgradeWorkerLevel } from './worker-ranking-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' import { + forfeitConsignmentPublishDepositByWorkOrderWithClient, releaseConsignmentPublishDepositBySubmissionWithClient, settleConsignmentSupplierPayoutByWorkOrderWithClient, } from './consignment-deposit-repo.js' +import { + releaseConsignmentAllocationForShareWithClient, + releaseConsignmentAllocationsForWorkOrderWithClient, +} from './consignment-allocation-repo.js' /** * 验收事务提交后再跑自动升级。 @@ -879,6 +884,9 @@ export async function cancelWorkOrderShare(input: { } } + // 寄卖拼单份额:取消时把占用的库存还回可匹配池。 + await releaseConsignmentAllocationForShareWithClient(client, Number(share.id), input.now) + const workerId = Number(share.worker_id || 0) let releasedDepositAmount = 0 if (workerId > 0) { @@ -1119,6 +1127,16 @@ export async function settleOverdueWorkOrderShare(input: { } } + // 寄卖份额超时:库存回池 + 按平台规则扣除该供货打手的寄卖保证金。 + await releaseConsignmentAllocationForShareWithClient(client, Number(share.id), input.now) + if (share.consignment_inventory_id) { + await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { + workOrderId: input.workOrderId, + note: '寄卖拼单超时,扣除寄卖保证金', + now: input.now, + }).catch(() => 0) + } + await client.query( `UPDATE work_order_shares SET status = 'cancelled', @@ -1231,6 +1249,9 @@ export async function cancelWorkOrderSharing(input: { } } + // 整单撤销拼单:释放该工单占用的全部寄卖库存。 + await releaseConsignmentAllocationsForWorkOrderWithClient(client, input.workOrderId, input.now) + const cancellableShares = shares.filter((share) => ['joined', 'submitted'].includes(share.status), ) @@ -1418,6 +1439,9 @@ export async function cancelWorkOrderSharingPart(input: { } } + // 取消剩余份额:释放该工单占用的全部寄卖库存。 + await releaseConsignmentAllocationsForWorkOrderWithClient(client, input.workOrderId, input.now) + const cancellableShares = shares.filter((share) => ['joined', 'submitted'].includes(String(share.status || '')), ) diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index d5bcac43..1ef611f2 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -26,6 +26,12 @@ import { fulfillWorkOrderWithConsignment, revokeWorkOrderConsignmentFulfillment, } from '../../../services/worker-platform/consignment-fulfillment-service.js' +import { + archiveAdminConsignmentProfile, + listAdminConsignmentProfiles, + publishAdminConsignmentProfile, + saveAdminConsignmentProfile, +} from '../../../services/worker-platform/consignment-profile-service.js' import { createJsonHandler, requireAdminPermission } from '../session.js' import { normalizeConsignmentDepositHandling, @@ -37,11 +43,102 @@ import { listWorkOrderInventoryMatchOptions, } from '../../../services/worker-platform/consignment-fulfillment-service.js' import { listPendingPistolMatches } from '../../../services/worker-platform/consignment-pending-match-service.js' +import { + listConsignmentMatchCandidatesForWorkOrder, + matchConsignmentWorkOrderManually, +} from '../../../services/worker-platform/consignment-match-engine-service.js' const router = Router() -// ---------------- 库存管理与统计 ---------------- +// ---------------- 分类级寄卖配置 ---------------- +router.get( + '/worker-platform/consignment/profiles', + requireAdminPermission('consignment.view'), + createJsonHandler(() => listAdminConsignmentProfiles(), { + successMessage: 'ok', + errorMessage: '读取寄卖配置失败', + scope: '[admin/consignment/profiles]', + }), +) + +router.post( + '/worker-platform/consignment/profiles', + requireAdminPermission('consignment.manage'), + createJsonHandler( + (req) => + saveAdminConsignmentProfile({ + ...(req.body || {}), + adminUserId: req.adminSession?.userId ?? null, + }), + { + successMessage: '寄卖配置已保存', + errorMessage: '保存寄卖配置失败', + scope: '[admin/consignment/profiles/save]', + }, + ), +) + +router.post( + '/worker-platform/consignment/profiles/:id/publish', + requireAdminPermission('consignment.manage'), + createJsonHandler( + (req) => + publishAdminConsignmentProfile(String(req.params.id || ''), req.adminSession?.userId ?? null), + { + successMessage: '寄卖配置已发布', + errorMessage: '发布寄卖配置失败', + scope: '[admin/consignment/profiles/publish]', + }, + ), +) + +router.post( + '/worker-platform/consignment/profiles/:id/archive', + requireAdminPermission('consignment.manage'), + createJsonHandler((req) => archiveAdminConsignmentProfile(String(req.params.id || '')), { + successMessage: '寄卖配置已归档', + errorMessage: '归档寄卖配置失败', + scope: '[admin/consignment/profiles/archive]', + }), +) + +// ---------------- 内部匹配(手动/自动共用) ---------------- + +router.get( + '/worker-platform/consignment/match-candidates', + requireAdminPermission('consignment.fulfill'), + createJsonHandler( + (req) => listConsignmentMatchCandidatesForWorkOrder(Number(req.query.workOrderId || 0)), + { + successMessage: 'ok', + errorMessage: '读取可匹配寄卖库存失败', + scope: '[admin/consignment/match-candidates]', + }, + ), +) + +router.post( + '/worker-platform/consignment/match', + requireAdminPermission('consignment.fulfill'), + createJsonHandler( + (req) => + matchConsignmentWorkOrderManually({ + workOrderId: Number(req.body?.workOrderId || 0), + inventoryIds: Array.isArray(req.body?.inventoryIds) + ? req.body.inventoryIds.map((id: unknown) => Number(id)) + : [], + actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin', + }), + { + successMessage: '寄卖库存已匹配', + errorMessage: '匹配寄卖库存失败', + scope: '[admin/consignment/match]', + }, + ), +) + +// ---------------- 库存管理与统计 ---------------- router.get( '/worker-platform/consignment/inventories', requireAdminPermission('consignment.view'), diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index 3e649905..d81347fb 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -6,13 +6,46 @@ import { listWorkerConsignments, publishWorkerConsignment, submitWorkerConsignmentGiftEvidence, + updateWorkerConsignmentSalePrice, } from '../../services/worker-platform/consignment-worker-service.js' import { fetchWorkerConsignmentContacts } from '../../services/worker-platform/consignment-contact-service.js' +import { listWorkerConsignmentProfiles } from '../../services/worker-platform/consignment-profile-service.js' import { sendRouteError } from '../../utils/http.js' import { getRequiredWorkerSession, requireActiveWorker } from './session.js' const router = Router() +/** + * GET /api/v1/worker/consignment/profiles + * 打手端寄卖页按已发布的分类配置动态生成 tab。 + */ +router.get('/consignment/profiles', async (_req, res) => { + try { + const result = await listWorkerConsignmentProfiles() + res.json({ ok: true, data: { profiles: result } }) + } catch (error) { + sendRouteError(res, error, '获取寄卖配置失败', '[worker/consignment/profiles]') + } +}) + +/** + * PATCH /api/v1/worker/consignment/items/:submissionId/price + * 打手修改在售寄卖售价(仅本人、未售出)。 + */ +router.patch('/consignment/items/:submissionId/price', requireActiveWorker, async (req, res) => { + try { + const session = getRequiredWorkerSession(req) + const result = await updateWorkerConsignmentSalePrice( + session.workerId, + String(req.params.submissionId || ''), + req.body?.salePriceFen ?? req.body?.priceFen, + ) + res.json({ ok: true, data: result }) + } catch (error) { + sendRouteError(res, error, '修改寄卖售价失败', '[worker/consignment/price]') + } +}) + /** * GET /api/v1/worker/consignment/contacts * 打手发布寄卖时的「对接客服」下拉:只返回启用中的名单 diff --git a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts index 18ded96e..8cbf592b 100644 --- a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts +++ b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts @@ -37,6 +37,7 @@ import { enqueueOrderServiceAutoPublish, processDueOrderServiceAutoPublishJobs, } from './order-service-auto-publish-service.js' +import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' @@ -201,6 +202,9 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined) } } + if (materialComplete && !autoPublishQueued) { + await enterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false) + } return { order: mapWorkOrderAdmin(workOrder), autoPublishQueued } } diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 5db66551..d5c92a30 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -24,6 +24,10 @@ import { generateConsignmentInventoryNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js' import { safeParseJson } from '../admin/admin-query-utils.js' +import { + normalizeConsignmentProfileConfig, + resolvePriceDropSuggestion, +} from './consignment-profile-service.js' import { ensureConsignmentPublishDepositFrozenBySubmissionWithClient, getConsignmentDepositErrorCode, @@ -160,12 +164,33 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { // 只保留卖家自己的提报信息,不再把上一单买家角色、主页截图、赠送凭证带出来。 const showingMatchedBuyer = row.status === 'consumed' const evidence = mapConsignmentEvidence(row) + const quantity = Math.max(1, Number(row.quantity || 1)) + const remainingQuantity = Math.max(0, Number(row.remaining_quantity ?? quantity)) + const salePriceFen = Math.max(0, Number(row.sale_price_fen || 0)) + const profileConfig = row.profile_snapshot_json + ? normalizeConsignmentProfileConfig(row.profile_snapshot_json) + : null + const priceDropSuggestion = profileConfig + ? resolvePriceDropSuggestion(profileConfig, { + listedAt: row.listed_at || row.inbound_at, + salePriceFen, + now: nowIso(), + }) + : null return { id: Number(row.id), inventoryNo: row.inventory_no, gameName: row.game_name, categoryId: row.category_id ? Number(row.category_id) : null, categoryName: row.category_name, + profileId: row.profile_id ? Number(row.profile_id) : null, + profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null, + quantity, + remainingQuantity, + salePriceFen, + listedAt: row.listed_at || row.inbound_at, + priceUpdatedAt: row.price_updated_at || null, + priceDropSuggestion, itemName: row.item_name, system: row.system, serverZone: row.server_zone, @@ -385,6 +410,10 @@ export async function updateAdminConsignmentInventory( payload.costPriceFen !== undefined ? Math.max(0, Math.round(Number(payload.costPriceFen))) : undefined, + salePriceFen: + payload.salePriceFen !== undefined + ? Math.max(0, Math.round(Number(payload.salePriceFen))) + : undefined, buyerName: payload.buyerName !== undefined ? String(payload.buyerName).trim() : undefined, inboundBatch: payload.inboundBatch !== undefined ? String(payload.inboundBatch).trim() : undefined, @@ -665,6 +694,13 @@ export async function approveAdminConsignmentSubmission( workerName, workerPhone, depositAmountFen, + quantity: Math.max(1, Math.round(Number(submission.quantity || 1))), + salePriceFen: Math.max( + 0, + Math.round( + Number(submission.requested_sale_price_fen || submission.asking_price_fen || costPriceFen), + ), + ), contactSupporterId: payload.contactSupporterId !== undefined ? payload.contactSupporterId 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 new file mode 100644 index 00000000..1b034fa4 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -0,0 +1,678 @@ +import type { PoolClient } from 'pg' + +import { withTransaction, query } from '../../db/client.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + createWorkOrderEventWithClient, + getWorkOrderById, + type WorkOrderRow, +} from '../../repositories/worker-platform/index.js' +import { + listActiveConsignmentInventoriesForMatching, + type ConsignmentInventoryRow, +} from '../../repositories/worker-platform/consignment-repo.js' +import { + reserveConsignmentInventoryWithClient, + releaseConsignmentAllocationsForWorkOrderWithClient, +} from '../../repositories/worker-platform/consignment-allocation-repo.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { + buildServiceFeeFen, + resolveConsignmentProfileForCategory, + type ConsignmentProfileSnapshot, +} from './consignment-profile-service.js' +import { + classifyConsignmentMatch, + extractPeacekeeperSpecs, +} from './consignment-matching-service.js' +import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js' + +type MatchCandidate = { + workOrder: WorkOrderRow + profile: ConsignmentProfileSnapshot | null +} + +function parseMaterial(value: unknown): Record { + const parsed = safeParseJson(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} +} + +function addMinutesIso(minutes: number, now: string): string | null { + const normalized = Math.max(0, Math.round(Number(minutes || 0))) + if (normalized <= 0) return null + return new Date(new Date(now).getTime() + normalized * 60 * 1000).toISOString() +} + +function inventorySalePriceFen(inventory: ConsignmentInventoryRow): number { + return Math.max(0, Math.round(Number(inventory.sale_price_fen || inventory.cost_price_fen || 0))) +} + +function inventoryMatchesProfile( + inventory: ConsignmentInventoryRow, + profile: ConsignmentProfileSnapshot | null, +): boolean { + if (!profile) return true + const inventoryProfileId = inventory.profile_id ? Number(inventory.profile_id) : null + if (inventoryProfileId) return inventoryProfileId === profile.profileId + // 存量库存没有 profile_id:按分类兜底(配置挂一级分类时同样命中)。 + return Number(inventory.category_id || 0) === profile.categoryId +} + +function inventoryMatchesWorkOrder( + workOrder: WorkOrderRow, + inventory: ConsignmentInventoryRow, +): boolean { + if (Number(inventory.remaining_quantity ?? 1) <= 0) return false + if (!inventory.allow_matching) return false + if (inventory.status !== 'in_stock') return false + const fields = resolveCollectSubmittedFieldValues(workOrder) + const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, { + system: fields.system, + serverZone: fields.serverZone, + series: fields.series, + }) + return classifyConsignmentMatch(orderSpecs, inventory).matchType === 'exact' +} + +async function resolveProfileForWorkOrder( + workOrder: WorkOrderRow, +): Promise { + if (workOrder.consignment_profile_id) { + // 内部匹配阶段写入的 profile 快照优先;仍以发布配置为准。 + const result = await query<{ category_id: number }>( + 'SELECT category_id FROM consignment_profiles WHERE id = $1 LIMIT 1', + [workOrder.consignment_profile_id], + ) + const categoryId = result.rows[0]?.category_id + if (categoryId) { + const resolved = await resolveConsignmentProfileForCategory(Number(categoryId)) + if (resolved) return resolved + } + } + return resolveConsignmentProfileForCategory(workOrder.category_id) +} + +/** 进入「内部匹配中」:命中分类寄卖配置时由资料提交/自动发布链路调用。 */ +export async function enterConsignmentInternalMatching( + workOrderId: number, + now = nowIso(), +): Promise { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false + const profile = await resolveProfileForWorkOrder(workOrder) + if (!profile) return false + const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes) + if (delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall) return false + const deadline = addMinutesIso(delayMinutes, now) + const result = await query( + ` + UPDATE work_orders + SET status = 'internal_matching', + consignment_profile_id = $1, + internal_match_deadline_at = $2, + hall_visible_at = $2, + updated_at = $3 + WHERE id = $4 AND status = $5 + RETURNING * + `, + [profile.profileId, deadline, now, workOrderId, WORK_ORDER_STATUS.UNASSIGNED], + ) + if (!result.rows[0]) return false + await query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, + payload_json, created_at + ) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_entered', $2, 'internal_matching', $3::jsonb, $4) + `, + [ + workOrderId, + WORK_ORDER_STATUS.UNASSIGNED, + JSON.stringify({ profileId: profile.profileId, delayMinutes, deadline }), + now, + ], + ) + return true +} + +export async function reenterConsignmentInternalMatching( + workOrderId: number, + now = nowIso(), +): Promise { + const workOrder = await getWorkOrderById(workOrderId) + if ( + !workOrder || + ![WORK_ORDER_STATUS.OPEN, WORK_ORDER_STATUS.UNASSIGNED].includes(workOrder.status as never) + ) { + return false + } + const profile = await resolveProfileForWorkOrder(workOrder) + if (!profile) return false + const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes) + if (delayMinutes <= 0) return false + const deadline = addMinutesIso(delayMinutes, now) + const result = await query( + ` + UPDATE work_orders + SET status = 'internal_matching', + consignment_profile_id = $1, + internal_match_deadline_at = $2, + hall_visible_at = $2, + internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1, + updated_at = $3 + WHERE id = $4 AND status IN ('open', 'unassigned') + RETURNING * + `, + [profile.profileId, deadline, now, workOrderId], + ) + if (!result.rows[0]) return false + await query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, + payload_json, created_at + ) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_reentered', $2, 'internal_matching', $3::jsonb, $4) + `, + [ + workOrderId, + workOrder.status, + JSON.stringify({ profileId: profile.profileId, delayMinutes, deadline }), + now, + ], + ) + return true +} + +function pickCandidateInventories( + workOrder: WorkOrderRow, + profile: ConsignmentProfileSnapshot | null, + inventories: ConsignmentInventoryRow[], +): ConsignmentInventoryRow[] { + return inventories + .filter( + (inventory) => + inventoryMatchesProfile(inventory, profile) && + inventoryMatchesWorkOrder(workOrder, inventory), + ) + .sort((a, b) => { + const priceDiff = inventorySalePriceFen(a) - inventorySalePriceFen(b) + if (priceDiff !== 0) return priceDiff + const listedA = Date.parse(String(a.listed_at || a.inbound_at || '')) || 0 + const listedB = Date.parse(String(b.listed_at || b.inbound_at || '')) || 0 + if (listedA !== listedB) return listedA - listedB + return Number(a.id) - Number(b.id) + }) +} + +async function applyWholeOrderMatch( + input: MatchCandidate, + inventory: ConsignmentInventoryRow, +): Promise { + const { workOrder, profile } = input + if (!workOrder.id || !inventory.worker_id) return false + const now = nowIso() + + return withTransaction(async (client: PoolClient) => { + const locked = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [workOrder.id], + ) + const current = locked.rows[0] + if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return false + if (current.assigned_worker_id) return false + + const allocation = await reserveConsignmentInventoryWithClient(client, { + inventoryId: Number(inventory.id), + workOrderId: Number(current.id), + quantity: 1, + unitPriceFen: inventorySalePriceFen(inventory), + now, + }) + if (!allocation) return false + + const config = profile?.config + const grossRewardFen = inventorySalePriceFen(inventory) || Number(current.reward_amount || 0) + const serviceFeeFen = config ? buildServiceFeeFen(config, grossRewardFen) : 0 + const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen) + const timeoutMinutes = config?.timeout.minutes ?? 30 + const timeoutPolicy = config?.timeout.policy ?? 'reopen' + const material = parseMaterial(current.material_json) + const nextMaterial = { + ...material, + consignment: { + inventoryId: Number(inventory.id), + inventoryNo: inventory.inventory_no, + itemName: inventory.item_name, + system: inventory.system, + serverZone: inventory.server_zone, + series: inventory.series, + diamondLevel: inventory.diamond_level, + brillianceCount: inventory.brilliance_count, + profileId: profile?.profileId ?? null, + profileVersionId: profile?.versionId ?? null, + salePriceFen: inventorySalePriceFen(inventory), + grossRewardFen, + serviceFeeFen, + netRewardFen, + previousRewardAmountFen: Number(current.reward_amount || 0), + workerId: Number(inventory.worker_id), + workerName: inventory.worker_name || '', + matchedAt: now, + matchType: 'auto', + }, + } + + const updated = await client.query( + ` + UPDATE work_orders + SET status = $1, + assigned_worker_id = $2, + assigned_at = $3, + deadline_at = $4, + timeout_minutes = $5, + timeout_policy = $6, + reward_amount = $7, + material_json = $8::jsonb, + hall_visible_at = NULL, + internal_match_deadline_at = NULL, + internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1, + updated_at = $3 + WHERE id = $9 AND status = 'internal_matching' + RETURNING * + `, + [ + WORK_ORDER_STATUS.IN_PROGRESS, + Number(inventory.worker_id), + now, + addMinutesIso(timeoutMinutes, now), + timeoutMinutes, + timeoutPolicy, + netRewardFen, + JSON.stringify(nextMaterial), + current.id, + ], + ) + if (!updated.rows[0]) return false + + await createWorkOrderEventWithClient(client, { + workOrderId: Number(current.id), + actorType: 'system', + actorId: 'consignment_match', + eventType: 'consignment_auto_matched', + fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING, + toStatus: WORK_ORDER_STATUS.IN_PROGRESS, + payloadJson: JSON.stringify({ + inventoryId: Number(inventory.id), + inventoryNo: inventory.inventory_no, + workerId: Number(inventory.worker_id), + profileId: profile?.profileId ?? null, + salePriceFen: inventorySalePriceFen(inventory), + grossRewardFen, + serviceFeeFen, + netRewardFen, + allocationId: allocation.id, + }), + now, + }) + return true + }) +} + +async function applySharingMatch( + input: MatchCandidate, + inventories: ConsignmentInventoryRow[], +): Promise { + const { workOrder, profile } = input + const now = nowIso() + const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + if (!workOrder.id || totalQuantity <= 1) return 0 + + return withTransaction(async (client: PoolClient) => { + const locked = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [workOrder.id], + ) + const current = locked.rows[0] + if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return 0 + + const activeResult = await client.query<{ active: number | string }>( + `SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`, + [current.id], + ) + let active = Number(activeResult.rows[0]?.active || 0) + let remaining = Math.max(0, totalQuantity - active) + if (remaining <= 0) return 0 + + const config = profile?.config + const timeoutMinutes = config?.timeout.minutes ?? 30 + const timeoutPolicy = config?.timeout.policy ?? 'reopen' + const deadline = addMinutesIso(timeoutMinutes, now) + 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) + + let createdCount = 0 + let firstInventoryId: number | null = null + for (const inventory of inventories) { + if (remaining <= 0) break + if (!inventory.worker_id) continue + if (!inventoryMatchesProfile(inventory, profile)) continue + if (!inventoryMatchesWorkOrder(current, inventory)) continue + + const allocation = await reserveConsignmentInventoryWithClient(client, { + inventoryId: Number(inventory.id), + workOrderId: Number(current.id), + quantity: 1, + unitPriceFen: inventorySalePriceFen(inventory), + now, + }) + if (!allocation) continue + + const share = await client.query<{ id: number }>( + ` + INSERT INTO work_order_shares ( + work_order_id, worker_id, quantity, unit_reward, share_reward, share_deposit, + 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, + 'joined', '{}'::jsonb, '{}'::jsonb, $5, $6, + $7, '', $8, $9, $9 + ) + RETURNING id + `, + [ + current.id, + Number(inventory.worker_id), + shareGrossFen, + shareNetFen, + deadline, + timeoutMinutes, + timeoutPolicy, + Number(inventory.id), + now, + ], + ) + const shareId = Number(share.rows[0]?.id || 0) + if (shareId <= 0) continue + await client.query( + 'UPDATE consignment_inventory_allocations SET work_order_share_id = $1, updated_at = $2 WHERE id = $3', + [shareId, now, allocation.id], + ) + createdCount += 1 + if (!firstInventoryId) firstInventoryId = Number(inventory.id) + remaining -= 1 + } + + if (createdCount > 0) { + active += createdCount + const material = parseMaterial(current.material_json) + const nextStatus = + active >= totalQuantity + ? WORK_ORDER_STATUS.IN_PROGRESS + : WORK_ORDER_STATUS.INTERNAL_MATCHING + await client.query( + ` + UPDATE work_orders + SET status = $1, + material_json = $2::jsonb, + assigned_at = CASE WHEN $1 = 'in_progress' THEN $3 ELSE assigned_at END, + internal_match_deadline_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE internal_match_deadline_at END, + hall_visible_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE hall_visible_at END, + internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1, + updated_at = $3 + WHERE id = $4 + `, + [ + nextStatus, + JSON.stringify({ + ...material, + ...(firstInventoryId + ? { + consignment: { + inventoryId: firstInventoryId, + profileId: profile?.profileId ?? null, + matchedAt: now, + matchType: 'auto_sharing', + }, + } + : {}), + consignmentSharing: { + profileId: profile?.profileId ?? null, + totalQuantity, + matchedQuantity: active, + matchedAt: now, + }, + }), + now, + current.id, + ], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(current.id), + actorType: 'system', + actorId: 'consignment_match', + eventType: 'consignment_sharing_matched', + fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING, + toStatus: nextStatus, + payloadJson: JSON.stringify({ + createdCount, + activeQuantity: active, + totalQuantity, + shareRewardFen: shareNetFen, + serviceFeeFenPerShare: shareFeeFen, + }), + now, + }) + } + return createdCount + }) +} + +/** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */ +export async function autoMatchConsignmentWorkOrders(options: { limit?: number } = {}) { + const limit = Math.min(Math.max(Number(options.limit || 50), 1), 200) + const orders = await query( + ` + SELECT * FROM work_orders + WHERE status = 'internal_matching' + ORDER BY reward_amount ASC, created_at ASC + LIMIT $1 + `, + [limit], + ) + let matched = 0 + for (const workOrder of orders.rows) { + try { + const profile = await resolveProfileForWorkOrder(workOrder) + if (!profile) continue + if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) { + continue + } + const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( + (inventory) => inventoryMatchesProfile(inventory, profile), + ) + const candidates = pickCandidateInventories(workOrder, profile, inventories) + if (candidates.length === 0) continue + + if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { + const created = await applySharingMatch({ workOrder, profile }, candidates) + if (created > 0) matched += 1 + } else { + const applied = await applyWholeOrderMatch({ workOrder, profile }, candidates[0]!) + if (applied) matched += 1 + } + } catch { + // 单笔失败不影响同批其它工单;下一次调度会重试。 + } + } + return { scanned: orders.rows.length, matched } +} + +/** 内部匹配超时:发回大厅(保留已占库存的拼单份额,剩余份数继续抢单)。 */ +export async function expireConsignmentInternalMatching(options: { now?: string } = {}) { + const now = options.now || nowIso() + const expired = await query( + ` + UPDATE work_orders + SET status = 'open', + published_at = COALESCE(published_at, $1), + hall_visible_at = NULL, + internal_match_deadline_at = NULL, + updated_at = $1 + WHERE status = 'internal_matching' + AND internal_match_deadline_at IS NOT NULL + AND internal_match_deadline_at <= $1 + RETURNING * + `, + [now], + ) + for (const workOrder of expired.rows) { + await query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, + payload_json, created_at + ) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_expired', 'internal_matching', 'open', $2::jsonb, $3) + `, + [workOrder.id, JSON.stringify({ expiredAt: now }), now], + ) + } + return { expired: expired.rows.length } +} + +/** 后台手动匹配弹窗:列出该工单可精确匹配的库存。 */ +export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: number) { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder) { + throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) + } + const profile = await resolveProfileForWorkOrder(workOrder) + const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) => + inventoryMatchesProfile(inventory, profile), + ) + const options = pickCandidateInventories(workOrder, profile, inventories).map((inventory) => ({ + inventoryId: Number(inventory.id), + inventoryNo: inventory.inventory_no, + itemName: inventory.item_name, + system: inventory.system, + serverZone: inventory.server_zone, + series: inventory.series, + diamondLevel: Number(inventory.diamond_level || 1), + brillianceCount: Number(inventory.brilliance_count || 0), + quantity: Number(inventory.quantity || 1), + remainingQuantity: Number(inventory.remaining_quantity ?? 1), + salePriceFen: inventorySalePriceFen(inventory), + listedAt: inventory.listed_at || inventory.inbound_at, + workerId: inventory.worker_id ? Number(inventory.worker_id) : null, + workerName: inventory.worker_name || '', + })) + const active = await query<{ active: number | string }>( + `SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`, + [workOrder.id], + ) + return { + workOrderId: Number(workOrder.id), + profileId: profile?.profileId ?? null, + sharingEnabled: Boolean(workOrder.sharing_enabled), + totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)), + activeQuantity: Number(active.rows[0]?.active || 0), + options, + } +} + +/** 后台手动匹配:选中一条库存走整单;拼单工单可多选库存,每份一条份额。 */ +export async function matchConsignmentWorkOrderManually(input: { + workOrderId: number + inventoryIds: number[] + actorName?: string +}): Promise<{ matched: boolean; matchedCount: number }> { + const workOrder = await getWorkOrderById(input.workOrderId) + if (!workOrder) { + throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) + } + if ( + ![ + WORK_ORDER_STATUS.INTERNAL_MATCHING, + WORK_ORDER_STATUS.UNASSIGNED, + WORK_ORDER_STATUS.OPEN, + ].includes(workOrder.status as never) + ) { + throw createHttpError('当前工单状态不允许内部库存匹配', { + statusCode: 409, + errorCode: 'work_order_not_matchable', + }) + } + const profile = await resolveProfileForWorkOrder(workOrder) + const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) => + inventoryMatchesProfile(inventory, profile), + ) + const selected = input.inventoryIds + .map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id))) + .filter((inventory): inventory is ConsignmentInventoryRow => Boolean(inventory)) + + if (selected.length === 0) { + throw createHttpError('请选择可用的寄卖库存', { + statusCode: 400, + errorCode: 'consignment_inventory_required', + }) + } + + const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING } + if (!workOrder.sharing_enabled) { + await query( + `UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`, + [nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]], + ) + } + + if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { + // 手动匹配前先把工单置为内部匹配中,复用同一套份额事务。 + await query( + `UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`, + [nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]], + ) + const created = await applySharingMatch({ workOrder: appliedWorkOrder, profile }, selected) + return { matched: created > 0, matchedCount: created } + } + + const applied = await applyWholeOrderMatch({ workOrder: appliedWorkOrder, profile }, selected[0]!) + return { matched: applied, matchedCount: applied ? 1 : 0 } +} + +/** 工单取消/退回大厅时释放所有未消耗的寄卖库存。 */ +export async function releaseConsignmentForWorkOrder(workOrderId: number): Promise { + const now = nowIso() + return withTransaction((client) => + releaseConsignmentAllocationsForWorkOrderWithClient(client, workOrderId, now), + ) +} + +let matchTimer: NodeJS.Timeout | null = null + +/** 轻量调度:每 30 秒扫描一次内部匹配与超时;unref 不阻塞进程退出。 */ +export function startConsignmentMatchScheduler(intervalMs = 30_000) { + if (matchTimer) return + matchTimer = setInterval( + () => { + void autoMatchConsignmentWorkOrders().catch(() => undefined) + void expireConsignmentInternalMatching().catch(() => undefined) + }, + Math.max(5_000, intervalMs), + ) + matchTimer.unref?.() +} + +export function stopConsignmentMatchScheduler() { + if (matchTimer) { + clearInterval(matchTimer) + matchTimer = null + } +} diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts new file mode 100644 index 00000000..247682b8 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { + buildDepositAmountFen, + buildServiceFeeFen, + normalizeConsignmentProfileConfig, + resolvePriceDropSuggestion, +} from './consignment-profile-service.js' + +test('寄卖配置:缺省值必须可安全用于发布(保证金/服务费默认关闭)', () => { + const config = normalizeConsignmentProfileConfig({}) + assert.equal(config.deposit.enabled, false) + assert.equal(config.serviceFee.enabled, false) + assert.equal(config.matching.mode, 'manual') + assert.equal(config.timeout.minutes, 30) + assert.equal(config.internalMatch.delayMinutes, 30) + assert.equal(config.fields.quantity, true) +}) + +test('寄卖配置:非法枚举与越界数字回落到安全值', () => { + const config = normalizeConsignmentProfileConfig({ + deposit: { mode: 'bad', fixedAmountFen: -1, unitAmountFen: 'x' }, + serviceFee: { mode: 'bad', percent: 500 }, + matching: { mode: 'bad' }, + timeout: { policy: 'bad', minutes: -10 }, + priceDrop: { hours: 0, mode: 'bad', value: -5 }, + }) + assert.equal(config.deposit.mode, 'fixed') + assert.equal(config.deposit.fixedAmountFen, 0) + assert.equal(config.deposit.unitAmountFen, 0) + assert.equal(config.serviceFee.mode, 'percent') + assert.equal(config.serviceFee.percent, 100) + assert.equal(config.matching.mode, 'manual') + assert.equal(config.timeout.policy, 'reopen') + assert.equal(config.timeout.minutes, 0) + assert.equal(config.priceDrop.hours, 1) + assert.equal(config.priceDrop.value, 0) +}) + +test('保证金:固定金额或按数量计算', () => { + const fixed = normalizeConsignmentProfileConfig({ + deposit: { enabled: true, mode: 'fixed', fixedAmountFen: 2000 }, + }) + assert.equal(buildDepositAmountFen(fixed, 3), 2000) + + const perQuantity = normalizeConsignmentProfileConfig({ + deposit: { enabled: true, mode: 'per_quantity', unitAmountFen: 500 }, + }) + assert.equal(buildDepositAmountFen(perQuantity, 3), 1500) + assert.equal(buildDepositAmountFen(perQuantity, 1), 500) +}) + +test('服务费:按比例或固定金额,且不超过成交金额', () => { + const percent = normalizeConsignmentProfileConfig({ + serviceFee: { enabled: true, mode: 'percent', percent: 10 }, + }) + assert.equal(buildServiceFeeFen(percent, 12_800), 1_280) + + const fixed = normalizeConsignmentProfileConfig({ + serviceFee: { enabled: true, mode: 'fixed', amountFen: 500 }, + }) + assert.equal(buildServiceFeeFen(fixed, 1_200), 500) + assert.equal(buildServiceFeeFen(fixed, 300), 300) +}) + +test('降价提示:未到时长不提示,到点后按比例给出建议售价', () => { + const config = normalizeConsignmentProfileConfig({ + priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 }, + }) + const listedAt = '2026-01-01T00:00:00.000Z' + assert.equal( + resolvePriceDropSuggestion(config, { + listedAt, + salePriceFen: 10_000, + now: '2026-01-01T12:00:00.000Z', + }), + null, + ) + const suggestion = resolvePriceDropSuggestion(config, { + listedAt, + salePriceFen: 10_000, + now: '2026-01-03T00:00:00.000Z', + }) + assert.equal(suggestion?.suggestedPriceFen, 9_000) + assert.equal(suggestion?.dropHours, 24) +}) + +test('迁移 129 必须包含寄卖配置、库存数量/售价与分配表', async () => { + const source = await readFile( + new URL('../../db/migrations/129_consignment_matching.sql', import.meta.url), + 'utf8', + ) + for (const fragment of [ + 'CREATE TABLE IF NOT EXISTS consignment_profiles', + 'CREATE TABLE IF NOT EXISTS consignment_profile_versions', + 'remaining_quantity', + 'sale_price_fen', + 'consignment_inventory_allocations', + 'internal_match_deadline_at', + 'consignment_inventory_id', + ]) { + assert.ok(source.includes(fragment), `迁移 129 缺少 ${fragment}`) + } +}) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts new file mode 100644 index 00000000..01ade1b6 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -0,0 +1,624 @@ +import { query, withTransaction } from '../../db/client.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js' + +export type ConsignmentPricingConfig = { + useTemplatePrice: boolean + allowWorkerPrice: boolean + minPriceFen: number +} + +export type ConsignmentDepositConfig = { + enabled: boolean + mode: 'fixed' | 'per_quantity' + fixedAmountFen: number + unitAmountFen: number +} + +export type ConsignmentServiceFeeConfig = { + enabled: boolean + mode: 'percent' | 'fixed' + percent: number + amountFen: number +} + +export type ConsignmentInventoryLimitConfig = { + enabled: boolean + maxActiveQuantity: number +} + +export type ConsignmentMatchingConfig = { + /** manual = 只允许人工匹配;auto = 调度器自动匹配 */ + mode: 'manual' | 'auto' + autoMatchEnabled: boolean + exactOnly: boolean + preferLowReward: boolean +} + +export type ConsignmentInternalMatchConfig = { + /** 资料提交后等待内部库存匹配的分钟数;0 = 不等待,直接发大厅 */ + delayMinutes: number + fallbackToHall: boolean +} + +export type ConsignmentTimeoutConfig = { + /** 匹配成功进入代练中后的时限(分钟),默认 30 */ + minutes: number + policy: 'reopen' | 'cancel_release' | 'cancel_deduct' +} + +export type ConsignmentPriceDropConfig = { + enabled: boolean + hours: number + mode: 'percent' | 'fixed' + value: number +} + +export type ConsignmentPublishFields = { + system: boolean + serverZone: boolean + series: boolean + diamondLevel: boolean + brillianceCount: boolean + quantity: boolean +} + +export type ConsignmentProfileConfig = { + version: 1 + tabTitle: string + tabDescription: string + fields: ConsignmentPublishFields + pricing: ConsignmentPricingConfig + deposit: ConsignmentDepositConfig + serviceFee: ConsignmentServiceFeeConfig + inventoryLimit: ConsignmentInventoryLimitConfig + matching: ConsignmentMatchingConfig + internalMatch: ConsignmentInternalMatchConfig + timeout: ConsignmentTimeoutConfig + priceDrop: ConsignmentPriceDropConfig +} + +export type ConsignmentProfileRow = { + id: number + category_id: number + name: string + title: string + description: string + tab_title: string + config_json: unknown + status: 'draft' | 'published' | 'archived' + current_version_id: number | null + created_by_admin_user_id: number | null + created_at: string + updated_at: string + category_name?: string + category_parent_id?: number | null + current_version_no?: number | null +} + +export type ConsignmentProfileSnapshot = { + profileId: number + categoryId: number + versionId: number + versionNo: number + config: ConsignmentProfileConfig +} + +export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = { + version: 1, + tabTitle: '', + tabDescription: '', + fields: { + system: true, + serverZone: true, + series: true, + diamondLevel: true, + brillianceCount: true, + quantity: true, + }, + pricing: { + useTemplatePrice: true, + allowWorkerPrice: false, + minPriceFen: 0, + }, + deposit: { + enabled: false, + mode: 'fixed', + fixedAmountFen: 0, + unitAmountFen: 0, + }, + serviceFee: { + enabled: false, + mode: 'percent', + percent: 0, + amountFen: 0, + }, + inventoryLimit: { + enabled: false, + maxActiveQuantity: 0, + }, + matching: { + mode: 'manual', + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + }, + internalMatch: { + delayMinutes: 30, + fallbackToHall: true, + }, + timeout: { + minutes: 30, + policy: 'reopen', + }, + priceDrop: { + enabled: false, + hours: 72, + mode: 'percent', + value: 10, + }, +} + +function record(value: unknown): Record { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } + } + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function text(value: unknown, maxLength: number): string { + return String(value ?? '') + .trim() + .slice(0, maxLength) +} + +function bool(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value + if (value === 'true' || value === 1 || value === '1') return true + if (value === 'false' || value === 0 || value === '0') return false + return fallback +} + +function integer(value: unknown, fallback: number, min: number, max: number): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, Math.round(parsed))) +} + +export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentProfileConfig { + const source = record(value) + const fields = record(source.fields) + const pricing = record(source.pricing) + const deposit = record(source.deposit) + const serviceFee = record(source.serviceFee) + const inventoryLimit = record(source.inventoryLimit) + const matching = record(source.matching) + const internalMatch = record(source.internalMatch) + const timeout = record(source.timeout) + const priceDrop = record(source.priceDrop) + + const matchingMode = matching.mode === 'auto' ? 'auto' : 'manual' + const depositMode = deposit.mode === 'per_quantity' ? 'per_quantity' : 'fixed' + const serviceFeeMode = serviceFee.mode === 'fixed' ? 'fixed' : 'percent' + const priceDropMode = priceDrop.mode === 'fixed' ? 'fixed' : 'percent' + const timeoutPolicy = + timeout.policy === 'cancel_release' || timeout.policy === 'cancel_deduct' + ? timeout.policy + : 'reopen' + + return { + version: 1, + tabTitle: text(source.tabTitle, 30), + tabDescription: text(source.tabDescription, 200), + fields: { + system: bool(fields.system, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.system), + serverZone: bool(fields.serverZone, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.serverZone), + series: bool(fields.series, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.series), + diamondLevel: bool( + fields.diamondLevel, + DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.diamondLevel, + ), + brillianceCount: bool( + fields.brillianceCount, + DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.brillianceCount, + ), + quantity: bool(fields.quantity, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.quantity), + }, + pricing: { + useTemplatePrice: bool( + pricing.useTemplatePrice, + DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.useTemplatePrice, + ), + allowWorkerPrice: bool( + pricing.allowWorkerPrice, + DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.allowWorkerPrice, + ), + minPriceFen: integer(pricing.minPriceFen, 0, 0, 100_000_000), + }, + deposit: { + enabled: bool(deposit.enabled, false), + mode: depositMode, + fixedAmountFen: integer(deposit.fixedAmountFen, 0, 0, 100_000_000), + unitAmountFen: integer(deposit.unitAmountFen, 0, 0, 100_000_000), + }, + serviceFee: { + enabled: bool(serviceFee.enabled, false), + mode: serviceFeeMode, + percent: integer(serviceFee.percent, 0, 0, 100), + amountFen: integer(serviceFee.amountFen, 0, 0, 100_000_000), + }, + inventoryLimit: { + enabled: bool(inventoryLimit.enabled, false), + maxActiveQuantity: integer(inventoryLimit.maxActiveQuantity, 0, 0, 100_000), + }, + matching: { + mode: matchingMode, + autoMatchEnabled: matchingMode === 'auto' ? bool(matching.autoMatchEnabled, true) : false, + exactOnly: bool(matching.exactOnly, true), + preferLowReward: bool(matching.preferLowReward, true), + }, + internalMatch: { + delayMinutes: integer(internalMatch.delayMinutes, 30, 0, 7 * 24 * 60), + fallbackToHall: bool(internalMatch.fallbackToHall, true), + }, + timeout: { + minutes: integer(timeout.minutes, 30, 0, 7 * 24 * 60), + policy: timeoutPolicy, + }, + priceDrop: { + enabled: bool(priceDrop.enabled, false), + hours: integer(priceDrop.hours, 72, 1, 24 * 365), + mode: priceDropMode, + value: integer(priceDrop.value, 10, 0, 100_000_000), + }, + } +} + +function mapProfile(row: ConsignmentProfileRow) { + return { + profileId: Number(row.id), + categoryId: Number(row.category_id), + categoryName: row.category_name || '', + categoryParentId: row.category_parent_id ? Number(row.category_parent_id) : null, + name: row.name, + title: row.title || '', + description: row.description || '', + tabTitle: row.tab_title || '', + status: row.status, + currentVersionId: row.current_version_id ? Number(row.current_version_id) : null, + currentVersionNo: row.current_version_no ? Number(row.current_version_no) : null, + config: normalizeConsignmentProfileConfig(row.config_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +const PROFILE_SELECT = ` + SELECT + p.*, + c.name AS category_name, + c.parent_id AS category_parent_id, + v.version_no AS current_version_no + FROM consignment_profiles p + INNER JOIN work_categories c ON c.id = p.category_id + LEFT JOIN consignment_profile_versions v ON v.id = p.current_version_id +` + +export async function listAdminConsignmentProfiles() { + const [categories, profiles] = await Promise.all([ + listAllWorkCategories(), + query(`${PROFILE_SELECT} ORDER BY c.sort_order ASC, p.id ASC`), + ]) + return { + categories: categories.map((category) => ({ + categoryId: Number(category.id), + categoryKey: category.category_key, + name: category.name, + parentId: category.parent_id ? Number(category.parent_id) : null, + status: category.status, + })), + profiles: profiles.rows.map(mapProfile), + } +} + +export async function saveAdminConsignmentProfile(payload: JsonObject = {}) { + const categoryId = Number(payload.categoryId ?? payload.category_id ?? 0) + if (!Number.isInteger(categoryId) || categoryId <= 0) { + throw createHttpError('请选择寄卖分类', { + statusCode: 400, + errorCode: 'consignment_category_required', + }) + } + const categoryResult = await query<{ id: number }>( + 'SELECT id FROM work_categories WHERE id = $1 LIMIT 1', + [categoryId], + ) + if (!categoryResult.rows[0]) { + throw createHttpError('分类不存在', { + statusCode: 404, + errorCode: 'work_category_not_found', + }) + } + + const config = normalizeConsignmentProfileConfig(payload.config ?? payload.config_json) + const now = nowIso() + const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null + const name = text(payload.name, 80) || '寄卖配置' + const title = text(payload.title, 120) + const description = text(payload.description, 500) + const tabTitle = text(payload.tabTitle, 30) || config.tabTitle || name + + const result = await query( + ` + INSERT INTO consignment_profiles ( + category_id, name, title, description, tab_title, config_json, + status, created_by_admin_user_id, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'draft', $7, $8, $8) + ON CONFLICT (category_id) DO UPDATE SET + name = EXCLUDED.name, + title = EXCLUDED.title, + description = EXCLUDED.description, + tab_title = EXCLUDED.tab_title, + config_json = EXCLUDED.config_json, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + categoryId, + name, + title, + description, + tabTitle, + JSON.stringify({ ...config, tabTitle }), + adminUserId, + now, + ], + ) + + const profiles = await query(`${PROFILE_SELECT} WHERE p.id = $1`, [ + result.rows[0]!.id, + ]) + return { profile: mapProfile(profiles.rows[0]!) } +} + +export async function publishAdminConsignmentProfile( + profileIdInput: number | string, + adminUserIdInput: unknown = null, +) { + const profileId = Number(profileIdInput) + if (!Number.isInteger(profileId) || profileId <= 0) { + throw createHttpError('寄卖配置不存在', { + statusCode: 400, + errorCode: 'consignment_profile_invalid', + }) + } + const adminUserId = Number(adminUserIdInput ?? 0) || null + + return withTransaction(async (client) => { + const locked = await client.query( + 'SELECT * FROM consignment_profiles WHERE id = $1 FOR UPDATE', + [profileId], + ) + const profile = locked.rows[0] + if (!profile) { + throw createHttpError('寄卖配置不存在', { + statusCode: 404, + errorCode: 'consignment_profile_not_found', + }) + } + const config = normalizeConsignmentProfileConfig(profile.config_json) + const maxVersion = await client.query<{ max_version: number | null }>( + 'SELECT MAX(version_no) AS max_version FROM consignment_profile_versions WHERE profile_id = $1', + [profileId], + ) + const versionNo = Number(maxVersion.rows[0]?.max_version || 0) + 1 + const now = nowIso() + const version = await client.query<{ id: number }>( + ` + INSERT INTO consignment_profile_versions ( + profile_id, version_no, config_json, published_by_admin_user_id, published_at, created_at + ) VALUES ($1, $2, $3::jsonb, $4, $5, $5) + RETURNING id + `, + [profileId, versionNo, JSON.stringify(config), adminUserId, now], + ) + await client.query( + ` + UPDATE consignment_profiles + SET status = 'published', current_version_id = $1, updated_at = $2 + WHERE id = $3 + `, + [version.rows[0]!.id, now, profileId], + ) + const profiles = await client.query( + `${PROFILE_SELECT} WHERE p.id = $1`, + [profileId], + ) + return { profile: mapProfile(profiles.rows[0]!) } + }) +} + +export async function archiveAdminConsignmentProfile(profileIdInput: number | string) { + const profileId = Number(profileIdInput) + if (!Number.isInteger(profileId) || profileId <= 0) { + throw createHttpError('寄卖配置不存在', { + statusCode: 400, + errorCode: 'consignment_profile_invalid', + }) + } + const result = await query( + `UPDATE consignment_profiles SET status = 'archived', updated_at = $1 WHERE id = $2 RETURNING *`, + [nowIso(), profileId], + ) + if (!result.rows[0]) { + throw createHttpError('寄卖配置不存在', { + statusCode: 404, + errorCode: 'consignment_profile_not_found', + }) + } + return { profile: mapProfile(result.rows[0]) } +} + +/** 打手端:只返回已发布且分类启用的寄卖配置,按分类排序生成 tab。 */ +export async function listWorkerConsignmentProfiles() { + const result = await query( + ` + ${PROFILE_SELECT} + WHERE p.status = 'published' AND c.status = 'active' + ORDER BY c.sort_order ASC, p.id ASC + `, + ) + return result.rows.map((row) => { + const config = normalizeConsignmentProfileConfig(row.config_json) + return { + profileId: Number(row.id), + categoryId: Number(row.category_id), + categoryName: row.category_name || '', + name: row.name, + title: row.title || row.name, + description: row.description || '', + tabTitle: row.tab_title || config.tabTitle || row.name, + tabDescription: config.tabDescription, + config, + } + }) +} + +/** + * 工单分类解析寄卖配置:优先二级分类自身,没有则回退一级分类。 + * 只认已发布版本,返回可直接固化的快照。 + */ +export async function resolveConsignmentProfileForCategory( + categoryIdInput: number | null | undefined, +): Promise { + const categoryId = Number(categoryIdInput || 0) + if (!Number.isInteger(categoryId) || categoryId <= 0) return null + + const categoryResult = await query<{ id: number; parent_id: number | null }>( + 'SELECT id, parent_id FROM work_categories WHERE id = $1 LIMIT 1', + [categoryId], + ) + const category = categoryResult.rows[0] + if (!category) return null + + const candidates = [Number(category.id)] + if (category.parent_id) candidates.push(Number(category.parent_id)) + + for (const candidate of candidates) { + const result = await query( + `${PROFILE_SELECT} WHERE p.category_id = $1 AND p.status = 'published' LIMIT 1`, + [candidate], + ) + const row = result.rows[0] + if (!row || !row.current_version_id) continue + return { + profileId: Number(row.id), + categoryId: Number(row.category_id), + versionId: Number(row.current_version_id), + versionNo: Number(row.current_version_no || 1), + config: normalizeConsignmentProfileConfig(row.config_json), + } + } + return null +} + +export async function getPublishedConsignmentProfileById( + profileIdInput: number | string, +): Promise { + const profileId = Number(profileIdInput) + if (!Number.isInteger(profileId) || profileId <= 0) return null + const result = await query( + `${PROFILE_SELECT} WHERE p.id = $1 AND p.status = 'published' LIMIT 1`, + [profileId], + ) + const row = result.rows[0] + if (!row || !row.current_version_id) return null + return { + profileId: Number(row.id), + categoryId: Number(row.category_id), + versionId: Number(row.current_version_id), + versionNo: Number(row.current_version_no || 1), + config: normalizeConsignmentProfileConfig(row.config_json), + } +} + +/** 打手当前在售 + 待审核的寄卖数量(按件数,供单人寄售上限校验)。 */ +export async function countWorkerActiveConsignmentQuantity(workerId: number): Promise { + const result = await query<{ total: number | string }>( + ` + SELECT + COALESCE(( + SELECT SUM(GREATEST(remaining_quantity, 0)) + FROM consignment_inventories + WHERE worker_id = $1 AND status = 'in_stock' + ), 0) + + COALESCE(( + SELECT SUM(GREATEST(quantity, 1)) + FROM consignment_submissions + WHERE worker_id = $1 AND status = 'pending_review' + ), 0) AS total + `, + [workerId], + ) + return Math.max(0, Math.round(Number(result.rows[0]?.total || 0))) +} + +export function buildDepositAmountFen(config: ConsignmentProfileConfig, quantity: number): number { + if (!config.deposit.enabled) return 0 + if (config.deposit.mode === 'per_quantity') { + return Math.max(0, Math.round(config.deposit.unitAmountFen)) * Math.max(1, quantity) + } + return Math.max(0, Math.round(config.deposit.fixedAmountFen)) +} + +export function buildServiceFeeFen( + config: ConsignmentProfileConfig, + grossAmountFen: number, +): number { + if (!config.serviceFee.enabled) return 0 + const gross = Math.max(0, Math.round(grossAmountFen)) + if (config.serviceFee.mode === 'fixed') { + return Math.min(gross, Math.max(0, Math.round(config.serviceFee.amountFen))) + } + return Math.min(gross, Math.round((gross * config.serviceFee.percent) / 100)) +} + +/** 降价提示:未售出超过配置时长时给出建议售价(仅提示,不自动改价)。 */ +export function resolvePriceDropSuggestion( + config: ConsignmentProfileConfig, + input: { listedAt: string | null | undefined; salePriceFen: number; now: string }, +): { + suggestedPriceFen: number + dropHours: number + dropMode: 'percent' | 'fixed' + dropValue: number +} | null { + if (!config.priceDrop.enabled) return null + const listedAtMs = Date.parse(String(input.listedAt || '')) + const nowMs = Date.parse(input.now) + if (!Number.isFinite(listedAtMs) || !Number.isFinite(nowMs)) return null + if (nowMs - listedAtMs < config.priceDrop.hours * 60 * 60 * 1000) return null + const price = Math.max(0, Math.round(input.salePriceFen)) + const suggested = + config.priceDrop.mode === 'fixed' + ? Math.max(0, price - config.priceDrop.value) + : Math.max(0, Math.round((price * (100 - config.priceDrop.value)) / 100)) + if (suggested >= price) return null + return { + suggestedPriceFen: suggested, + dropHours: config.priceDrop.hours, + dropMode: config.priceDrop.mode, + dropValue: config.priceDrop.value, + } +} 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 4944f6d3..bf02d3b2 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -2,11 +2,13 @@ import { withTransaction } from '../../db/client.js' import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js' import { createConsignmentSubmissionWithClient, + getConsignmentInventoryById, getConsignmentInventoryBySubmissionId, getConsignmentSubmissionById, listConsignmentInventoriesByIds, listConsignmentSubmissionsByIds, listWorkerConsignmentPage, + updateConsignmentInventory, type WorkerConsignmentGroup, } from '../../repositories/worker-platform/consignment-repo.js' import { getWorkerUserById } from '../../repositories/worker-platform/index.js' @@ -16,6 +18,13 @@ import type { WorkOrderRow } 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' +import { + buildDepositAmountFen, + countWorkerActiveConsignmentQuantity, + getPublishedConsignmentProfileById, + normalizeConsignmentProfileConfig, + resolvePriceDropSuggestion, +} from './consignment-profile-service.js' import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js' import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js' import type { JsonObject } from '../../types/json.js' @@ -157,6 +166,51 @@ export function getConsignmentPublishDepositFen() { return getConfiguredConsignmentPublishDepositFen() } +/** 打手修改在售寄卖售价(仅限本人、未售出的库存)。 */ +export async function updateWorkerConsignmentSalePrice( + workerId: number, + submissionIdInput: number | string, + priceFenInput: unknown, +) { + const submission = await getConsignmentSubmissionById(submissionIdInput) + if (!submission || Number(submission.worker_id || 0) !== Number(workerId)) { + throw createHttpError('寄卖单不存在', { + statusCode: 404, + errorCode: 'consignment_submission_not_found', + }) + } + if (submission.status !== 'approved' || !submission.result_inventory_id) { + throw createHttpError('该寄卖单尚未入库,不能改价', { + statusCode: 409, + errorCode: 'consignment_not_in_stock', + }) + } + const inventory = await getConsignmentInventoryById(Number(submission.result_inventory_id)) + if (!inventory || Number(inventory.worker_id || 0) !== Number(workerId)) { + throw createHttpError('寄卖库存不存在', { + statusCode: 404, + errorCode: 'consignment_inventory_not_found', + }) + } + if (inventory.status !== 'in_stock') { + throw createHttpError('该寄卖库存已售出或已下架,不能改价', { + statusCode: 409, + errorCode: 'consignment_inventory_unavailable', + }) + } + const priceFen = Math.max(100, Math.round(Number(priceFenInput || 0))) + const updated = await updateConsignmentInventory(inventory.id, { + salePriceFen: priceFen, + now: nowIso(), + }) + return { + submissionId: Number(submission.id), + inventoryId: Number(inventory.id), + salePriceFen: Number(updated?.sale_price_fen || priceFen), + updatedAt: updated?.updated_at || nowIso(), + } +} + export async function publishWorkerConsignment(workerId: number, payload: JsonObject) { const worker = await getWorkerUserById(workerId) if (!worker) { @@ -177,6 +231,29 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb // 对接客服必填:发布时选定,后续按人统计提成 / 方便联系。 const contactSupporter = await resolveEnabledConsignmentContact(payload.contactSupporterId) + // 分类级寄卖配置:新链路必须带 profileId;存量调用不带则沿用旧逻辑。 + const profileIdInput = Number(payload.profileId ?? payload.profile_id ?? 0) + const profile = + Number.isInteger(profileIdInput) && profileIdInput > 0 + ? await getPublishedConsignmentProfileById(profileIdInput) + : null + if (Number.isInteger(profileIdInput) && profileIdInput > 0 && !profile) { + throw createHttpError('寄卖配置不存在或未发布', { + statusCode: 400, + errorCode: 'consignment_profile_unavailable', + }) + } + const quantity = Math.max(1, Math.min(1000, Math.round(Number(payload.quantity || 1)))) + if (profile?.config.inventoryLimit.enabled) { + const activeQuantity = await countWorkerActiveConsignmentQuantity(workerId) + if (activeQuantity + quantity > profile.config.inventoryLimit.maxActiveQuantity) { + throw createHttpError( + `超出单人寄售数量上限(最多 ${profile.config.inventoryLimit.maxActiveQuantity} 件,当前在售/待审核 ${activeQuantity} 件)`, + { statusCode: 409, errorCode: 'consignment_quantity_limit_exceeded' }, + ) + } + } + const system = String(payload.system || '').trim() const serverZone = String(payload.serverZone || '').trim() const series = String(payload.series || '').trim() @@ -261,9 +338,8 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb }) } - // 寄售价以「接单模板」为准:服务端按同一套规则复算,不信任前端传来的金额 - // (卖家端已把该字段改成只读展示,这里再兜一道,避免直连接口随意定价)。 - // 只有该规格在模板里完全没配过价时才回落到调用方传来的值。 + // 寄售价:配置开启「模板价」时以接单模板为准;允许打手改价时以打手填写价为准 + // (不低于配置的最低价)。未命中配置的存量调用保持旧行为。 const priceSuggestion = await getWorkerConsignmentPriceSuggestion({ system, series, @@ -271,10 +347,25 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb brillianceCount, }) const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0))) - const submittedPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0))) - const askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen + const submittedPriceFen = Math.max( + 0, + Math.round(Number(payload.askingPriceFen ?? payload.salePriceFen ?? 0)), + ) + let askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen + if (profile) { + const minPriceFen = Math.max(0, profile.config.pricing.minPriceFen) + if (profile.config.pricing.allowWorkerPrice && submittedPriceFen > 0) { + askingPriceFen = submittedPriceFen + } else if (!profile.config.pricing.useTemplatePrice && submittedPriceFen > 0) { + askingPriceFen = submittedPriceFen + } + askingPriceFen = Math.max(minPriceFen, askingPriceFen) + } + askingPriceFen = Math.max(100, askingPriceFen) - const depositAmountFen = getConsignmentPublishDepositFen() + const depositAmountFen = profile?.config.deposit.enabled + ? buildDepositAmountFen(profile.config, quantity) + : getConsignmentPublishDepositFen() const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim() const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : '' // 区服是「互通」(通配)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭 @@ -289,7 +380,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb const created = await createConsignmentSubmissionWithClient(client, { submissionNo, gameName: '和平精英', - categoryId: null, + categoryId: profile?.categoryId ?? null, itemName, system, serverZone, @@ -306,9 +397,26 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb sellerNote: String(payload.sellerNote || '').trim(), channel: 'worker_portal', publishDepositFen: depositAmountFen, + profileId: profile?.profileId ?? null, + profileVersionId: profile?.versionId ?? null, + quantity, + salePriceFen: askingPriceFen, }, screenshotsJson: screenshots, askingPriceFen, + requestedSalePriceFen: askingPriceFen, + quantity, + profileId: profile?.profileId ?? null, + profileVersionId: profile?.versionId ?? null, + profileSnapshotJson: profile + ? { + profileId: profile.profileId, + categoryId: profile.categoryId, + versionId: profile.versionId, + versionNo: profile.versionNo, + config: profile.config, + } + : null, workerId: Number(worker.id), workerName: worker.display_name || worker.username, workerPhone: sellerPhone || worker.phone || '', @@ -341,6 +449,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb diamondLevel: submission.diamond_level, brillianceCount: submission.brilliance_count, askingPriceFen: submission.asking_price_fen, + salePriceFen: Number(submission.requested_sale_price_fen || submission.asking_price_fen || 0), + quantity: Number(submission.quantity || 1), + profileId: submission.profile_id ? Number(submission.profile_id) : null, + profileVersionId: submission.profile_version_id + ? Number(submission.profile_version_id) + : null, status: submission.status, workerId: submission.worker_id, workerName: submission.worker_name, @@ -473,6 +587,20 @@ export async function listWorkerConsignments( diamondLevel: sub.diamond_level, brillianceCount: sub.brilliance_count, askingPriceFen: sub.asking_price_fen, + salePriceFen: Number( + inv?.sale_price_fen ?? sub.requested_sale_price_fen ?? sub.asking_price_fen ?? 0, + ), + quantity: Math.max(1, Number(sub.quantity || inv?.quantity || 1)), + remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0, + listedAt: inv?.listed_at || null, + canEditPrice: inv?.status === 'in_stock', + priceDropSuggestion: inv?.profile_snapshot_json + ? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), { + listedAt: inv.listed_at || inv.inbound_at, + salePriceFen: Number(inv.sale_price_fen || 0), + now: nowIso(), + }) + : null, depositAmountFen: sub.deposit_amount_fen, ...mapConsignmentContactSupporter(contactSupporter), reviewNote: sub.review_note, @@ -518,6 +646,18 @@ export async function listWorkerConsignments( diamondLevel: inv.diamond_level, brillianceCount: inv.brilliance_count, askingPriceFen: inv.cost_price_fen, + salePriceFen: Number(inv.sale_price_fen || inv.cost_price_fen || 0), + quantity: Math.max(1, Number(inv.quantity || 1)), + remainingQuantity: Math.max(0, Number(inv.remaining_quantity ?? 0)), + listedAt: inv.listed_at || inv.inbound_at || null, + canEditPrice: inv.status === 'in_stock', + priceDropSuggestion: inv.profile_snapshot_json + ? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), { + listedAt: inv.listed_at || inv.inbound_at, + salePriceFen: Number(inv.sale_price_fen || 0), + now: nowIso(), + }) + : null, depositAmountFen: inv.deposit_amount_fen, ...mapConsignmentContactSupporter(inv), reviewNote: '', diff --git a/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts b/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts index 722072ba..6a04202c 100644 --- a/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-auto-publish-service.ts @@ -8,6 +8,7 @@ import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { publishAdminWorkOrder } from './admin-work-order-management-service.js' +import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js' type Queryable = Pick @@ -141,6 +142,13 @@ async function processAutoPublishJob( return 'failed' } + // 分类命中寄卖配置:先进入内部匹配,不发大厅;超时由调度任务发回大厅。 + const enteredInternalMatching = await enterConsignmentInternalMatching(workOrderId) + if (enteredInternalMatching) { + await finishJob(job, 'skipped', '已进入寄卖内部匹配', { internalMatching: true }) + return 'skipped' + } + const snapshot = safeParseJson(order.service_profile_snapshot_json) const autoPublish = snapshot.autoPublish && typeof snapshot.autoPublish === 'object' diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts index e18f2e2f..f34f09b1 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -17,6 +17,7 @@ import { nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { enterConsignmentInternalMatching } from './consignment-match-engine-service.js' import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input-normalizers.js' import { ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, @@ -386,6 +387,9 @@ export async function submitOrderServiceMaterial( } publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) }) + // 命中分类寄卖配置时先进入内部匹配,不发大厅;超时由调度任务发回大厅。 + await enterConsignmentInternalMatching(Number(result.order.id)).catch(() => false) + if (result.autoPublishEnabled) { void processDueOrderServiceAutoPublishJobs(1).catch(() => undefined) } diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts index dae08d52..38da5c7e 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -5,6 +5,7 @@ import { createHttpError } from '../../utils/http.js' const PUBLIC_MATERIAL_STATUS_LABELS: Record = { [WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善', [WORK_ORDER_STATUS.UNASSIGNED]: '待分配', + [WORK_ORDER_STATUS.INTERNAL_MATCHING]: '内部匹配中', [WORK_ORDER_STATUS.OPEN]: '待抢单', [WORK_ORDER_STATUS.IN_PROGRESS]: '代练中', [WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '待验收', diff --git a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts index 784c0b8c..bdeb6380 100644 --- a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts +++ b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts @@ -11,6 +11,7 @@ import { publishWorkOrderRealtimeChange, } from '../realtime/realtime-event-service.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' +import { reenterConsignmentInternalMatching } from './consignment-match-engine-service.js' export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) { const limit = Math.max(1, Number(options.limit || 50)) @@ -20,13 +21,18 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI let processedCount = 0 let skippedCount = 0 for (const workOrder of overdue) { + const policy = normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy) const result = await settleOverdueWorkOrder({ workOrderId: workOrder.id, - policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy), + policy, now: nowIso(), }) if (result.order) { processedCount += 1 + // 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。 + if (policy === 'reopen') { + await reenterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false) + } publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds: Number(workOrder.assigned_worker_id) diff --git a/apps/backend/src/startup/bootstrap.ts b/apps/backend/src/startup/bootstrap.ts index 9623ce37..fc1192d9 100644 --- a/apps/backend/src/startup/bootstrap.ts +++ b/apps/backend/src/startup/bootstrap.ts @@ -4,6 +4,7 @@ import { ensureFulfillmentCatalogBootstrapped } from '../services/bootstrap/fulf import { migrateJsonConfigFilesToDatabase } from '../services/config/json-config-migration-service.js' import { startKuaishouIndustrySendCallbackRetryWorker } from '../services/platforms/kuaishou-industry/send-code-service.js' import { startScheduledJobs } from '../services/scheduler/scheduler-service.js' +import { startConsignmentMatchScheduler } from '../services/worker-platform/consignment-match-engine-service.js' import { logError, logInfo } from '../utils/logger.js' import { startMemoryReporter } from './memory-reporter.js' import { formatStartupError, type StartupState } from './state.js' @@ -46,6 +47,7 @@ export async function bootstrapCoreServices( startScheduledJobs() startKuaishouIndustrySendCallbackRetryWorker() + startConsignmentMatchScheduler() startMemoryReporter() break } catch (error) { diff --git a/apps/backend/src/startup/shutdown.ts b/apps/backend/src/startup/shutdown.ts index 3755a73e..8b2f7526 100644 --- a/apps/backend/src/startup/shutdown.ts +++ b/apps/backend/src/startup/shutdown.ts @@ -2,6 +2,7 @@ import type { Server } from 'node:http' import { stopKuaishouIndustrySendCallbackRetryWorker } from '../services/platforms/kuaishou-industry/send-code-service.js' import { stopScheduledJobs } from '../services/scheduler/scheduler-service.js' +import { stopConsignmentMatchScheduler } from '../services/worker-platform/consignment-match-engine-service.js' import { closeDb } from '../db/client.js' import { logError, logInfo } from '../utils/logger.js' import type { StartupState } from './state.js' @@ -28,6 +29,7 @@ export function createShutdownController( stopScheduledJobs() stopKuaishouIndustrySendCallbackRetryWorker() + stopConsignmentMatchScheduler() if (server.listening) { await new Promise((resolve) => { diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index aa6f3ca1..5b5991fd 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -22,6 +22,7 @@ type EventNodeStyle = { const STATUS_LABELS: Record = { pending_material: '待完善资料', unassigned: '待分配', + internal_matching: '内部匹配中', open: '待抢单', in_progress: '代练中', pending_acceptance: '待验收', diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx new file mode 100644 index 00000000..b41d9fc1 --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -0,0 +1,161 @@ +import { App, Alert, Button, Card, Empty, Modal, Space, Tag, Typography } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' + +import PercentTable from '@/components/common/PercentTable' +import { + fetchAdminConsignmentMatchCandidates, + matchAdminConsignmentWorkOrder, +} from '@/services/admin/consignment' +import type { ConsignmentMatchCandidate } from '@/types/consignment' + +function money(fen: number) { + return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}` +} + +export default function ConsignmentWorkOrderMatchModal({ + open, + workOrderId, + onClose, + onMatched, +}: { + open: boolean + workOrderId: number | null + onClose: () => void + onMatched?: () => void +}) { + const { message } = App.useApp() + const [selectedIds, setSelectedIds] = useState([]) + const [submitting, setSubmitting] = useState(false) + + const candidatesQuery = useQuery({ + queryKey: ['admin-consignment-match-candidates', workOrderId], + queryFn: () => fetchAdminConsignmentMatchCandidates(Number(workOrderId)), + enabled: open && Boolean(workOrderId), + }) + const data = candidatesQuery.data?.data + const options = data?.options || [] + const needed = Math.max(1, Number(data?.totalQuantity || 1) - Number(data?.activeQuantity || 0)) + + useEffect(() => { + if (!open) { + setSelectedIds([]) + return + } + if (options.length === 0) return + // 默认按缺口选中价格最低的几条,客服可自行调整。 + setSelectedIds(options.slice(0, needed).map((item) => item.inventoryId)) + }, [open, options, needed]) + + const totalPrice = useMemo( + () => + selectedIds.reduce((sum, id) => { + const item = options.find((option) => option.inventoryId === id) + return sum + Number(item?.salePriceFen || 0) + }, 0), + [options, selectedIds], + ) + + return ( + + + + + } + > + {candidatesQuery.isLoading ? : null} + {!candidatesQuery.isLoading && options.length === 0 ? ( + + ) : null} + {options.length > 0 ? ( + + + + rowKey="inventoryId" + size="small" + pagination={false} + dataSource={options} + rowSelection={{ + selectedRowKeys: selectedIds, + onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))), + }} + columns={[ + { + title: '寄卖库存', + render: (_, item) => ( + + {item.itemName} + + {item.inventoryNo} · {item.workerName || `打手#${item.workerId || '-'}`} + + + ), + }, + { + title: '规格', + render: (_, item) => + `${item.system}/${item.serverZone}/${item.series}/${item.diamondLevel}钻/${item.brillianceCount}璀`, + }, + { + title: '库存', + width: 90, + render: (_, item) => ( + 1 ? 'blue' : 'default'}> + 剩 {item.remainingQuantity}/{item.quantity} + + ), + }, + { + title: '售价', + width: 110, + render: (_, item) => money(item.salePriceFen), + }, + ]} + /> + + 已选 {selectedIds.length} 条,合计成本 {money(totalPrice)} + + + ) : null} + + ) +} diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 7371e932..f378a960 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -16,6 +16,7 @@ import AnnouncementPanel from './panels/AnnouncementPanel' import CancelRequestsPanel from './panels/CancelRequestsPanel' import CategoriesPanel from './panels/CategoriesPanel' import ConsignmentContactsPanel from './panels/ConsignmentContactsPanel' +import ConsignmentConfigPanel from './panels/ConsignmentConfigPanel' import ConsignmentInventoryPanel from './panels/ConsignmentInventoryPanel' import ConsignmentSubmissionsPanel from './panels/ConsignmentSubmissionsPanel' import FeedbacksPanel from './panels/FeedbacksPanel' @@ -185,6 +186,11 @@ export default function AdminWorkerPlatformPage() { : []), { key: 'rules', label: '接单模板', children: }, { key: 'order-service', label: '订单服务', children: }, + { + key: 'consignment-config', + label: '寄卖配置', + children: , + }, { key: 'product-match', label: '匹配诊断', children: }, { key: 'categories', label: '分类', children: }, { key: 'workers', label: '打手', children: }, diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx new file mode 100644 index 00000000..2c0ea2e1 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -0,0 +1,568 @@ +import { ReloadOutlined, SaveOutlined, SendOutlined, StopOutlined } from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Divider, + Empty, + Form, + Input, + InputNumber, + Select, + Space, + Switch, + Tag, + Typography, +} from 'antd' +import { useEffect, useMemo, useState } from 'react' + +import { + archiveAdminConsignmentProfile, + fetchAdminConsignmentProfiles, + publishAdminConsignmentProfile, + saveAdminConsignmentProfile, +} from '@/services/admin/consignment' +import type { ConsignmentProfile } from '@/types/consignment' + +const { Text, Title } = Typography + +type ConfigFormValues = { + name: string + title: string + description: string + tabTitle: string + fields: { + system: boolean + serverZone: boolean + series: boolean + diamondLevel: boolean + brillianceCount: boolean + quantity: boolean + } + pricing: { useTemplatePrice: boolean; allowWorkerPrice: boolean; minPriceYuan: number } + deposit: { + enabled: boolean + mode: 'fixed' | 'per_quantity' + fixedAmountYuan: number + unitAmountYuan: number + } + serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number } + inventoryLimit: { enabled: boolean; maxActiveQuantity: number } + matching: { + mode: 'manual' | 'auto' + autoMatchEnabled: boolean + exactOnly: boolean + preferLowReward: boolean + } + internalMatch: { delayMinutes: number; fallbackToHall: boolean } + timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' } + priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number } +} + +function yuan(fen: number | undefined): number { + return Number(((Number(fen || 0) || 0) / 100).toFixed(2)) +} + +function fen(value: number | undefined): number { + return Math.max(0, Math.round(Number(value || 0) * 100)) +} + +function toFormValues(profile: ConsignmentProfile): ConfigFormValues { + const config = profile.config + return { + name: profile.name, + title: profile.title, + description: profile.description, + tabTitle: profile.tabTitle, + fields: { ...config.fields }, + pricing: { + useTemplatePrice: config.pricing.useTemplatePrice, + allowWorkerPrice: config.pricing.allowWorkerPrice, + minPriceYuan: yuan(config.pricing.minPriceFen), + }, + deposit: { + enabled: config.deposit.enabled, + mode: config.deposit.mode, + fixedAmountYuan: yuan(config.deposit.fixedAmountFen), + unitAmountYuan: yuan(config.deposit.unitAmountFen), + }, + serviceFee: { + enabled: config.serviceFee.enabled, + mode: config.serviceFee.mode, + percent: config.serviceFee.percent, + amountYuan: yuan(config.serviceFee.amountFen), + }, + inventoryLimit: { ...config.inventoryLimit }, + matching: { ...config.matching }, + internalMatch: { ...config.internalMatch }, + timeout: { ...config.timeout }, + priceDrop: { ...config.priceDrop }, + } +} + +function emptyFormValues(): ConfigFormValues { + return { + name: '寄卖配置', + title: '', + description: '', + tabTitle: '', + fields: { + system: true, + serverZone: true, + series: true, + diamondLevel: true, + brillianceCount: true, + quantity: true, + }, + pricing: { useTemplatePrice: true, allowWorkerPrice: false, minPriceYuan: 0 }, + deposit: { enabled: false, mode: 'fixed', fixedAmountYuan: 0, unitAmountYuan: 0 }, + serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 }, + inventoryLimit: { enabled: false, maxActiveQuantity: 0 }, + matching: { + mode: 'manual', + autoMatchEnabled: false, + exactOnly: true, + preferLowReward: true, + }, + internalMatch: { delayMinutes: 30, fallbackToHall: true }, + timeout: { minutes: 30, policy: 'reopen' }, + priceDrop: { enabled: false, hours: 72, mode: 'percent', value: 10 }, + } +} + +function buildPayload(categoryId: number, values: ConfigFormValues) { + return { + categoryId, + name: values.name, + title: values.title, + description: values.description, + tabTitle: values.tabTitle, + config: { + version: 1, + tabTitle: values.tabTitle, + tabDescription: values.description, + fields: values.fields, + pricing: { + useTemplatePrice: values.pricing.useTemplatePrice, + allowWorkerPrice: values.pricing.allowWorkerPrice, + minPriceFen: fen(values.pricing.minPriceYuan), + }, + deposit: { + enabled: values.deposit.enabled, + mode: values.deposit.mode, + fixedAmountFen: fen(values.deposit.fixedAmountYuan), + unitAmountFen: fen(values.deposit.unitAmountYuan), + }, + serviceFee: { + enabled: values.serviceFee.enabled, + mode: values.serviceFee.mode, + percent: values.serviceFee.percent, + amountFen: fen(values.serviceFee.amountYuan), + }, + inventoryLimit: values.inventoryLimit, + matching: values.matching, + internalMatch: values.internalMatch, + timeout: values.timeout, + priceDrop: values.priceDrop, + }, + } +} + +export default function ConsignmentConfigPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const [saving, setSaving] = useState(false) + const [form] = Form.useForm() + + const profilesQuery = useQuery({ + queryKey: ['admin-consignment-profiles'], + queryFn: () => fetchAdminConsignmentProfiles(), + }) + const data = profilesQuery.data?.data + const categories = useMemo( + () => (data?.categories || []).filter((category) => category.status === 'active'), + [data?.categories], + ) + const profiles = data?.profiles || [] + const profileByCategoryId = useMemo( + () => new Map(profiles.map((profile) => [profile.categoryId, profile])), + [profiles], + ) + const selectedCategory = categories.find((category) => category.categoryId === selectedCategoryId) + const selectedProfile = selectedCategoryId + ? profileByCategoryId.get(selectedCategoryId) + : undefined + + useEffect(() => { + if (selectedCategoryId === null && categories.length > 0) { + setSelectedCategoryId(categories[0]!.categoryId) + } + }, [categories, selectedCategoryId]) + + useEffect(() => { + if (!selectedCategoryId) return + if (selectedProfile) { + form.setFieldsValue(toFormValues(selectedProfile)) + } else { + form.setFieldsValue({ + ...emptyFormValues(), + name: selectedCategory?.name ? `${selectedCategory.name}寄卖` : '寄卖配置', + tabTitle: selectedCategory?.name || '', + }) + } + }, [selectedCategoryId, selectedProfile, selectedCategory?.name, form]) + + async function refresh() { + await queryClient.invalidateQueries({ queryKey: ['admin-consignment-profiles'] }) + } + + async function handleSave() { + if (!selectedCategoryId) return + const values = await form.validateFields() + setSaving(true) + try { + await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values)) + message.success('寄卖配置已保存') + await refresh() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } finally { + setSaving(false) + } + } + + const publishMutation = useMutation({ + mutationFn: (profileId: number) => publishAdminConsignmentProfile(profileId), + onSuccess: async () => { + message.success('已发布新版本,打手端将按新配置展示') + await refresh() + }, + onError: (error) => message.error(error instanceof Error ? error.message : '发布失败'), + }) + const archiveMutation = useMutation({ + mutationFn: (profileId: number) => archiveAdminConsignmentProfile(profileId), + onSuccess: async () => { + message.success('寄卖配置已归档') + await refresh() + }, + onError: (error) => message.error(error instanceof Error ? error.message : '归档失败'), + }) + + return ( + + 寄卖配置 + {profiles.filter((p) => p.status === 'published').length} 个已发布 + + } + extra={ + + } + > +
+ + +
+ {selectedCategory ? ( + <> +
+
+ + {selectedCategory.name} + + {selectedProfile?.status === 'published' + ? `线上 v${selectedProfile.currentVersionNo || 1}` + : selectedProfile?.status === 'archived' + ? '已归档' + : '草稿'} + + + 分类标识:{selectedCategory.categoryKey} +
+ + + + {selectedProfile ? ( + + ) : null} + +
+ +
+ 基础与打手端展示 + + + + + + + + + + + 发布字段 + + + + + + + + + + + + + + + + + + + + + + 价格 / 保证金 / 服务费 + + + + + + + + + + + + + + + + + + + + + + + + + + + 数量限制 / 匹配 / 超时 / 降价 + + + + + + + + + + + + + + + + + + + + + +
+ + + + 联系方式与游戏凭据 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + 提交后进入客服审核;押金在驳回或履约完成后释放 + +
+ + ) + + const publishTabs = + consignmentProfiles.length > 0 + ? consignmentProfiles.map((profile) => ({ + key: `publish-${profile.profileId}`, + label: ( + + {profile.tabTitle || '发布寄卖'} + + ), + children: publishTabContent, + })) + : [ + { + key: 'publish', + label: ( + + 发布寄卖 + + ), + children: publishTabContent, + }, + ] + return (
@@ -635,324 +1018,26 @@ export default function WorkerConsignmentPage() { setActiveTab(key as 'publish' | 'records')} + activeKey={ + activeTab === 'records' + ? 'records' + : selectedProfile + ? `publish-${selectedProfile.profileId}` + : 'publish' + } + onChange={(key) => { + if (key === 'records') { + setActiveTab('records') + return + } + const profileId = Number(String(key).replace('publish-', '')) + if (Number.isInteger(profileId) && profileId > 0) { + setSelectedProfileId(profileId) + } + setActiveTab('publish') + }} items={[ - { - key: 'publish', - label: ( - - 发布寄卖 - - ), - children: profileQuery.isLoading ? ( -
- -
- ) : !canConsign ? ( -
- - - 寄卖功能暂未开通 - - - 平台当前开启寄卖白名单。请联系客服申请开通,由后台运营开通后即可发布苏尔南寄卖。 - - -
- } - /> -
- ) : ( -
- } - className="worker-consignment-tip" - message={ - canPublish - ? `履约押金 ¥${publishDepositYuan.toFixed(0)}:发布成功后暂扣,驳回或履约完成后释放。当前可用余额 ¥${(availableAmount / 100).toFixed(2)}` - : `可用余额不足 ¥${publishDepositYuan.toFixed(0)}。请先联系客服充值履约押金后,才能发布寄售。当前可用 ¥${(availableAmount / 100).toFixed(2)}` - } - /> - - {!consignmentContactsQuery.isLoading && contactSupporters.length === 0 ? ( - - ) : null} - - - 规格与价格 - - -
-
- - - - - - - - - - - ({ - label: `${o.value}钻`, - value: o.value, - }))} - /> - - ({ - validator(_, value) { - const dia = getFieldValue('diamondLevel') - if (value != null && dia != null && Number(value) > Number(dia)) { - return Promise.reject( - new Error(`璀璨数不能超过钻石等级(当前${dia}钻最多选${dia}璀)`), - ) - } - return Promise.resolve() - }, - }), - ]} - initialValue={0} - className="worker-consignment-spec-card is-count" - > - ({ - label: o.value === 0 ? '无' : `${o.value}璀`, - value: o.value, - disabled: - currentDiamondLevel != null && o.value > Number(currentDiamondLevel), - }))} - /> - -
- -
- -
- {priceState.status === 'loading' ? ( - - 匹配模板价… - - ) : priceState.status === 'matched' ? ( - ¥{(priceState.fen / 100).toFixed(0)} - ) : priceState.status === 'empty' ? ( - 该规格暂无模板价,请联系客服 - ) : ( - 选完规格后自动带出 - )} -
-
- {/* 价格只读展示,但仍要随表单提交给后端(后端也会按模板复算) */} - - - - - -
-
- - - 联系方式与游戏凭据 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - 提交后进入客服审核;押金在驳回或履约完成后释放 - -
- - ), - }, + ...publishTabs, { key: 'records', label: ( @@ -1361,6 +1446,11 @@ function ConsignmentRecordCard({ onConfirmFriend: (record: WorkerConsignmentRecord) => void onSubmitEvidence: (record: WorkerConsignmentRecord) => void }) { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [priceOpen, setPriceOpen] = useState(false) + const [priceYuan, setPriceYuan] = useState(0) + const [savingPrice, setSavingPrice] = useState(false) const shots = Array.isArray(record.screenshotsJson) ? record.screenshotsJson.filter((s): s is string => typeof s === 'string') : [] @@ -1393,8 +1483,17 @@ function ConsignmentRecordCard({ />
- 寄售价 ¥{(record.askingPriceFen / 100).toFixed(2)} + 寄售价{' '} + ¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)} + {Number(record.quantity || 1) > 1 ? ( + + 数量{' '} + + {record.remainingQuantity ?? record.quantity} / {record.quantity} 件 + + + ) : null} 押金 ¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)} @@ -1402,7 +1501,28 @@ function ConsignmentRecordCard({ 对接客服 {formatConsignmentContactSupporter(record).text} {record.workOrderId ? 履约单 #{record.workOrderId} : null} + {record.canEditPrice && record.submissionId ? ( + + ) : null}
+ {record.priceDropSuggestion ? ( + + ) : null} {record.reviewNote ? ( 审核说明:{record.reviewNote} ) : null} @@ -1549,6 +1669,39 @@ function ConsignmentRecordCard({ {formatDateTime(record.createdAt)} + setPriceOpen(false)} + confirmLoading={savingPrice} + okText="保存" + onOk={async () => { + if (!record.submissionId) return + setSavingPrice(true) + try { + await updateWorkerConsignmentPrice( + record.submissionId, + Math.max(100, Math.round(Number(priceYuan || 0) * 100)), + ) + message.success('寄卖售价已更新') + setPriceOpen(false) + await queryClient.invalidateQueries({ queryKey: ['worker-my-consignments'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '修改售价失败') + } finally { + setSavingPrice(false) + } + }} + > + setPriceYuan(Number(value || 0))} + style={{ width: '100%' }} + /> + ) } diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index cb676d25..8ac01866 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -1,7 +1,10 @@ import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from '@/lib/http' import type { + AdminConsignmentProfilesResponse, ConsignmentContactSupporter, ConsignmentInventory, + ConsignmentMatchCandidatesResponse, + ConsignmentProfile, ConsignmentStatsSummary, ConsignmentSubmission, InventoryMatchableOrders, @@ -10,6 +13,54 @@ import type { WorkOrderInventoryMatchOptions, } from '@/types/consignment' +// --- 分类级寄卖配置 --- + +export function fetchAdminConsignmentProfiles() { + return apiGet( + '/api/v1/admin/worker-platform/consignment/profiles', + ) +} + +export function saveAdminConsignmentProfile(payload: Record) { + return apiPost<{ profile: ConsignmentProfile }>( + '/api/v1/admin/worker-platform/consignment/profiles', + payload, + ) +} + +export function publishAdminConsignmentProfile(profileId: number | string) { + return apiPost<{ profile: ConsignmentProfile }>( + `/api/v1/admin/worker-platform/consignment/profiles/${profileId}/publish`, + {}, + ) +} + +export function archiveAdminConsignmentProfile(profileId: number | string) { + return apiPost<{ profile: ConsignmentProfile }>( + `/api/v1/admin/worker-platform/consignment/profiles/${profileId}/archive`, + {}, + ) +} + +// --- 内部匹配(工单侧手动匹配) --- + +export function fetchAdminConsignmentMatchCandidates(workOrderId: number | string) { + return apiGet( + '/api/v1/admin/worker-platform/consignment/match-candidates', + { workOrderId }, + ) +} + +export function matchAdminConsignmentWorkOrder(payload: { + workOrderId: number + inventoryIds: number[] +}) { + return apiPost<{ matched: boolean; matchedCount: number }>( + '/api/v1/admin/worker-platform/consignment/match', + payload, + ) +} + // --- 对接客服名单 --- export function fetchAdminConsignmentContacts(params?: { keyword?: string; enabled?: string }) { diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 47093005..0c094217 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -1,4 +1,4 @@ -import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from '@/lib/http' +import { apiDelete, apiGet, apiPatch, apiPost, apiPostForm, apiPut } from '@/lib/http' import { optimizeImageForUpload } from '@/utils/image-upload' import type { UploadedFile, @@ -31,6 +31,7 @@ import type { ConsignmentBuyerInfo, ConsignmentContactSupporter, ConsignmentSellerInfo, + WorkerConsignmentProfile, } from '@/types/consignment' import { getWorkerDeviceId } from '@/utils/worker-auth' @@ -373,6 +374,9 @@ export function supplementWorkerAcceptedOrderEvidence( // ---------------- 打手端:我要寄卖与手枪库存 ---------------- export type WorkerConsignmentPublishPayload = { + /** 分类级寄卖配置 id(打手端 tab 对应) */ + profileId?: number + quantity?: number itemName?: string system: string serverZone: string @@ -448,6 +452,17 @@ export type WorkerConsignmentRecord = { diamondLevel: number brillianceCount: number askingPriceFen: number + salePriceFen?: number + quantity?: number + remainingQuantity?: number + listedAt?: string | null + canEditPrice?: boolean + priceDropSuggestion?: { + suggestedPriceFen: number + dropHours: number + dropMode: 'percent' | 'fixed' + dropValue: number + } | null depositAmountFen: number contactSupporterId?: number | null contactSupporterName?: string @@ -508,6 +523,21 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa return apiPost('/api/v1/worker/consignment/publish', payload) } +/** 打手端寄卖页:已发布配置 → 动态 tab。 */ +export function fetchWorkerConsignmentProfiles() { + return apiGet<{ profiles: WorkerConsignmentProfile[] }>('/api/v1/worker/consignment/profiles') +} + +/** 打手修改在售寄卖售价(分)。 */ +export function updateWorkerConsignmentPrice(submissionId: number | string, salePriceFen: number) { + return apiPatch<{ + submissionId: number + inventoryId: number + salePriceFen: number + updatedAt: string + }>(`/api/v1/worker/consignment/items/${submissionId}/price`, { salePriceFen }) +} + /** 发布寄卖时的「对接客服」下拉(仅启用中的名单)。 */ export function fetchWorkerConsignmentContacts() { return apiGet<{ items: ConsignmentContactSupporter[] }>('/api/v1/worker/consignment/contacts') diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 19c04b2d..59648dae 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -88,6 +88,23 @@ export type ConsignmentInventory = ConsignmentEvidenceFields & sourceSubmissionId: number | null /** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */ sourceSubmissionNo?: string + /** 分类级寄卖配置 */ + profileId?: number | null + profileVersionId?: number | null + /** 批次总数量 / 剩余可匹配数量 */ + quantity?: number + remainingQuantity?: number + /** 寄卖售价(分) */ + salePriceFen?: number + listedAt?: string | null + priceUpdatedAt?: string | null + canEditPrice?: boolean + priceDropSuggestion?: { + suggestedPriceFen: number + dropHours: number + dropMode: 'percent' | 'fixed' + dropValue: number + } | null workerId?: number | null workerName?: string workerPhone?: string @@ -160,6 +177,11 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields & contentJson: Record screenshotsJson: string[] askingPriceFen: number + /** 分类级寄卖配置与数量/售价 */ + profileId?: number | null + profileVersionId?: number | null + quantity?: number + requestedSalePriceFen?: number workerId?: number | null workerName?: string workerPhone?: string @@ -269,3 +291,109 @@ export type PendingPistolMatchResult = { truncated: boolean pairs: PendingPistolMatchPair[] } + +// --------------------------------------------------------------------------- +// 分类级寄卖配置(管理端「寄卖配置」tab / 打手端动态 tab) +// --------------------------------------------------------------------------- +export type ConsignmentProfileConfig = { + version: 1 + tabTitle: string + tabDescription: string + fields: { + system: boolean + serverZone: boolean + series: boolean + diamondLevel: boolean + brillianceCount: boolean + quantity: boolean + } + pricing: { useTemplatePrice: boolean; allowWorkerPrice: boolean; minPriceFen: number } + deposit: { + enabled: boolean + mode: 'fixed' | 'per_quantity' + fixedAmountFen: number + unitAmountFen: number + } + serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountFen: number } + inventoryLimit: { enabled: boolean; maxActiveQuantity: number } + matching: { + mode: 'manual' | 'auto' + autoMatchEnabled: boolean + exactOnly: boolean + preferLowReward: boolean + } + internalMatch: { delayMinutes: number; fallbackToHall: boolean } + timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' } + priceDrop: { + enabled: boolean + hours: number + mode: 'percent' | 'fixed' + value: number + } +} + +export type ConsignmentProfile = { + profileId: number + categoryId: number + categoryName?: string + categoryParentId?: number | null + name: string + title: string + description: string + tabTitle: string + status: 'draft' | 'published' | 'archived' + currentVersionId: number | null + currentVersionNo: number | null + config: ConsignmentProfileConfig + createdAt?: string + updatedAt?: string +} + +export type AdminConsignmentProfilesResponse = { + categories: Array<{ + categoryId: number + categoryKey: string + name: string + parentId: number | null + status: string + }> + profiles: ConsignmentProfile[] +} + +export type WorkerConsignmentProfile = { + profileId: number + categoryId: number + categoryName: string + name: string + title: string + description: string + tabTitle: string + tabDescription: string + config: ConsignmentProfileConfig +} + +export type ConsignmentMatchCandidate = { + inventoryId: number + inventoryNo: string + itemName: string + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + quantity: number + remainingQuantity: number + salePriceFen: number + listedAt: string | null + workerId: number | null + workerName: string +} + +export type ConsignmentMatchCandidatesResponse = { + workOrderId: number + profileId: number | null + sharingEnabled: boolean + totalQuantity: number + activeQuantity: number + options: ConsignmentMatchCandidate[] +} diff --git a/apps/frontend/src/utils/admin-worker-platform-tabs.ts b/apps/frontend/src/utils/admin-worker-platform-tabs.ts index 35febb87..3374f63e 100644 --- a/apps/frontend/src/utils/admin-worker-platform-tabs.ts +++ b/apps/frontend/src/utils/admin-worker-platform-tabs.ts @@ -23,6 +23,7 @@ export const WORKER_PLATFORM_TAB_KEYS: readonly string[] = [ 'announcement', 'rules', 'order-service', + 'consignment-config', 'product-match', 'categories', 'workers', @@ -55,6 +56,7 @@ export const WORKER_PLATFORM_TAB_QUERY_KEYS: Record = { ['admin-order-service-auto-publish-jobs'], ['admin-categories'], ], + 'consignment-config': [['admin-consignment-profiles'], ['admin-categories']], 'product-match': [['admin-product-match']], categories: [['admin-categories']], workers: [['admin-workers'], ['admin-worker-users']], diff --git a/apps/frontend/tests/consignment-matching-config.test.ts b/apps/frontend/tests/consignment-matching-config.test.ts new file mode 100644 index 00000000..aa1df7c3 --- /dev/null +++ b/apps/frontend/tests/consignment-matching-config.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +function read(relativePath: string) { + return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8') +} + +test('管理端:订单服务之后必须存在寄卖配置 tab', () => { + const tabs = read('../src/utils/admin-worker-platform-tabs.ts') + assert.ok(tabs.includes("'consignment-config'"), '合法 tab 名单必须包含 consignment-config') + assert.ok( + tabs.includes("'consignment-config': [['admin-consignment-profiles']"), + '切到寄卖配置必须刷新配置查询', + ) + + const page = read('../src/pages/admin/AdminWorkerPlatformPage.tsx') + assert.ok(page.includes('ConsignmentConfigPanel'), '页面必须挂载寄卖配置面板') + const orderServiceIndex = page.indexOf("label: '订单服务'") + const consignmentIndex = page.indexOf("label: '寄卖配置'") + assert.ok( + orderServiceIndex > 0 && consignmentIndex > orderServiceIndex, + '寄卖配置必须在订单服务之后', + ) +}) + +test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/内部匹配/超时/降价', () => { + const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx') + for (const label of [ + '打手端 tab 名称', + '开启保证金', + '按数量', + '开启服务费', + '开启单人寄售上限', + '匹配模式', + '自动匹配开关', + '内部匹配等待(分钟)', + '代练超时(分钟)', + '开启降价提示', + ]) { + assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`) + } +}) + +test('打手端:寄卖页必须按已发布配置动态生成 tab,并支持改价', () => { + const page = read('../src/pages/worker/WorkerConsignmentPage.tsx') + assert.ok(page.includes('fetchWorkerConsignmentProfiles'), '必须拉取已发布寄卖配置') + assert.ok(page.includes('consignmentProfiles.map'), '必须按配置生成 tab') + assert.ok(page.includes('修改售价'), '在售寄卖必须提供改价入口') + assert.ok(page.includes('updateWorkerConsignmentPrice'), '改价必须调用专用接口') + assert.ok(page.includes('priceDropSuggestion'), '必须展示降价建议') + assert.ok(page.includes('寄售数量'), '发布表单必须支持数量') +}) + +test('工单页:必须挂载内部库存匹配弹窗,并支持状态 internal_matching', () => { + const panel = read('../src/pages/admin/panels/WorkOrdersPanel.tsx') + assert.ok(panel.includes('ConsignmentWorkOrderMatchModal'), '工单页必须挂载内部匹配弹窗') + assert.ok(panel.includes("'internal_matching'"), '状态筛选必须支持内部匹配中') + + const shared = read('../src/pages/admin/panels/shared.tsx') + assert.ok(shared.includes("internal_matching: '内部匹配中'"), '状态文案必须包含内部匹配中') + assert.ok(shared.includes("internal_matching: 'magenta'"), '状态配色必须包含内部匹配中') +})