fix(consignment): 下线 H5 收集链接渠道并封闭寄卖履约状态机
下线定向收集链接(H5 填报)整条线,提报统一走接单平台打手端: - 删除前端 ConsignmentCollectPage、consignment-public 服务与 /consignment-collect/:token 路由 - 删除后端公开路由 routes/consignment.ts、consignment-public-service 与管理端 /consignment/links 三接口 - 迁移 079 删表 consignment_collect_links、删列 consignment_submissions.link_id, source_type 的 collection_link 归并为 worker_portal 并替换 CHECK 修复手工入库/编辑的凭据与截图静默丢失:后端统一按 credentialsJson ?? credentials、 screenshotsJson ?? screenshots 读取(入库/编辑/审核入库三个入口)。 按"已验收一律禁止撤销"封闭状态机: - 撤销寄售履约加状态白名单(in_progress|pending_acceptance|problem)+ 事务内 FOR UPDATE 锁单 + UPDATE 带 status 条件(0 行 409)+ 原因必填 + 清空指派/时限/验收草稿痕迹 - 库存改状态一律带条件:撤销要求 bound_work_order_id 命中本工单;作废/回库要求未被 进行中工单占用(被挡时提示具体工单号);079 加部分唯一索引并清理历史重复绑定 - 工单终止统一解绑回库:打手撤单/取消指派/退回大厅/撤单/打手退单申请/超时处置接入 releaseConsignmentInventoryBindingForWorkOrderWithClient(售后不改工单状态,无需接入) - 回库路径保留运营的 allow_matching 开关设置(不再强制开启),前端文案同步更正 顺带修掉资金与归属的同源缺陷: - 押金归属与重冻金额改取不可变的冻结核销行,审核改派不再"退给错人/向错人扣款" - 新增 assigned_worker_id 拆开"卖家"与"被指派履约人",占库不再覆盖卖家 - PUT /inventories/:id 禁止直接改 status;发布接口补 requireActiveWorker - 押金冻结/释放后补推钱包实时事件;迁移 079 给发布冻结加台账幂等唯一索引 验证:后端 401 测试/398 通过/3 skip(新增 5 例回归),前端 16 通过; 两侧 typecheck、lint:check、format:check 与 check:sql、build 全绿。 迁移 079 未经真实数据库执行(本环境无 Postgres),上库前请先 dry-run。
This commit is contained in:
@@ -5,7 +5,6 @@ import process from 'node:process'
|
||||
import adminRouter from './routes/admin.js'
|
||||
import affiliateDashRouter from './routes/affiliate-dash.js'
|
||||
import collectRouter from './routes/collect.js'
|
||||
import consignmentRouter from './routes/consignment.js'
|
||||
import filesRouter from './routes/files.js'
|
||||
import claimsRouter from './routes/claims.js'
|
||||
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
|
||||
@@ -105,7 +104,6 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
|
||||
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
|
||||
app.use('/api/v1/claim', claimsRouter)
|
||||
app.use('/api/v1/collect', collectRouter)
|
||||
app.use('/api/v1/consignment', consignmentRouter)
|
||||
app.use('/api/v1/pdd/extension', pddExtensionRouter)
|
||||
app.use('/api/v1/files', filesRouter)
|
||||
app.use('/api/v1/worker', workerRouter)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
-- 079_drop_consignment_collect_links.sql —— 下线「定向收集链接 / H5 填报」渠道
|
||||
--
|
||||
-- 背景:寄卖提报统一走接单平台(打手端提报道),对外收集链接这条线整条移除:
|
||||
-- - 前端:ConsignmentCollectPage、services/consignment-public.ts、/consignment-collect/:token 路由
|
||||
-- - 后端:routes/consignment.ts(公开 link/submit/upload)、consignment-public-service.ts、
|
||||
-- 管理端 /consignment/links 三个接口
|
||||
-- - 数据:consignment_collect_links 表、consignment_submissions.link_id 列
|
||||
-- 历史数据不做兼容处理(测试阶段),source_type 的 collection_link 归并为 worker_portal。
|
||||
|
||||
-- 1) 提报单去掉链接外键列(依赖该列的索引随之删除)
|
||||
ALTER TABLE consignment_submissions
|
||||
DROP COLUMN IF EXISTS link_id;
|
||||
|
||||
-- 2) 收集链接主表下线
|
||||
DROP TABLE IF EXISTS consignment_collect_links;
|
||||
|
||||
-- 3) 库存来源枚举:collection_link -> worker_portal
|
||||
-- 顺序很重要:旧 CHECK 只允许 ('manual','collection_link','recycle'),
|
||||
-- 必须先摘掉旧约束再回填,否则 'worker_portal' 会被旧约束拒绝,整条迁移失败回滚。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'consignment_inventories_source_type_check'
|
||||
AND conrelid = 'consignment_inventories'::regclass
|
||||
) THEN
|
||||
ALTER TABLE consignment_inventories
|
||||
DROP CONSTRAINT consignment_inventories_source_type_check;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE consignment_inventories
|
||||
SET source_type = 'worker_portal', updated_at = NOW()
|
||||
WHERE source_type = 'collection_link';
|
||||
|
||||
ALTER TABLE consignment_inventories
|
||||
ADD CONSTRAINT consignment_inventories_source_type_check
|
||||
CHECK (source_type IN ('manual', 'worker_portal', 'recycle'));
|
||||
|
||||
COMMENT ON COLUMN consignment_inventories.source_type IS
|
||||
'入库来源:manual 手工录入 / worker_portal 接单平台提报入库 / recycle 回收';
|
||||
|
||||
COMMENT ON TABLE consignment_submissions IS '打手通过接单平台提交的寄卖提报审核单';
|
||||
|
||||
-- 4) 区分"卖家"与"被指派的履约打手":worker_id 语义固定为卖家,指派结果单独存列,
|
||||
-- 避免占库时覆盖卖家导致「我的寄卖」丢件。
|
||||
ALTER TABLE consignment_inventories
|
||||
ADD COLUMN IF NOT EXISTS assigned_worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN consignment_inventories.worker_id IS '寄卖卖家(提报人)打手ID,入库后不再变更';
|
||||
COMMENT ON COLUMN consignment_inventories.assigned_worker_id IS
|
||||
'被指派执行本次履约的打手ID(仅押金履约模式占用,回库/撤单时清空)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_assigned_worker_id
|
||||
ON consignment_inventories(assigned_worker_id)
|
||||
WHERE assigned_worker_id IS NOT NULL;
|
||||
|
||||
-- 一件现货同一时刻最多绑定一张工单:防止同一件现货被多张工单占用。
|
||||
-- 先清理历史脏数据:同一工单被多件现货同时绑定时,保留工单 material 里记录的那一件,
|
||||
-- 其余解绑回库(它们是此前"回库后又被另一张单占用"缺陷的产物)。
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.bound_work_order_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ci.bound_work_order_id
|
||||
ORDER BY
|
||||
(ci.id = COALESCE(NULLIF(wo.material_json -> 'consignment' ->> 'inventoryId', '')::bigint, 0)) DESC,
|
||||
ci.id DESC
|
||||
) AS rn
|
||||
FROM consignment_inventories ci
|
||||
JOIN work_orders wo ON wo.id = ci.bound_work_order_id
|
||||
WHERE ci.bound_work_order_id IS NOT NULL
|
||||
AND ci.status = 'consumed'
|
||||
)
|
||||
UPDATE consignment_inventories ci
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
remark = remark || ' [重复绑定自动解绑: 工单 ' || ranked.bound_work_order_id || ']',
|
||||
updated_at = NOW()
|
||||
FROM ranked
|
||||
WHERE ci.id = ranked.id
|
||||
AND ranked.rn > 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_consignment_inventories_bound_work_order
|
||||
ON consignment_inventories(bound_work_order_id)
|
||||
WHERE bound_work_order_id IS NOT NULL AND status = 'consumed';
|
||||
|
||||
-- 5) 寄卖发布押金的冻结核销行幂等键:同一提报单只能有一笔发布冻结,
|
||||
-- 防止未来任何绕过"先读台账再冻结"约定的调用方重复扣款。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_worker_wallet_ledgers_consignment_publish_freeze
|
||||
ON worker_wallet_ledgers ((payload_json ->> 'consignmentSubmissionId'))
|
||||
WHERE ledger_type = 'deposit_freeze'
|
||||
AND payload_json ->> 'kind' = 'consignment_publish';
|
||||
@@ -170,6 +170,36 @@ export async function releaseConsignmentPublishDepositWithClient(
|
||||
return releaseAmount
|
||||
}
|
||||
|
||||
/**
|
||||
* 押金归属与金额以「台账实际冻结核销行」为准。
|
||||
*
|
||||
* 提报单上的 `worker_id` / `deposit_amount_fen` 都是可变字段(审核可以改派打手、
|
||||
* 运营可以调整金额),若用它反查钱包,会在改派后把押金退给从未发布过的打手
|
||||
* (对原发布者而言是静默不退的挂账);反过来重冻时也可能向错误钱包扣款。
|
||||
* 台账行是唯一不可变的事实来源。
|
||||
*/
|
||||
async function getConsignmentDepositLedgerSnapshot(
|
||||
client: PoolClient,
|
||||
submissionId: number | string,
|
||||
): Promise<{ ownerWorkerId: number; frozenAmountFen: number } | null> {
|
||||
const res = await client.query<{ worker_id: number | string | null; amount: number | string }>(
|
||||
`
|
||||
SELECT worker_id, ABS(amount) AS amount
|
||||
FROM worker_wallet_ledgers
|
||||
WHERE ledger_type = 'deposit_freeze'
|
||||
AND COALESCE(payload_json->>'kind', '') = 'consignment_publish'
|
||||
AND COALESCE(payload_json->>'consignmentSubmissionId', '') = $1
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[String(submissionId)],
|
||||
)
|
||||
const row = res.rows[0]
|
||||
const ownerWorkerId = Number(row?.worker_id || 0)
|
||||
if (!ownerWorkerId) return null
|
||||
return { ownerWorkerId, frozenAmountFen: Math.max(0, Math.round(Number(row?.amount || 0))) }
|
||||
}
|
||||
|
||||
async function getConsignmentSubmissionDepositTarget(
|
||||
client: PoolClient,
|
||||
submissionId: number | string,
|
||||
@@ -192,11 +222,15 @@ async function getConsignmentSubmissionDepositTarget(
|
||||
[submissionId],
|
||||
)
|
||||
const row = submission.rows[0]
|
||||
const workerId = Number(row?.worker_id || 0)
|
||||
const ledger = await getConsignmentDepositLedgerSnapshot(client, submissionId)
|
||||
|
||||
const workerId = ledger?.ownerWorkerId || Number(row?.worker_id || 0)
|
||||
if (!workerId) return null
|
||||
|
||||
return {
|
||||
workerId,
|
||||
depositAmountFen: Math.max(0, Math.round(Number(row?.deposit_amount_fen || 0))),
|
||||
depositAmountFen:
|
||||
ledger?.frozenAmountFen || Math.max(0, Math.round(Number(row?.deposit_amount_fen || 0))),
|
||||
submissionNo: String(row?.submission_no || ''),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ export type ConsignmentInventoryRow = {
|
||||
bound_work_order_id: string | number | null
|
||||
bound_platform_order_id: string
|
||||
shop_name: string
|
||||
source_type: 'manual' | 'collection_link' | 'recycle'
|
||||
source_type: 'manual' | 'worker_portal' | 'recycle'
|
||||
source_submission_id: string | number | null
|
||||
worker_id?: string | number | null
|
||||
worker_name?: string
|
||||
worker_phone?: string
|
||||
assigned_worker_id?: string | number | null
|
||||
deposit_amount_fen?: number
|
||||
inbound_at: string
|
||||
consumed_at: string | null
|
||||
@@ -43,35 +44,9 @@ export type ConsignmentInventoryRow = {
|
||||
category_name?: string
|
||||
}
|
||||
|
||||
export type ConsignmentCollectLinkRow = {
|
||||
id: string | number
|
||||
token: string
|
||||
title: string
|
||||
game_name: string
|
||||
category_id: string | number | null
|
||||
system: string
|
||||
server_zone: string
|
||||
series: string
|
||||
diamond_level: number
|
||||
brilliance_count: number
|
||||
preset_price_fen: number
|
||||
inbound_batch: string
|
||||
buyer_name: string
|
||||
template_json: string | Record<string, unknown>
|
||||
is_multi_use: boolean
|
||||
expires_at: string | null
|
||||
enabled: boolean
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
category_name?: string
|
||||
submission_count?: number
|
||||
}
|
||||
|
||||
export type ConsignmentSubmissionRow = {
|
||||
id: string | number
|
||||
submission_no: string
|
||||
link_id: string | number | null
|
||||
game_name: string
|
||||
category_id: string | number | null
|
||||
item_name: string
|
||||
@@ -99,7 +74,6 @@ export type ConsignmentSubmissionRow = {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
category_name?: string
|
||||
link_title?: string
|
||||
}
|
||||
|
||||
export type ConsignmentStatsSummary = {
|
||||
@@ -340,7 +314,7 @@ export async function createConsignmentInventory(input: {
|
||||
screenshotsJson?: unknown[]
|
||||
remark?: string
|
||||
allowMatching?: boolean
|
||||
sourceType?: 'manual' | 'collection_link' | 'recycle'
|
||||
sourceType?: 'manual' | 'worker_portal' | 'recycle'
|
||||
sourceSubmissionId?: number | null
|
||||
workerId?: number | null
|
||||
workerName?: string
|
||||
@@ -421,7 +395,6 @@ export async function updateConsignmentInventory(
|
||||
screenshotsJson?: unknown[] | undefined
|
||||
remark?: string | undefined
|
||||
allowMatching?: boolean | undefined
|
||||
status?: 'in_stock' | 'consumed' | 'archived' | undefined
|
||||
allocationStatus?: string | undefined
|
||||
workerId?: number | null | undefined
|
||||
workerName?: string | undefined
|
||||
@@ -501,10 +474,6 @@ export async function updateConsignmentInventory(
|
||||
values.push(input.allowMatching)
|
||||
updates.push(`allow_matching = $${values.length}`)
|
||||
}
|
||||
if (input.status !== undefined) {
|
||||
values.push(input.status)
|
||||
updates.push(`status = $${values.length}`)
|
||||
}
|
||||
if (input.allocationStatus !== undefined) {
|
||||
values.push(input.allocationStatus)
|
||||
updates.push(`allocation_status = $${values.length}`)
|
||||
@@ -552,7 +521,16 @@ export async function updateConsignmentInventory(
|
||||
export async function claimConsignmentInventoryForWorkerWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
input: { workerId: number; now: string; allocationStatus?: string },
|
||||
input: {
|
||||
workerId: number
|
||||
now: string
|
||||
allocationStatus?: string
|
||||
/** 占库时同时写入工单绑定,便于撤单/超时/售后按工单解绑回库。 */
|
||||
boundOrderId?: number | null
|
||||
boundWorkOrderId?: number | null
|
||||
boundPlatformOrderId?: string
|
||||
shopName?: string
|
||||
},
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
@@ -560,20 +538,41 @@ export async function claimConsignmentInventoryForWorkerWithClient(
|
||||
SET
|
||||
status = 'consumed',
|
||||
allocation_status = $1,
|
||||
worker_id = $2,
|
||||
assigned_worker_id = $2,
|
||||
consumed_at = $3,
|
||||
updated_at = $3
|
||||
WHERE id = $4 AND status = 'in_stock'
|
||||
updated_at = $3,
|
||||
bound_order_id = $4,
|
||||
bound_work_order_id = $5,
|
||||
bound_platform_order_id = $6,
|
||||
shop_name = $7
|
||||
WHERE id = $8 AND status = 'in_stock'
|
||||
RETURNING *
|
||||
`,
|
||||
[input.allocationStatus || '已指派打手(押金履约中)', input.workerId, input.now, id],
|
||||
[
|
||||
input.allocationStatus || '已指派打手(押金履约中)',
|
||||
input.workerId,
|
||||
input.now,
|
||||
input.boundOrderId ?? null,
|
||||
input.boundWorkOrderId ?? null,
|
||||
input.boundPlatformOrderId ?? '',
|
||||
input.shopName ?? '',
|
||||
id,
|
||||
],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
export async function claimConsignmentInventoryForWorker(
|
||||
id: number | string,
|
||||
input: { workerId: number; now: string; allocationStatus?: string },
|
||||
input: {
|
||||
workerId: number
|
||||
now: string
|
||||
allocationStatus?: string
|
||||
boundOrderId?: number | null
|
||||
boundWorkOrderId?: number | null
|
||||
boundPlatformOrderId?: string
|
||||
shopName?: string
|
||||
},
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
return withTransaction((client) =>
|
||||
claimConsignmentInventoryForWorkerWithClient(client, id, input),
|
||||
@@ -591,7 +590,7 @@ export async function releaseConsignmentInventoryClaimWithClient(
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
worker_id = NULL,
|
||||
assigned_worker_id = NULL,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
@@ -617,6 +616,19 @@ export async function releaseConsignmentInventoryClaim(
|
||||
return withTransaction((client) => releaseConsignmentInventoryClaimWithClient(client, id, input))
|
||||
}
|
||||
|
||||
/** 仅当库存未被"进行中的工单"占用时才允许人工改状态(作废 / 退回复库) */
|
||||
const NO_LIVE_BOUND_WORK_ORDER_SQL = `
|
||||
(
|
||||
ci.bound_work_order_id IS NULL
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM work_orders wo
|
||||
WHERE wo.id = ci.bound_work_order_id
|
||||
AND wo.status NOT IN ('accepted', 'cancelled')
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
export async function archiveConsignmentInventoryWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
@@ -624,12 +636,13 @@ export async function archiveConsignmentInventoryWithClient(
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories
|
||||
UPDATE consignment_inventories ci
|
||||
SET
|
||||
status = 'archived',
|
||||
allow_matching = FALSE,
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
WHERE ci.id = $2
|
||||
AND ${NO_LIVE_BOUND_WORK_ORDER_SQL}
|
||||
RETURNING *
|
||||
`,
|
||||
[now, id],
|
||||
@@ -645,22 +658,24 @@ export async function returnConsignmentInventoryToStockWithClient(
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories
|
||||
UPDATE consignment_inventories ci
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
assigned_worker_id = NULL,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
allow_matching = TRUE,
|
||||
remark = CASE
|
||||
WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']'
|
||||
ELSE remark
|
||||
END,
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = 'consumed'
|
||||
WHERE ci.id = $3
|
||||
AND ci.status = 'consumed'
|
||||
AND ${NO_LIVE_BOUND_WORK_ORDER_SQL}
|
||||
RETURNING *
|
||||
`,
|
||||
[reason, now, id],
|
||||
@@ -668,6 +683,44 @@ export async function returnConsignmentInventoryToStockWithClient(
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单终止 / 回退时解绑寄卖库存:把被该工单占用的现货放回在库池。
|
||||
*
|
||||
* 撤单、退回大厅、超时处置、售后取消都必须调用本函数,否则现货会永久停留在
|
||||
* `consumed`(占成本但不再参与匹配)。发货押金仍保持冻结:现货回到在库池即等于
|
||||
* 重新挂卖,符合"发布冻结、回库重冻"的既有口径。
|
||||
*/
|
||||
export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number | string,
|
||||
input: { now: string; reason?: string },
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
assigned_worker_id = NULL,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
remark = CASE
|
||||
WHEN $3 = '' THEN remark
|
||||
ELSE remark || ' [工单解绑回库: ' || $3 || ']'
|
||||
END,
|
||||
updated_at = $1
|
||||
WHERE bound_work_order_id = $2
|
||||
AND status = 'consumed'
|
||||
RETURNING *
|
||||
`,
|
||||
[input.now, workOrderId, input.reason || ''],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
/** 退回复库(将已出库物品恢复为在库待匹配) */
|
||||
export async function returnConsignmentInventoryToStock(
|
||||
id: number | string,
|
||||
@@ -706,132 +759,10 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
|
||||
return res.rows
|
||||
}
|
||||
|
||||
// ---------------- 收集链接管理 ----------------
|
||||
|
||||
export async function listConsignmentCollectLinks(): Promise<ConsignmentCollectLinkRow[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
ccl.*,
|
||||
wc.name AS category_name,
|
||||
(SELECT COUNT(*)::integer FROM consignment_submissions cs WHERE cs.link_id = ccl.id) AS submission_count
|
||||
FROM consignment_collect_links ccl
|
||||
LEFT JOIN work_categories wc ON wc.id = ccl.category_id
|
||||
ORDER BY ccl.id DESC
|
||||
`
|
||||
const res = await query<ConsignmentCollectLinkRow>(sql)
|
||||
return res.rows
|
||||
}
|
||||
|
||||
export async function getConsignmentCollectLinkByToken(
|
||||
token: string,
|
||||
): Promise<ConsignmentCollectLinkRow | null> {
|
||||
const sql = `
|
||||
SELECT
|
||||
ccl.*,
|
||||
wc.name AS category_name
|
||||
FROM consignment_collect_links ccl
|
||||
LEFT JOIN work_categories wc ON wc.id = ccl.category_id
|
||||
WHERE ccl.token = $1 AND ccl.enabled = TRUE
|
||||
LIMIT 1
|
||||
`
|
||||
const res = await query<ConsignmentCollectLinkRow>(sql, [token])
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
export async function createConsignmentCollectLink(input: {
|
||||
token: string
|
||||
title: string
|
||||
gameName?: string
|
||||
categoryId?: number | null
|
||||
system?: string
|
||||
serverZone?: string
|
||||
series?: string
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
presetPriceFen?: number
|
||||
inboundBatch?: string
|
||||
buyerName?: string
|
||||
templateJson?: Record<string, unknown>
|
||||
isMultiUse?: boolean
|
||||
expiresAt?: string | null
|
||||
createdBy?: string
|
||||
now: string
|
||||
}): Promise<ConsignmentCollectLinkRow> {
|
||||
const sql = `
|
||||
INSERT INTO consignment_collect_links (
|
||||
token, title, game_name, category_id, system, server_zone,
|
||||
series, diamond_level, brilliance_count, preset_price_fen,
|
||||
inbound_batch, buyer_name, template_json, is_multi_use,
|
||||
expires_at, enabled, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13::jsonb, $14,
|
||||
$15, TRUE, $16, $17, $17
|
||||
)
|
||||
RETURNING *
|
||||
`
|
||||
const res = await query<ConsignmentCollectLinkRow>(sql, [
|
||||
input.token,
|
||||
input.title,
|
||||
input.gameName || '和平精英',
|
||||
input.categoryId || null,
|
||||
input.system || '',
|
||||
input.serverZone || '',
|
||||
input.series || '',
|
||||
input.diamondLevel || 1,
|
||||
input.brillianceCount || 0,
|
||||
input.presetPriceFen || 0,
|
||||
input.inboundBatch || '',
|
||||
input.buyerName || '',
|
||||
toJsonString(input.templateJson || {}),
|
||||
input.isMultiUse !== false,
|
||||
input.expiresAt || null,
|
||||
input.createdBy || '',
|
||||
input.now,
|
||||
])
|
||||
return res.rows[0]!
|
||||
}
|
||||
|
||||
export async function updateConsignmentCollectLink(
|
||||
id: number | string,
|
||||
input: {
|
||||
title?: string | undefined
|
||||
enabled?: boolean | undefined
|
||||
now: string
|
||||
},
|
||||
): Promise<ConsignmentCollectLinkRow | null> {
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
if (input.title !== undefined) {
|
||||
values.push(input.title)
|
||||
updates.push(`title = $${values.length}`)
|
||||
}
|
||||
if (input.enabled !== undefined) {
|
||||
values.push(input.enabled)
|
||||
updates.push(`enabled = $${values.length}`)
|
||||
}
|
||||
|
||||
values.push(input.now)
|
||||
updates.push(`updated_at = $${values.length}`)
|
||||
|
||||
values.push(id)
|
||||
const sql = `
|
||||
UPDATE consignment_collect_links
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${values.length}
|
||||
RETURNING *
|
||||
`
|
||||
const res = await query<ConsignmentCollectLinkRow>(sql, values)
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
// ---------------- 玩家提报与审核 ----------------
|
||||
// ---------------- 打手提报与审核 ----------------
|
||||
|
||||
export async function listConsignmentSubmissions(filters: {
|
||||
status?: string | undefined
|
||||
linkId?: number | undefined
|
||||
workerId?: number | undefined
|
||||
keyword?: string | undefined
|
||||
page?: number | undefined
|
||||
@@ -844,10 +775,6 @@ export async function listConsignmentSubmissions(filters: {
|
||||
values.push(filters.status)
|
||||
conditions.push(`cs.status = $${values.length}`)
|
||||
}
|
||||
if (filters.linkId) {
|
||||
values.push(filters.linkId)
|
||||
conditions.push(`cs.link_id = $${values.length}`)
|
||||
}
|
||||
if (filters.workerId) {
|
||||
values.push(filters.workerId)
|
||||
conditions.push(`cs.worker_id = $${values.length}`)
|
||||
@@ -872,11 +799,9 @@ export async function listConsignmentSubmissions(filters: {
|
||||
const listSql = `
|
||||
SELECT
|
||||
cs.*,
|
||||
wc.name AS category_name,
|
||||
ccl.title AS link_title
|
||||
wc.name AS category_name
|
||||
FROM consignment_submissions cs
|
||||
LEFT JOIN work_categories wc ON wc.id = cs.category_id
|
||||
LEFT JOIN consignment_collect_links ccl ON ccl.id = cs.link_id
|
||||
${whereClause}
|
||||
ORDER BY cs.id DESC
|
||||
LIMIT $${values.length - 1} OFFSET $${values.length}
|
||||
@@ -891,11 +816,9 @@ export async function getConsignmentSubmissionById(
|
||||
const sql = `
|
||||
SELECT
|
||||
cs.*,
|
||||
wc.name AS category_name,
|
||||
ccl.title AS link_title
|
||||
wc.name AS category_name
|
||||
FROM consignment_submissions cs
|
||||
LEFT JOIN work_categories wc ON wc.id = cs.category_id
|
||||
LEFT JOIN consignment_collect_links ccl ON ccl.id = cs.link_id
|
||||
WHERE cs.id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
@@ -905,7 +828,6 @@ export async function getConsignmentSubmissionById(
|
||||
|
||||
export async function createConsignmentSubmission(input: {
|
||||
submissionNo: string
|
||||
linkId?: number | null
|
||||
gameName?: string
|
||||
categoryId?: number | null
|
||||
itemName: string
|
||||
@@ -934,7 +856,6 @@ export async function createConsignmentSubmissionWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
submissionNo: string
|
||||
linkId?: number | null
|
||||
gameName?: string
|
||||
categoryId?: number | null
|
||||
itemName: string
|
||||
@@ -959,25 +880,24 @@ export async function createConsignmentSubmissionWithClient(
|
||||
): Promise<ConsignmentSubmissionRow> {
|
||||
const sql = `
|
||||
INSERT INTO consignment_submissions (
|
||||
submission_no, link_id, game_name, category_id, item_name,
|
||||
submission_no, game_name, category_id, item_name,
|
||||
system, server_zone, series, diamond_level, brilliance_count,
|
||||
seller_game_id, seller_payment_info, seller_phone, contact_info,
|
||||
content_json, screenshots_json, asking_price_fen,
|
||||
worker_id, worker_name, worker_phone, deposit_amount_fen,
|
||||
status, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14,
|
||||
$15::jsonb, $16::jsonb, $17,
|
||||
$18, $19, $20, $21,
|
||||
'pending_review', $22, $22
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13,
|
||||
$14::jsonb, $15::jsonb, $16,
|
||||
$17, $18, $19, $20,
|
||||
'pending_review', $21, $21
|
||||
)
|
||||
RETURNING *
|
||||
`
|
||||
const res = await client.query<ConsignmentSubmissionRow>(sql, [
|
||||
input.submissionNo,
|
||||
input.linkId || null,
|
||||
input.gameName || '和平精英',
|
||||
input.categoryId || null,
|
||||
input.itemName,
|
||||
@@ -1099,7 +1019,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $17::jsonb, $18::jsonb, $19,
|
||||
TRUE, 'in_stock', '待安排', 'collection_link',
|
||||
TRUE, 'in_stock', '待安排', 'worker_portal',
|
||||
$20, $21, $22, $23, $24,
|
||||
$25, $25, $25
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
|
||||
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
@@ -78,6 +79,12 @@ export async function cancelWorkerWorkOrder(input: {
|
||||
)
|
||||
}
|
||||
|
||||
// 打手撤单:被本工单占用的寄卖现货放回在库池(否则现货永久停留在 consumed)。
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
|
||||
now: input.now,
|
||||
reason: '打手撤单',
|
||||
})
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'worker',
|
||||
@@ -181,6 +188,12 @@ export async function unassignWorkOrder(input: {
|
||||
)
|
||||
}
|
||||
|
||||
// 取消指派:寄卖现货回到在库池等待重新匹配。
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
|
||||
now: input.now,
|
||||
reason: '后台取消指派',
|
||||
})
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
@@ -289,6 +302,12 @@ export async function returnAssignedWorkOrderToHall(input: {
|
||||
)
|
||||
}
|
||||
|
||||
// 退回大厅:寄卖现货回到在库池;若该单原本走内部现货履约,运营可重新指派。
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
|
||||
now: input.now,
|
||||
reason: '后台退回大厅',
|
||||
})
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
@@ -397,6 +416,12 @@ export async function cancelAssignedWorkOrder(input: {
|
||||
)
|
||||
}
|
||||
|
||||
// 撤单取消订单:寄卖现货回到在库池,避免占用成本却不再参与匹配。
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
|
||||
now: input.now,
|
||||
reason: '后台撤单取消订单',
|
||||
})
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
@@ -479,6 +504,13 @@ export async function reopenCancelledWorkOrder(input: {
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
|
||||
// 重新启用已取消工单:历史绑定若未清理干净,这里兜底解绑,避免"重新履约再次占库"
|
||||
// 造成同一件现货被两张单子锁定。
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
|
||||
now: input.now,
|
||||
reason: '已取消工单重新启用',
|
||||
})
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
|
||||
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
@@ -318,6 +319,14 @@ export async function settleOverdueWorkOrder(input: {
|
||||
[targetStatus, input.now, input.workOrderId],
|
||||
)
|
||||
|
||||
// 超时处置(重开/取消):被本工单占用的寄卖现货必须先放回在库池。
|
||||
// 内部现货履约单在赠送冷静期内同样带 deadline_at,若不解绑,现货会永久停留在 consumed:
|
||||
// 既不再参与匹配,发货押金也没有释放入口。
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
|
||||
now: input.now,
|
||||
reason: `超时处置(${effectivePolicy})`,
|
||||
})
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'system',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PoolClient } from 'pg'
|
||||
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
|
||||
import {
|
||||
ensureWorkerWalletWithClient,
|
||||
getWorkerWalletWithClient,
|
||||
@@ -557,6 +558,11 @@ export async function reviewWorkerCancelRequest(input: {
|
||||
WHERE id = $2`,
|
||||
[input.now, workOrder.id],
|
||||
)
|
||||
// 打手退单申请通过:工单回到大厅,被本工单占用的寄卖现货同时放回在库池。
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, workOrder.id, {
|
||||
now: input.now,
|
||||
reason: '打手退单申请通过',
|
||||
})
|
||||
if (releasedDepositAmount > 0) {
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releasedDepositAmount)
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
approveAdminConsignmentSubmission,
|
||||
archiveAdminConsignmentInventory,
|
||||
createAdminConsignmentInventory,
|
||||
createAdminConsignmentLink,
|
||||
fetchAdminConsignmentInventories,
|
||||
fetchAdminConsignmentLinks,
|
||||
fetchAdminConsignmentStats,
|
||||
fetchAdminConsignmentSubmissions,
|
||||
getAdminConsignmentInventory,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
returnAdminConsignmentInventory,
|
||||
toggleAdminConsignmentMatching,
|
||||
updateAdminConsignmentInventory,
|
||||
updateAdminConsignmentLink,
|
||||
} from '../../../services/worker-platform/consignment-admin-service.js'
|
||||
import {
|
||||
assignWorkOrderWithConsignmentWorker,
|
||||
@@ -131,41 +128,6 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
// ---------------- 收集链接管理 ----------------
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/links',
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler(() => fetchAdminConsignmentLinks(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取收集链接列表失败',
|
||||
scope: '[admin/consignment/links]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/links',
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler(
|
||||
(req) => createAdminConsignmentLink(req.body, req.adminSession?.username || 'admin'),
|
||||
{
|
||||
successMessage: '收集链接已生成',
|
||||
errorMessage: '生成收集链接失败',
|
||||
scope: '[admin/consignment/links/create]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.put(
|
||||
'/worker-platform/consignment/links/:id',
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler((req) => updateAdminConsignmentLink(String(req.params.id || ''), req.body), {
|
||||
successMessage: '收集链接已更新',
|
||||
errorMessage: '更新收集链接失败',
|
||||
scope: '[admin/consignment/links/update]',
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------- 提报单审核 ----------------
|
||||
|
||||
router.get(
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||||
import {
|
||||
getPublicConsignmentLink,
|
||||
submitPublicConsignment,
|
||||
} from '../services/worker-platform/consignment-public-service.js'
|
||||
import { uploadFileAsset } from '../services/file-storage/file-storage-service.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
import { uploadSingleFile } from './file-upload.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const consignmentRateLimit = createRateLimitMiddleware({
|
||||
scope: 'consignment-public',
|
||||
windowMs: 60_000,
|
||||
max: 60,
|
||||
})
|
||||
|
||||
router.get(
|
||||
'/:token',
|
||||
consignmentRateLimit,
|
||||
createRouteHandler((req) => getPublicConsignmentLink(String(req.params.token || '')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '获取收集信息失败',
|
||||
scope: '[consignment/link]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:token/submit',
|
||||
consignmentRateLimit,
|
||||
createRouteHandler(
|
||||
(req) => submitPublicConsignment(String(req.params.token || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '寄卖申请已提交,客服审核后将与您联系',
|
||||
errorMessage: '提交寄卖信息失败',
|
||||
scope: '[consignment/submit]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/upload',
|
||||
consignmentRateLimit,
|
||||
uploadSingleFile,
|
||||
createRouteHandler(
|
||||
(req) => {
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: req.body?.scene || 'consignment-material',
|
||||
uploaderType: 'consignment',
|
||||
uploaderId: 'anonymous',
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '凭据图片已上传',
|
||||
errorMessage: '凭据图片上传失败',
|
||||
scope: '[consignment/upload]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
publishWorkerConsignment,
|
||||
} from '../../services/worker-platform/consignment-worker-service.js'
|
||||
import { sendRouteError } from '../../utils/http.js'
|
||||
import { getRequiredWorkerSession } from './session.js'
|
||||
import { getRequiredWorkerSession, requireActiveWorker } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -13,7 +13,7 @@ const router = Router()
|
||||
* POST /api/v1/worker/consignment/publish
|
||||
* 打手在接单平台直接提报苏尔南手枪寄卖
|
||||
*/
|
||||
router.post('/consignment/publish', async (req, res) => {
|
||||
router.post('/consignment/publish', requireActiveWorker, async (req, res) => {
|
||||
try {
|
||||
const session = getRequiredWorkerSession(req)
|
||||
const result = await publishWorkerConsignment(session.workerId, req.body || {})
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import {
|
||||
type ConsignmentCollectLinkRow,
|
||||
type ConsignmentInventoryRow,
|
||||
type ConsignmentSubmissionRow,
|
||||
approveConsignmentSubmissionWithInbound,
|
||||
createConsignmentCollectLink,
|
||||
createConsignmentInventory,
|
||||
getConsignmentInventoryById,
|
||||
getConsignmentInventoryStats,
|
||||
getConsignmentSubmissionById,
|
||||
listConsignmentCollectLinks,
|
||||
listConsignmentInventories,
|
||||
listConsignmentSubmissions,
|
||||
rejectConsignmentSubmissionWithClient,
|
||||
archiveConsignmentInventoryWithClient,
|
||||
returnConsignmentInventoryToStockWithClient,
|
||||
updateConsignmentCollectLink,
|
||||
updateConsignmentInventory,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import {
|
||||
@@ -24,15 +20,15 @@ import {
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js'
|
||||
import { generateConsignmentInventoryNo } from '../../utils/random.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||
getConsignmentDepositErrorCode,
|
||||
releaseConsignmentPublishDepositBySubmissionWithClient,
|
||||
releaseConsignmentPublishDepositWithClient,
|
||||
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
|
||||
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
|
||||
const code = getConsignmentDepositErrorCode(error)
|
||||
@@ -53,6 +49,21 @@ function throwConsignmentDepositHttpError(error: unknown, restock: boolean): nev
|
||||
throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端历史字段为 credentialsJson / screenshotsJson,早期接口写的是 credentials / screenshots,
|
||||
* 两个键名都接受,避免客服录入的账号密码被静默丢弃。
|
||||
*/
|
||||
function pickCredentialsJson(payload: JsonObject): Record<string, unknown> | undefined {
|
||||
const value = payload.credentialsJson ?? payload.credentials
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function pickScreenshotsJson(payload: JsonObject): unknown[] | undefined {
|
||||
const value = payload.screenshotsJson ?? payload.screenshots
|
||||
return Array.isArray(value) ? value : undefined
|
||||
}
|
||||
|
||||
export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
@@ -94,6 +105,7 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||
workerId: row.worker_id ? Number(row.worker_id) : null,
|
||||
workerName: row.worker_name || '',
|
||||
workerPhone: row.worker_phone || '',
|
||||
assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null,
|
||||
depositAmountFen: Number(row.deposit_amount_fen || 0),
|
||||
inboundAt: row.inbound_at || row.created_at,
|
||||
consumedAt: row.consumed_at,
|
||||
@@ -102,42 +114,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||
}
|
||||
}
|
||||
|
||||
export function mapConsignmentCollectLink(row: ConsignmentCollectLinkRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
token: row.token,
|
||||
title: row.title,
|
||||
gameName: row.game_name,
|
||||
categoryId: row.category_id ? Number(row.category_id) : null,
|
||||
categoryName: row.category_name,
|
||||
system: row.system,
|
||||
serverZone: row.server_zone,
|
||||
series: row.series,
|
||||
diamondLevel: Number(row.diamond_level || 1),
|
||||
brillianceCount: Number(row.brilliance_count || 0),
|
||||
presetPriceFen: Number(row.preset_price_fen || 0),
|
||||
inboundBatch: row.inbound_batch || '',
|
||||
buyerName: row.buyer_name || '',
|
||||
templateJson:
|
||||
row.template_json && typeof row.template_json === 'object'
|
||||
? (row.template_json as Record<string, unknown>)
|
||||
: safeParseJson(row.template_json),
|
||||
isMultiUse: Boolean(row.is_multi_use),
|
||||
expiresAt: row.expires_at,
|
||||
enabled: Boolean(row.enabled),
|
||||
createdBy: row.created_by || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
submissionCount: Number(row.submission_count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
submissionNo: row.submission_no,
|
||||
linkId: row.link_id ? Number(row.link_id) : null,
|
||||
linkTitle: row.link_title,
|
||||
gameName: row.game_name,
|
||||
categoryId: row.category_id ? Number(row.category_id) : null,
|
||||
categoryName: row.category_name,
|
||||
@@ -252,11 +232,8 @@ export async function createAdminConsignmentInventory(payload: JsonObject, actor
|
||||
sellerGameId: String(payload.sellerGameId || '').trim(),
|
||||
sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(),
|
||||
sellerPhone: String(payload.sellerPhone || '').trim(),
|
||||
credentialsJson:
|
||||
payload.credentials && typeof payload.credentials === 'object'
|
||||
? (payload.credentials as Record<string, unknown>)
|
||||
: {},
|
||||
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [],
|
||||
credentialsJson: pickCredentialsJson(payload) ?? {},
|
||||
screenshotsJson: pickScreenshotsJson(payload) ?? [],
|
||||
remark: String(payload.remark || '').trim(),
|
||||
allowMatching: payload.allowMatching !== false,
|
||||
sourceType: 'manual',
|
||||
@@ -276,6 +253,14 @@ export async function updateAdminConsignmentInventory(
|
||||
_actorName = '',
|
||||
) {
|
||||
const now = nowIso()
|
||||
// 状态只能通过「下架作废 / 退回复库 / 匹配开关」这些带守卫的动作变更,
|
||||
// 编辑接口直接写 status 会绕过「现货被进行中工单占用」保护与押金处理。
|
||||
if (payload.status !== undefined) {
|
||||
throw createHttpError('编辑接口不支持直接修改物品状态', {
|
||||
statusCode: 400,
|
||||
errorCode: 'consignment_status_update_forbidden',
|
||||
})
|
||||
}
|
||||
const updated = await updateConsignmentInventory(id, {
|
||||
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
|
||||
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
|
||||
@@ -305,14 +290,10 @@ export async function updateAdminConsignmentInventory(
|
||||
? String(payload.sellerPaymentInfo).trim()
|
||||
: undefined,
|
||||
sellerPhone: payload.sellerPhone !== undefined ? String(payload.sellerPhone).trim() : undefined,
|
||||
credentialsJson:
|
||||
payload.credentials && typeof payload.credentials === 'object'
|
||||
? (payload.credentials as Record<string, unknown>)
|
||||
: undefined,
|
||||
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined,
|
||||
credentialsJson: pickCredentialsJson(payload),
|
||||
screenshotsJson: pickScreenshotsJson(payload),
|
||||
remark: payload.remark !== undefined ? String(payload.remark).trim() : undefined,
|
||||
allowMatching: payload.allowMatching !== undefined ? Boolean(payload.allowMatching) : undefined,
|
||||
status: payload.status !== undefined ? (payload.status as any) : undefined,
|
||||
workerId:
|
||||
payload.workerId !== undefined
|
||||
? payload.workerId
|
||||
@@ -362,17 +343,46 @@ export async function archiveAdminConsignmentInventory(id: number | string) {
|
||||
return archived
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('寄卖物品不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'consignment_not_found',
|
||||
})
|
||||
await throwConsignmentInventoryBlockedError(id, '作废')
|
||||
}
|
||||
return mapConsignmentInventory(updated)
|
||||
const mapped = mapConsignmentInventory(updated!)
|
||||
if (mapped.workerId) {
|
||||
publishWorkerWalletRealtimeChange(mapped.workerId)
|
||||
}
|
||||
return mapped
|
||||
} catch (error) {
|
||||
throwConsignmentDepositHttpError(error, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工改状态被「现货正被进行中工单占用」挡住时,给出可操作提示,
|
||||
* 而不是笼统地报"不存在"。
|
||||
*/
|
||||
async function throwConsignmentInventoryBlockedError(
|
||||
id: number | string,
|
||||
actionLabel: string,
|
||||
): Promise<never> {
|
||||
const current = await getConsignmentInventoryById(id)
|
||||
if (!current) {
|
||||
throw createHttpError('寄卖物品不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'consignment_not_found',
|
||||
})
|
||||
}
|
||||
const boundWorkOrderId = Number(current.bound_work_order_id || 0)
|
||||
if (boundWorkOrderId > 0 && current.status === 'consumed') {
|
||||
throw createHttpError(
|
||||
`该现货正被工单 #${boundWorkOrderId} 使用,无法${actionLabel};请先撤销寄售履约或撤单,再操作`,
|
||||
{ statusCode: 409, errorCode: 'consignment_inventory_in_use' },
|
||||
)
|
||||
}
|
||||
throw createHttpError(`当前状态(${current.status})不支持${actionLabel}`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_inventory_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
/** 已出库物品退回复库 */
|
||||
export async function returnAdminConsignmentInventory(
|
||||
id: number | string,
|
||||
@@ -395,84 +405,41 @@ export async function returnAdminConsignmentInventory(
|
||||
return returned
|
||||
})
|
||||
if (!updated) {
|
||||
const current = await getConsignmentInventoryById(id)
|
||||
if (!current) {
|
||||
throw createHttpError('寄卖物品不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'consignment_not_found',
|
||||
})
|
||||
}
|
||||
if (current.status === 'consumed') {
|
||||
await throwConsignmentInventoryBlockedError(id, '退回复库')
|
||||
}
|
||||
throw createHttpError('只有已出库物品可以退回复库', {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_not_returnable',
|
||||
})
|
||||
}
|
||||
return mapConsignmentInventory(updated)
|
||||
const mapped = mapConsignmentInventory(updated)
|
||||
if (mapped.workerId) {
|
||||
publishWorkerWalletRealtimeChange(mapped.workerId)
|
||||
}
|
||||
return mapped
|
||||
} catch (error) {
|
||||
throwConsignmentDepositHttpError(error, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 收集链接管理 ----------------
|
||||
|
||||
export async function fetchAdminConsignmentLinks() {
|
||||
const items = await listConsignmentCollectLinks()
|
||||
return { items: items.map(mapConsignmentCollectLink) }
|
||||
}
|
||||
|
||||
export async function createAdminConsignmentLink(payload: JsonObject, actorName = '') {
|
||||
const title = String(payload.title || '').trim()
|
||||
if (!title) {
|
||||
throw createHttpError('链接标题不能为空', { statusCode: 400, errorCode: 'invalid_link_title' })
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const token = randomToken(16)
|
||||
|
||||
const row = await createConsignmentCollectLink({
|
||||
token,
|
||||
title,
|
||||
gameName: String(payload.gameName || '和平精英').trim(),
|
||||
categoryId: payload.categoryId ? Number(payload.categoryId) : null,
|
||||
system: String(payload.system || '').trim(),
|
||||
serverZone: String(payload.serverZone || '').trim(),
|
||||
series: String(payload.series || '').trim(),
|
||||
diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))),
|
||||
brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))),
|
||||
presetPriceFen: Math.max(0, Math.round(Number(payload.presetPriceFen || 0))),
|
||||
inboundBatch: String(payload.inboundBatch || '').trim(),
|
||||
buyerName: String(payload.buyerName || actorName || '').trim(),
|
||||
templateJson:
|
||||
payload.template && typeof payload.template === 'object'
|
||||
? (payload.template as Record<string, unknown>)
|
||||
: {},
|
||||
isMultiUse: payload.isMultiUse !== false,
|
||||
expiresAt: payload.expiresAt ? String(payload.expiresAt) : null,
|
||||
createdBy: actorName,
|
||||
now,
|
||||
})
|
||||
|
||||
return mapConsignmentCollectLink(row)
|
||||
}
|
||||
|
||||
export async function updateAdminConsignmentLink(id: number | string, payload: JsonObject) {
|
||||
const now = nowIso()
|
||||
const updated = await updateConsignmentCollectLink(id, {
|
||||
title: payload.title !== undefined ? String(payload.title).trim() : undefined,
|
||||
enabled: payload.enabled !== undefined ? Boolean(payload.enabled) : undefined,
|
||||
now,
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('收集链接不存在', { statusCode: 404, errorCode: 'link_not_found' })
|
||||
}
|
||||
return mapConsignmentCollectLink(updated)
|
||||
}
|
||||
|
||||
// ---------------- 提报单审核 ----------------
|
||||
|
||||
export async function fetchAdminConsignmentSubmissions(query: JsonObject = {}) {
|
||||
const status = query.status ? String(query.status).trim() : undefined
|
||||
const linkId = query.linkId ? Number(query.linkId) : undefined
|
||||
const keyword = query.keyword ? String(query.keyword).trim() : undefined
|
||||
const page = query.page ? Number(query.page) : 1
|
||||
const pageSize = query.pageSize ? Number(query.pageSize) : 20
|
||||
|
||||
const res = await listConsignmentSubmissions({
|
||||
status,
|
||||
linkId,
|
||||
keyword,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -560,11 +527,8 @@ export async function approveAdminConsignmentSubmission(
|
||||
sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(),
|
||||
sellerPaymentInfo: String(payload.sellerPaymentInfo || submission.seller_payment_info).trim(),
|
||||
sellerPhone: phoneToLookup,
|
||||
credentialsJson:
|
||||
payload.credentials && typeof payload.credentials === 'object'
|
||||
? (payload.credentials as Record<string, unknown>)
|
||||
: undefined,
|
||||
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined,
|
||||
credentialsJson: pickCredentialsJson(payload),
|
||||
screenshotsJson: pickScreenshotsJson(payload),
|
||||
reviewNote: String(payload.reviewNote || '').trim(),
|
||||
workerId,
|
||||
workerName,
|
||||
@@ -600,8 +564,8 @@ export async function rejectAdminConsignmentSubmission(
|
||||
now,
|
||||
})
|
||||
if (rejected?.worker_id) {
|
||||
await releaseConsignmentPublishDepositWithClient(client, {
|
||||
workerId: Number(rejected.worker_id),
|
||||
// 释放走"按提报单"版本:押金归属以冻结核销行为准,不受审核改派影响。
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId: rejected.id,
|
||||
note: `寄卖提报驳回退还押金 ${rejected.submission_no}`,
|
||||
now,
|
||||
@@ -615,5 +579,8 @@ export async function rejectAdminConsignmentSubmission(
|
||||
errorCode: 'submission_already_reviewed',
|
||||
})
|
||||
}
|
||||
if (updated.worker_id) {
|
||||
publishWorkerWalletRealtimeChange(Number(updated.worker_id))
|
||||
}
|
||||
return mapConsignmentSubmission(updated)
|
||||
}
|
||||
|
||||
@@ -312,12 +312,19 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const orderMaterial = safeParseJson(workOrder.material_json)
|
||||
const orderSource = safeParseJson(orderMaterial.source)
|
||||
const assignShopName = String(orderSource.shopName || orderSource.shopTitle || '').trim()
|
||||
|
||||
// 1. 先原子占用库存:并发指派时只有一方能把 status 从 in_stock 改成 consumed,
|
||||
// 避免同一件现货被两张工单同时认领。
|
||||
// 避免同一件现货被两张工单同时认领。同时写入工单绑定,便于撤单/超时按工单解绑回库。
|
||||
const claimed = await claimConsignmentInventoryForWorker(inventory.id, {
|
||||
workerId: Number(targetWorkerId),
|
||||
now,
|
||||
boundOrderId: workOrder.order_id ? Number(workOrder.order_id) : null,
|
||||
boundWorkOrderId: Number(workOrder.id),
|
||||
boundPlatformOrderId: String(workOrder.platform_order_id || ''),
|
||||
shopName: assignShopName,
|
||||
})
|
||||
if (!claimed) {
|
||||
throw createHttpError('寄卖库存已被其他操作占用,请刷新重试', {
|
||||
@@ -334,7 +341,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
input.actorName,
|
||||
)
|
||||
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
const material = orderMaterial
|
||||
|
||||
// 3. 将寄售手枪信息写入工单 material
|
||||
const updatedMaterial = {
|
||||
@@ -409,12 +416,36 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许「撤销寄售履约」的工单状态白名单。
|
||||
*
|
||||
* 已验收(accepted)的工单代表钱已结算、货已交付,撤销会把工单改回未分配、把现货放回
|
||||
* 在库池并重新冻结已退还的押金 —— 等于同一件现货二次出货,因此一律禁止;
|
||||
* 买家退款/纠纷请走售后流程。已取消(cancelled)的工单同样禁止撤销。
|
||||
*/
|
||||
const CONSIGNMENT_REVOCABLE_WORK_ORDER_STATUSES: readonly string[] = [
|
||||
WORK_ORDER_STATUS.IN_PROGRESS,
|
||||
WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
WORK_ORDER_STATUS.PROBLEM,
|
||||
]
|
||||
|
||||
export function assertConsignmentRevocableWorkOrderStatus(status: unknown): void {
|
||||
const normalized = String(status || '').trim()
|
||||
if (!CONSIGNMENT_REVOCABLE_WORK_ORDER_STATUSES.includes(normalized)) {
|
||||
throw createHttpError('该工单已验收或已取消,不能撤销寄售履约;退款与纠纷请走售后流程', {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_revoke_status_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工单上的寄售信息是否可走「撤销寄售履约」流程,并返回关联库存 ID。
|
||||
*
|
||||
* 只有“内部现货占库”履约才能走本流程;押金履约模式(worker_deposit)由打手接单并
|
||||
* 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」,
|
||||
* 否则打手押金不会释放,会造成资金挂账。
|
||||
* 只有"内部现货占库"履约才能走本流程;押金履约模式(worker_deposit)由打手接单并
|
||||
* 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」——
|
||||
* 这两条链路会调用 releaseConsignmentInventoryBindingForWorkOrderWithClient 自动解绑
|
||||
* 寄卖库存,打手押金由既有逻辑释放,避免资金挂账与库存悬空。
|
||||
*/
|
||||
export function resolveRevocableConsignmentInventoryId(
|
||||
consignment: Record<string, unknown>,
|
||||
@@ -457,6 +488,15 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
if (!workOrder) {
|
||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||
}
|
||||
assertConsignmentRevocableWorkOrderStatus(workOrder.status)
|
||||
|
||||
const reason = String(input.reason || '').trim()
|
||||
if (!reason) {
|
||||
throw createHttpError('请填写撤销原因', {
|
||||
statusCode: 400,
|
||||
errorCode: 'consignment_revoke_reason_required',
|
||||
})
|
||||
}
|
||||
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
const consignmentInfo = safeParseJson(material.consignment)
|
||||
@@ -466,70 +506,85 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
|
||||
try {
|
||||
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
|
||||
// 0. 事务内重新加锁读单:避免与「验收 / 并发撤销」竞争时用陈旧快照覆盖状态。
|
||||
const lockedRes = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[workOrder.id],
|
||||
)
|
||||
const lockedOrder = lockedRes.rows[0]
|
||||
if (!lockedOrder) {
|
||||
throw createHttpError('工单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_not_found',
|
||||
})
|
||||
}
|
||||
assertConsignmentRevocableWorkOrderStatus(lockedOrder.status)
|
||||
const lockedMaterial = safeParseJson(lockedOrder.material_json)
|
||||
resolveRevocableConsignmentInventoryId(safeParseJson(lockedMaterial.consignment))
|
||||
|
||||
let inv: ConsignmentInventoryRow | null = null
|
||||
|
||||
// 1. 处理关联库存解绑与状态流转
|
||||
if (inventoryId) {
|
||||
if (input.action === 'archive_inventory') {
|
||||
// 物品异常作废
|
||||
const invSql = `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'archived',
|
||||
allow_matching = FALSE,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
remark = remark || ' [交付异常作废: ' || $1 || ']',
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`
|
||||
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
||||
input.reason || '异常作废',
|
||||
now,
|
||||
inventoryId,
|
||||
])
|
||||
inv = invRes.rows[0] || null
|
||||
} else {
|
||||
// 恢复在库 (in_stock)
|
||||
const invSql = `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
remark = remark || ' [撤销回库: ' || $1 || ']',
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`
|
||||
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
||||
input.reason || '撤单回库',
|
||||
now,
|
||||
inventoryId,
|
||||
])
|
||||
inv = invRes.rows[0] || null
|
||||
}
|
||||
// 1. 处理关联库存解绑与状态流转。
|
||||
// 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑,
|
||||
// 造成同一件现货被多张工单履约。
|
||||
const invSql =
|
||||
input.action === 'archive_inventory'
|
||||
? `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'archived',
|
||||
allow_matching = FALSE,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
remark = remark || ' [交付异常作废: ' || $1 || ']',
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
|
||||
RETURNING *
|
||||
`
|
||||
: `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
assigned_worker_id = NULL,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
remark = remark || ' [撤销回库: ' || $1 || ']',
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
|
||||
RETURNING *
|
||||
`
|
||||
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
||||
reason,
|
||||
now,
|
||||
inventoryId,
|
||||
lockedOrder.id,
|
||||
])
|
||||
inv = invRes.rows[0] || null
|
||||
if (!inv) {
|
||||
throw createHttpError('该现货已被其他操作解绑或占用,请刷新工单后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_inventory_binding_changed',
|
||||
})
|
||||
}
|
||||
|
||||
const submissionId = consignmentInfo.sourceSubmissionId || inv?.source_submission_id
|
||||
const submissionId = consignmentInfo.sourceSubmissionId || inv.source_submission_id
|
||||
if (submissionId) {
|
||||
if (input.action === 'archive_inventory') {
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
note: `寄卖履约作废退还押金 ${inv?.inventory_no || submissionId}`,
|
||||
note: `寄卖履约作废退还押金 ${inv.inventory_no || submissionId}`,
|
||||
now,
|
||||
})
|
||||
} else {
|
||||
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
note: `寄卖撤销回库重新冻结押金 ${inv?.inventory_no || submissionId}`,
|
||||
note: `寄卖撤销回库重新冻结押金 ${inv.inventory_no || submissionId}`,
|
||||
now,
|
||||
})
|
||||
}
|
||||
@@ -541,46 +596,67 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
nextOrderStatus = WORK_ORDER_STATUS.CANCELLED
|
||||
}
|
||||
|
||||
const { consignment: _removed, ...cleanMaterial } = material
|
||||
const { consignment: _removed, ...cleanMaterial } = lockedMaterial
|
||||
const archiveHistory = [
|
||||
...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []),
|
||||
...(Array.isArray(lockedMaterial.consignmentHistory)
|
||||
? lockedMaterial.consignmentHistory
|
||||
: []),
|
||||
{
|
||||
...consignmentInfo,
|
||||
revokedAt: now,
|
||||
revokedBy: input.actorName,
|
||||
revokeReason: input.reason,
|
||||
revokeReason: reason,
|
||||
},
|
||||
]
|
||||
|
||||
// 工单回到「未指派 / 已取消」前,要把上一次交付留下的指派、时限、验收草稿与
|
||||
// 问题单痕迹一并清干净;否则重新指派时新打手会看到上一手的 deadline 与验收内容。
|
||||
const woSql = `
|
||||
UPDATE work_orders
|
||||
SET
|
||||
status = $1,
|
||||
material_json = $2::jsonb,
|
||||
assigned_worker_id = NULL,
|
||||
assigned_at = NULL,
|
||||
deadline_at = NULL,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
problem_note = '',
|
||||
published_at = NULL,
|
||||
pinned_at = NULL,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $3
|
||||
WHERE id = $4
|
||||
WHERE id = $4 AND status = $5
|
||||
RETURNING *
|
||||
`
|
||||
const woRes = await client.query<WorkOrderRow>(woSql, [
|
||||
nextOrderStatus,
|
||||
JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }),
|
||||
now,
|
||||
workOrder.id,
|
||||
lockedOrder.id,
|
||||
lockedOrder.status,
|
||||
])
|
||||
const wo = woRes.rows[0]!
|
||||
const wo = woRes.rows[0]
|
||||
if (!wo) {
|
||||
throw createHttpError('工单状态已被其他操作变更,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_status_changed',
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 记录事件
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: workOrder.id,
|
||||
workOrderId: lockedOrder.id,
|
||||
actorType: 'operator',
|
||||
actorId: input.actorName || 'admin',
|
||||
eventType: 'consignment_revoked',
|
||||
fromStatus: workOrder.status,
|
||||
fromStatus: lockedOrder.status,
|
||||
toStatus: nextOrderStatus,
|
||||
payloadJson: JSON.stringify({
|
||||
action: input.action,
|
||||
reason: input.reason,
|
||||
reason,
|
||||
inventoryId,
|
||||
}),
|
||||
now,
|
||||
@@ -589,7 +665,10 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
return { updatedOrder: wo, updatedInventory: inv }
|
||||
})
|
||||
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(updatedOrder.id),
|
||||
hallChanged: updatedOrder.status === WORK_ORDER_STATUS.UNASSIGNED,
|
||||
})
|
||||
|
||||
return {
|
||||
order: mapWorkOrderAdmin(updatedOrder),
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import {
|
||||
createConsignmentSubmission,
|
||||
getConsignmentCollectLinkByToken,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import { getWorkerUserByPhone } from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getConsignmentPublishDepositFen } from './worker-finance-config-service.js'
|
||||
|
||||
export async function getPublicConsignmentLink(token: string) {
|
||||
const cleanToken = String(token || '').trim()
|
||||
if (!cleanToken) {
|
||||
throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' })
|
||||
}
|
||||
|
||||
const link = await getConsignmentCollectLinkByToken(cleanToken)
|
||||
if (!link) {
|
||||
throw createHttpError('收集链接不存在或已被关闭', {
|
||||
statusCode: 404,
|
||||
errorCode: 'link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) {
|
||||
throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' })
|
||||
}
|
||||
|
||||
return {
|
||||
title: link.title,
|
||||
gameName: link.game_name,
|
||||
categoryId: link.category_id,
|
||||
categoryName: link.category_name,
|
||||
system: link.system,
|
||||
serverZone: link.server_zone,
|
||||
series: link.series,
|
||||
diamondLevel: link.diamond_level,
|
||||
brillianceCount: link.brilliance_count,
|
||||
presetPriceFen: link.preset_price_fen,
|
||||
template: link.template_json,
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitPublicConsignment(token: string, payload: JsonObject) {
|
||||
const cleanToken = String(token || '').trim()
|
||||
if (!cleanToken) {
|
||||
throw createHttpError('链接无效', { statusCode: 400, errorCode: 'invalid_token' })
|
||||
}
|
||||
|
||||
const link = await getConsignmentCollectLinkByToken(cleanToken)
|
||||
if (!link) {
|
||||
throw createHttpError('收集链接不存在或已被关闭', {
|
||||
statusCode: 404,
|
||||
errorCode: 'link_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (link.expires_at && new Date(link.expires_at).getTime() < Date.now()) {
|
||||
throw createHttpError('该收集链接已过期', { statusCode: 410, errorCode: 'link_expired' })
|
||||
}
|
||||
|
||||
const itemName = String(
|
||||
payload.itemName ||
|
||||
link.title ||
|
||||
`${link.system}${link.server_zone}${link.series}${link.diamond_level}钻`,
|
||||
).trim()
|
||||
const contactInfo = String(payload.contactInfo || payload.sellerContact || '').trim()
|
||||
if (!contactInfo) {
|
||||
throw createHttpError('请填写微信号或QQ号以便联系与核实', {
|
||||
statusCode: 400,
|
||||
errorCode: 'contact_info_required',
|
||||
})
|
||||
}
|
||||
|
||||
const contentPayload =
|
||||
payload.contentJson && typeof payload.contentJson === 'object'
|
||||
? (payload.contentJson as Record<string, unknown>)
|
||||
: payload.content && typeof payload.content === 'object'
|
||||
? (payload.content as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
const characterName = String(contentPayload.characterName || payload.characterName || '').trim()
|
||||
const characterId = String(contentPayload.characterId || payload.characterId || '').trim()
|
||||
if (!characterName) {
|
||||
throw createHttpError('请填写游戏内角色名', {
|
||||
statusCode: 400,
|
||||
errorCode: 'character_name_required',
|
||||
})
|
||||
}
|
||||
if (!characterId) {
|
||||
throw createHttpError('请填写角色数字ID', {
|
||||
statusCode: 400,
|
||||
errorCode: 'character_id_required',
|
||||
})
|
||||
}
|
||||
|
||||
const screenshots = Array.isArray(payload.screenshotsJson)
|
||||
? payload.screenshotsJson
|
||||
: Array.isArray(payload.screenshots)
|
||||
? payload.screenshots
|
||||
: []
|
||||
if (screenshots.length === 0) {
|
||||
throw createHttpError('请上传游戏主页截图以供核验', {
|
||||
statusCode: 400,
|
||||
errorCode: 'screenshot_required',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const submissionNo = generateConsignmentSubmissionNo()
|
||||
const askingPriceFen = Math.max(
|
||||
100,
|
||||
Math.round(Number(payload.askingPriceFen || link.preset_price_fen || 0)),
|
||||
)
|
||||
|
||||
const sellerPhone = String(payload.sellerPhone || '').trim()
|
||||
if (!sellerPhone) {
|
||||
throw createHttpError('请填写联系电话', {
|
||||
statusCode: 400,
|
||||
errorCode: 'seller_phone_required',
|
||||
})
|
||||
}
|
||||
|
||||
let workerId: number | null = null
|
||||
let workerName = ''
|
||||
let workerPhone = ''
|
||||
|
||||
if (sellerPhone) {
|
||||
const matchedWorker = await getWorkerUserByPhone(sellerPhone)
|
||||
if (matchedWorker) {
|
||||
workerId = Number(matchedWorker.id)
|
||||
workerName = matchedWorker.display_name || matchedWorker.username || ''
|
||||
workerPhone = matchedWorker.phone || sellerPhone
|
||||
}
|
||||
}
|
||||
|
||||
const diamondLevel = Math.max(
|
||||
1,
|
||||
Math.min(7, Number(payload.diamondLevel || link.diamond_level || 1)),
|
||||
)
|
||||
const brillianceCount = Math.max(
|
||||
0,
|
||||
Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)),
|
||||
)
|
||||
|
||||
const sellerGameId = String(payload.sellerGameId || `${characterName} (${characterId})`).trim()
|
||||
|
||||
const submission = await createConsignmentSubmission({
|
||||
submissionNo,
|
||||
linkId: Number(link.id),
|
||||
gameName: link.game_name,
|
||||
categoryId: link.category_id ? Number(link.category_id) : null,
|
||||
itemName,
|
||||
system: String(payload.system || link.system || '').trim(),
|
||||
serverZone: String(payload.serverZone || link.server_zone || '').trim(),
|
||||
series: String(payload.series || link.series || '').trim(),
|
||||
diamondLevel,
|
||||
brillianceCount,
|
||||
sellerGameId,
|
||||
sellerPaymentInfo: String(payload.sellerPaymentInfo || '走接单平台钱包结算').trim(),
|
||||
sellerPhone,
|
||||
contactInfo,
|
||||
contentJson: {
|
||||
...contentPayload,
|
||||
characterName,
|
||||
characterId,
|
||||
sellerNote: String(contentPayload.sellerNote || payload.sellerNote || '').trim(),
|
||||
channel: 'collect_link',
|
||||
},
|
||||
screenshotsJson: screenshots,
|
||||
askingPriceFen,
|
||||
workerId,
|
||||
workerName,
|
||||
workerPhone,
|
||||
depositAmountFen: Math.max(
|
||||
0,
|
||||
Math.round(Number(payload.depositAmountFen ?? getConsignmentPublishDepositFen())),
|
||||
),
|
||||
now,
|
||||
})
|
||||
|
||||
return {
|
||||
submissionNo: submission.submission_no,
|
||||
status: submission.status,
|
||||
submittedAt: submission.created_at,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js'
|
||||
|
||||
export function getConsignmentPublishDepositFen() {
|
||||
@@ -106,7 +107,6 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
const submission = await withTransaction(async (client) => {
|
||||
const created = await createConsignmentSubmissionWithClient(client, {
|
||||
submissionNo,
|
||||
linkId: null,
|
||||
gameName: '和平精英',
|
||||
categoryId: null,
|
||||
itemName,
|
||||
@@ -146,6 +146,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
return created
|
||||
})
|
||||
|
||||
// 发布冻结押金后推送钱包变更,避免打手端余额展示滞后。
|
||||
publishWorkerWalletRealtimeChange(Number(worker.id))
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
submissionNo: submission.submission_no,
|
||||
|
||||
@@ -61,11 +61,17 @@ import {
|
||||
releaseConsignmentPublishDepositWithClient,
|
||||
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||
import {
|
||||
archiveConsignmentInventoryWithClient,
|
||||
claimConsignmentInventoryForWorkerWithClient,
|
||||
releaseConsignmentInventoryBindingForWorkOrderWithClient,
|
||||
releaseConsignmentInventoryClaimWithClient,
|
||||
returnConsignmentInventoryToStockWithClient,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
|
||||
import { resolveRevocableConsignmentInventoryId } from './consignment-fulfillment-service.js'
|
||||
import {
|
||||
assertConsignmentRevocableWorkOrderStatus,
|
||||
resolveRevocableConsignmentInventoryId,
|
||||
} from './consignment-fulfillment-service.js'
|
||||
|
||||
test('后台权限白名单覆盖全部迁移登记的权限,避免保存角色权限时被静默清除', () => {
|
||||
const migrationsDir = fileURLToPath(new URL('../../db/migrations', import.meta.url))
|
||||
@@ -94,11 +100,11 @@ test('后台权限白名单覆盖全部迁移登记的权限,避免保存角
|
||||
})
|
||||
|
||||
test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用返回 null', async () => {
|
||||
const statements: string[] = []
|
||||
const statements: Array<{ sql: string; values: unknown[] }> = []
|
||||
let matched = false
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
statements.push(sql)
|
||||
query: async (sql: string, values: unknown[]) => {
|
||||
statements.push({ sql, values })
|
||||
return { rows: matched ? [{ id: 9, inventory_no: 'INV2609150001' }] : [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
@@ -113,11 +119,110 @@ test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用
|
||||
const claimed = await claimConsignmentInventoryForWorkerWithClient(client, 9, {
|
||||
workerId: 3,
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
boundOrderId: 66,
|
||||
boundWorkOrderId: 77,
|
||||
boundPlatformOrderId: 'PT-77',
|
||||
shopName: '旗舰店',
|
||||
})
|
||||
assert.equal(claimed?.inventory_no, 'INV2609150001')
|
||||
|
||||
assert.ok(statements[0]!.includes("WHERE id = $4 AND status = 'in_stock'"))
|
||||
assert.ok(statements[0]!.includes('RETURNING *'))
|
||||
const claimSql = statements[1]!.sql
|
||||
assert.ok(claimSql.includes("WHERE id = $8 AND status = 'in_stock'"))
|
||||
assert.ok(claimSql.includes('RETURNING *'))
|
||||
// 占库写入工单绑定;被指派打手写 assigned_worker_id,卖家 worker_id 不再被覆盖
|
||||
assert.ok(claimSql.includes('assigned_worker_id = $2'))
|
||||
assert.ok(claimSql.includes('bound_work_order_id = $5'))
|
||||
assert.deepEqual(statements[1]!.values.slice(3, 8), [66, 77, 'PT-77', '旗舰店', 9])
|
||||
})
|
||||
|
||||
test('撤销寄售履约的状态白名单:只允许进行中/待验收/问题单', () => {
|
||||
for (const status of ['in_progress', 'pending_acceptance', 'problem']) {
|
||||
assert.doesNotThrow(() => assertConsignmentRevocableWorkOrderStatus(status))
|
||||
}
|
||||
for (const status of ['accepted', 'cancelled', 'unassigned', 'open', '']) {
|
||||
assert.throws(
|
||||
() => assertConsignmentRevocableWorkOrderStatus(status),
|
||||
/不能撤销寄售履约/,
|
||||
`状态 ${status} 必须被拒绝`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开匹配开关', async () => {
|
||||
const statements: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
statements.push(sql)
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 77, {
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '后台撤单取消订单',
|
||||
})
|
||||
const sql = statements[0]!
|
||||
assert.ok(sql.includes('WHERE bound_work_order_id = $2'))
|
||||
assert.ok(sql.includes("status = 'consumed'"))
|
||||
assert.ok(!sql.includes('allow_matching'))
|
||||
})
|
||||
|
||||
test('人工作废与退回复库在现货仍被进行中工单占用时不改状态', async () => {
|
||||
const statements: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
statements.push(sql)
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
await archiveConsignmentInventoryWithClient(client, 9, '2026-09-15T00:00:00.000Z')
|
||||
await returnConsignmentInventoryToStockWithClient(
|
||||
client,
|
||||
9,
|
||||
'买家退货',
|
||||
'2026-09-15T00:00:00.000Z',
|
||||
)
|
||||
|
||||
for (const sql of statements) {
|
||||
assert.ok(
|
||||
sql.includes('FROM work_orders wo') && sql.includes("NOT IN ('accepted', 'cancelled')"),
|
||||
`缺少进行中工单占用保护:${sql}`,
|
||||
)
|
||||
}
|
||||
// 退回复库不得再强制打开匹配开关(避免覆盖运营手动暂停)
|
||||
assert.ok(!statements[1]!.includes('allow_matching'))
|
||||
})
|
||||
|
||||
test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const cancelRepo = await readFile.readFile(
|
||||
new URL('../../repositories/worker-platform/work-order-cancel-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const issueRepo = await readFile.readFile(
|
||||
new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const cancelRequestRepo = await readFile.readFile(
|
||||
new URL('../../repositories/worker-platform/worker-cancel-request-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
for (const [name, source] of [
|
||||
['work-order-cancel-repo', cancelRepo],
|
||||
['work-order-issue-repo', issueRepo],
|
||||
['worker-cancel-request-repo', cancelRequestRepo],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
source.includes('releaseConsignmentInventoryBindingForWorkOrderWithClient('),
|
||||
`${name} 缺少寄卖解绑调用`,
|
||||
)
|
||||
}
|
||||
// 撤单仓库内 5 条终态链路:打手撤单 / 取消指派 / 退回大厅 / 撤单取消 / 重新启用
|
||||
assert.equal(
|
||||
cancelRepo.match(/releaseConsignmentInventoryBindingForWorkOrderWithClient\(/g)?.length,
|
||||
5,
|
||||
)
|
||||
})
|
||||
|
||||
test('寄卖指派失败回滚只在物品仍为 consumed 时放回在库', async () => {
|
||||
|
||||
Reference in New Issue
Block a user