feat(consignment): 寄卖配置、内部匹配与打手端升级

This commit is contained in:
2026-11-05 15:39:40 +08:00
parent f94b914ab1
commit 2ef16ddcc7
34 changed files with 3705 additions and 350 deletions
@@ -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';
@@ -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',
@@ -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<ConsignmentAllocationRow | null> {
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<ConsignmentAllocationRow>(
`
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<void> {
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<number> {
const result = await client.query<ConsignmentAllocationRow>(
`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<number> {
const result = await client.query<ConsignmentAllocationRow>(
`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<void> {
await client.query(
`UPDATE consignment_inventory_allocations SET status = 'consumed', updated_at = $1 WHERE work_order_id = $2 AND status = 'reserved'`,
[now, workOrderId],
)
}
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null
quantity?: number
requestedSalePriceFen?: number
now: string
},
): Promise<ConsignmentSubmissionRow> {
@@ -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<string, unknown> | 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<ConsignmentInventoryRow>(invSql, invValues)
@@ -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<string, unknown> | 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<string, unknown>
draft_acceptance_json: string | Record<string, unknown>
admin_note: string
@@ -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,
@@ -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 || '')),
)
@@ -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'),
@@ -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
* 打手发布寄卖时的「对接客服」下拉:只返回启用中的名单
@@ -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 }
}
@@ -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
@@ -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<string, unknown> {
const parsed = safeParseJson(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
}
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<ConsignmentProfileSnapshot | null> {
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<boolean> {
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<WorkOrderRow>(
`
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<boolean> {
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<WorkOrderRow>(
`
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<boolean> {
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<WorkOrderRow>(
'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<WorkOrderRow>(
`
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<number> {
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<WorkOrderRow>(
'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<WorkOrderRow>(
`
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<WorkOrderRow>(
`
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<number> {
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
}
}
@@ -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}`)
}
})
@@ -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<string, unknown> {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
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<ConsignmentProfileRow>(`${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<ConsignmentProfileRow>(
`
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<ConsignmentProfileRow>(`${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<ConsignmentProfileRow>(
'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<ConsignmentProfileRow>(
`${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<ConsignmentProfileRow>(
`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<ConsignmentProfileRow>(
`
${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<ConsignmentProfileSnapshot | null> {
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<ConsignmentProfileRow>(
`${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<ConsignmentProfileSnapshot | null> {
const profileId = Number(profileIdInput)
if (!Number.isInteger(profileId) || profileId <= 0) return null
const result = await query<ConsignmentProfileRow>(
`${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<number> {
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,
}
}
@@ -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: '',
@@ -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<PoolClient, 'query'>
@@ -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'
@@ -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)
}
@@ -5,6 +5,7 @@ import { createHttpError } from '../../utils/http.js'
const PUBLIC_MATERIAL_STATUS_LABELS: Record<string, string> = {
[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]: '待验收',
@@ -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)
+2
View File
@@ -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) {
+2
View File
@@ -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<void>((resolve) => {