feat:寄卖增加对接客服,db脚本迁移修复

This commit is contained in:
2026-10-27 13:39:48 +08:00
parent 0de0fd7b58
commit 622bd779dd
25 changed files with 1515 additions and 106 deletions
@@ -1,4 +1,4 @@
-- 115_work_order_share_cancel_reason_comment.sql
-- 111_work_order_share_cancel_reason_comment.sql
-- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。
-- 纯注释变更,无结构/数据改动;重复执行无副作用。
@@ -0,0 +1,41 @@
-- 112_consignment_contact_supporter.sql
-- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。
-- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。
-- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。
-- 全部语句幂等,可重复执行。
CREATE TABLE IF NOT EXISTS consignment_contact_supporters (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT NOT NULL DEFAULT '',
wechat TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引
CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order
ON consignment_contact_supporters (enabled, sort_order, id);
ALTER TABLE consignment_submissions
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
ALTER TABLE consignment_inventories
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter
ON consignment_submissions (contact_supporter_id);
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter
ON consignment_inventories (contact_supporter_id);
-- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。
-- 后台可直接改名/补充联系方式,不会重复插入。
INSERT INTO consignment_contact_supporters (name, note, sort_order)
SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0
WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters);
@@ -0,0 +1,190 @@
import { query } from '../../db/client.js'
/**
* 寄卖「对接客服」名单。
*
* 独立于后台账号表:客服离职或后台账号改角色,都不应影响历史寄卖归属;
* 提报单 / 现货只保存名单 id,展示时 join 出当时选择的客服资料。
*/
export type ConsignmentContactRow = {
id: string | number
name: string
phone: string
wechat: string
note: string
enabled: boolean
sort_order: number
created_at: string
updated_at: string
}
export async function listConsignmentContacts(filters: {
keyword?: string | undefined
enabled?: boolean | undefined
}): Promise<ConsignmentContactRow[]> {
const conditions: string[] = []
const values: unknown[] = []
if (filters.enabled !== undefined) {
values.push(filters.enabled)
conditions.push(`enabled = $${values.length}`)
}
if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`)
conditions.push(
`(name ILIKE $${values.length} OR phone ILIKE $${values.length} OR wechat ILIKE $${values.length} OR note ILIKE $${values.length})`,
)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const res = await query<ConsignmentContactRow>(
`SELECT * FROM consignment_contact_supporters ${whereClause} ORDER BY sort_order ASC, id ASC`,
values,
)
return res.rows
}
/** 打手端发布下拉:只返回启用中的客服。 */
export async function listEnabledConsignmentContacts(): Promise<ConsignmentContactRow[]> {
return listConsignmentContacts({ enabled: true })
}
export async function getConsignmentContactById(
id: number | string,
): Promise<ConsignmentContactRow | null> {
const res = await query<ConsignmentContactRow>(
'SELECT * FROM consignment_contact_supporters WHERE id = $1 LIMIT 1',
[id],
)
return res.rows[0] || null
}
/** 重名检查:名单用于归属统计,同名会让运营无法判断到底是谁。 */
export async function findConsignmentContactByName(
name: string,
excludeId?: number | string,
): Promise<ConsignmentContactRow | null> {
const res = await query<ConsignmentContactRow>(
`SELECT * FROM consignment_contact_supporters
WHERE name = $1 ${excludeId !== undefined ? 'AND id <> $2' : ''}
ORDER BY id ASC
LIMIT 1`,
excludeId !== undefined ? [name, excludeId] : [name],
)
return res.rows[0] || null
}
export async function createConsignmentContact(input: {
name: string
phone?: string | undefined
wechat?: string | undefined
note?: string | undefined
enabled?: boolean | undefined
sortOrder?: number | undefined
now: string
}): Promise<ConsignmentContactRow> {
const res = await query<ConsignmentContactRow>(
`INSERT INTO consignment_contact_supporters (
name, phone, wechat, note, enabled, sort_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING *`,
[
input.name,
input.phone || '',
input.wechat || '',
input.note || '',
input.enabled !== false,
input.sortOrder || 0,
input.now,
],
)
return res.rows[0]!
}
export async function updateConsignmentContact(
id: number | string,
input: {
name?: string | undefined
phone?: string | undefined
wechat?: string | undefined
note?: string | undefined
sortOrder?: number | undefined
now: string
},
): Promise<ConsignmentContactRow | null> {
const updates: string[] = []
const values: unknown[] = []
if (input.name !== undefined) {
values.push(input.name)
updates.push(`name = $${values.length}`)
}
if (input.phone !== undefined) {
values.push(input.phone)
updates.push(`phone = $${values.length}`)
}
if (input.wechat !== undefined) {
values.push(input.wechat)
updates.push(`wechat = $${values.length}`)
}
if (input.note !== undefined) {
values.push(input.note)
updates.push(`note = $${values.length}`)
}
if (input.sortOrder !== undefined) {
values.push(input.sortOrder)
updates.push(`sort_order = $${values.length}`)
}
if (updates.length === 0) {
return getConsignmentContactById(id)
}
values.push(input.now)
updates.push(`updated_at = $${values.length}`)
values.push(id)
const res = await query<ConsignmentContactRow>(
`UPDATE consignment_contact_supporters
SET ${updates.join(', ')}
WHERE id = $${values.length}
RETURNING *`,
values,
)
return res.rows[0] || null
}
export async function setConsignmentContactEnabled(
id: number | string,
enabled: boolean,
now: string,
): Promise<ConsignmentContactRow | null> {
const res = await query<ConsignmentContactRow>(
`UPDATE consignment_contact_supporters
SET enabled = $1, updated_at = $2
WHERE id = $3
RETURNING *`,
[enabled, now, id],
)
return res.rows[0] || null
}
/** 删除前统计引用:有历史寄卖时禁止硬删,避免历史单据的对接客服变成空白。 */
export async function countConsignmentContactReferences(id: number | string): Promise<number> {
const res = await query<{ total: number }>(
`SELECT (
(SELECT COUNT(*) FROM consignment_submissions WHERE contact_supporter_id = $1) +
(SELECT COUNT(*) FROM consignment_inventories WHERE contact_supporter_id = $1)
)::integer AS total`,
[id],
)
return Number(res.rows[0]?.total || 0)
}
export async function deleteConsignmentContact(id: number | string): Promise<boolean> {
const res = await query<{ id: string | number }>(
'DELETE FROM consignment_contact_supporters WHERE id = $1 RETURNING id',
[id],
)
return res.rows.length > 0
}
@@ -50,6 +50,11 @@ export type ConsignmentInventoryRow = {
worker_phone?: string
assigned_worker_id?: string | number | null
deposit_amount_fen?: number
/** 对接客服(发布/审核入库时选定,用于提成归属与联系) */
contact_supporter_id?: string | number | null
contact_supporter_name?: string
contact_supporter_phone?: string
contact_supporter_wechat?: string
inbound_at: string
consumed_at: string | null
created_at: string
@@ -79,6 +84,11 @@ export type ConsignmentSubmissionRow = {
worker_name?: string
worker_phone?: string
deposit_amount_fen?: number
/** 对接客服(发布时选定,用于提成归属与联系) */
contact_supporter_id?: string | number | null
contact_supporter_name?: string
contact_supporter_phone?: string
contact_supporter_wechat?: string
status: 'pending_review' | 'approved' | 'rejected'
review_note: string
reviewer_name: string
@@ -175,13 +185,13 @@ export async function listConsignmentInventories(filters: {
if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`)
conditions.push(
`(ci.item_name ILIKE $${values.length} OR ci.inventory_no ILIKE $${values.length} OR ci.bound_platform_order_id ILIKE $${values.length} OR ci.seller_contact ILIKE $${values.length} OR ci.seller_game_id ILIKE $${values.length} OR ci.seller_payment_info ILIKE $${values.length} OR ci.remark ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length})`,
`(ci.item_name ILIKE $${values.length} OR ci.inventory_no ILIKE $${values.length} OR ci.bound_platform_order_id ILIKE $${values.length} OR ci.seller_contact ILIKE $${values.length} OR ci.seller_game_id ILIKE $${values.length} OR ci.seller_payment_info ILIKE $${values.length} OR ci.remark ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR ccs.name ILIKE $${values.length})`,
)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
// 计数也要带上提报单 join:关键字现在包含 cs.submission_no(卖家端主标识就是它)
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id ${whereClause}`
// 计数也要带上提报单 / 对接客服 join:关键字现在包含 cs.submission_no 与 ccs.name
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id ${whereClause}`
const countRes = await query<{ total: number }>(countSql, values)
const total = countRes.rows[0]?.total || 0
@@ -202,10 +212,14 @@ export async function listConsignmentInventories(filters: {
wo.gift_phase AS gift_phase,
wo.friend_added_at AS gift_friend_added_at,
wo.material_json AS bound_work_order_material_json,
wo.requirement_json AS bound_work_order_requirement_json
wo.requirement_json AS bound_work_order_requirement_json,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
${whereClause}
ORDER BY ci.id DESC
@@ -321,10 +335,14 @@ export async function getConsignmentInventoryById(
wo.gift_phase AS gift_phase,
wo.friend_added_at AS gift_friend_added_at,
wo.material_json AS bound_work_order_material_json,
wo.requirement_json AS bound_work_order_requirement_json
wo.requirement_json AS bound_work_order_requirement_json,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE ci.id = $1
LIMIT 1
@@ -344,10 +362,14 @@ export async function listConsignmentInventoriesByIds(
ci.*,
wc.name AS category_name,
cs.submission_no AS source_submission_no,
cs.screenshots_json AS submission_screenshots_json
cs.screenshots_json AS submission_screenshots_json,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
WHERE ci.id = ANY($1::bigint[])
`
const res = await query<ConsignmentInventoryRow>(sql, [numericIds])
@@ -379,6 +401,7 @@ export async function updateConsignmentInventory(
workerName?: string | undefined
workerPhone?: string | undefined
depositAmountFen?: number | undefined
contactSupporterId?: number | null | undefined
now: string
},
): Promise<ConsignmentInventoryRow | null> {
@@ -473,6 +496,10 @@ export async function updateConsignmentInventory(
values.push(input.depositAmountFen)
updates.push(`deposit_amount_fen = $${values.length}`)
}
if (input.contactSupporterId !== undefined) {
values.push(input.contactSupporterId)
updates.push(`contact_supporter_id = $${values.length}`)
}
if (updates.length === 0) return getConsignmentInventoryById(id)
@@ -484,10 +511,13 @@ export async function updateConsignmentInventory(
UPDATE consignment_inventories
SET ${updates.join(', ')}
WHERE id = $${values.length}
RETURNING *
RETURNING id
`
const res = await query<ConsignmentInventoryRow>(sql, values)
return res.rows[0] || null
const res = await query<{ id: string | number }>(sql, values)
const updatedId = res.rows[0]?.id
// 重新走带 join 的详情查询:RETURNING * 拿不到对接客服姓名,
// 编辑保存后的即时回显会变成空白(列表刷新前)。
return updatedId === undefined ? null : getConsignmentInventoryById(updatedId)
}
/**
@@ -880,9 +910,13 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
const sql = `
SELECT
ci.*,
cs.submission_no AS source_submission_no
cs.submission_no AS source_submission_no,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_inventories ci
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
WHERE ${conditions.join(' AND ')}
ORDER BY ci.id ASC
`
@@ -913,12 +947,12 @@ export async function listConsignmentSubmissions(filters: {
if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`)
conditions.push(
`(cs.item_name ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR cs.contact_info ILIKE $${values.length} OR cs.seller_game_id ILIKE $${values.length} OR cs.seller_phone ILIKE $${values.length})`,
`(cs.item_name ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR cs.contact_info ILIKE $${values.length} OR cs.seller_game_id ILIKE $${values.length} OR cs.seller_phone ILIKE $${values.length} OR ccs.name ILIKE $${values.length})`,
)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_submissions cs ${whereClause}`
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_submissions cs LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id ${whereClause}`
const countRes = await query<{ total: number }>(countSql, values)
const total = countRes.rows[0]?.total || 0
@@ -934,9 +968,13 @@ export async function listConsignmentSubmissions(filters: {
wc.name AS category_name,
wo.acceptance_json AS gift_evidence_json,
wo.work_order_no AS gift_work_order_no,
wo.status AS gift_work_order_status
wo.status AS gift_work_order_status,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_submissions cs
LEFT JOIN work_categories wc ON wc.id = cs.category_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id
LEFT JOIN consignment_inventories rinv ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id)
LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
${whereClause}
@@ -1152,9 +1190,13 @@ export async function listConsignmentSubmissionsByIds(
wc.name AS category_name,
wo.acceptance_json AS gift_evidence_json,
wo.work_order_no AS gift_work_order_no,
wo.status AS gift_work_order_status
wo.status AS gift_work_order_status,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_submissions cs
LEFT JOIN work_categories wc ON wc.id = cs.category_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id
LEFT JOIN consignment_inventories rinv
ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id)
LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
@@ -1179,10 +1221,14 @@ export async function getConsignmentInventoryBySubmissionId(
SELECT
ci.*,
wc.name AS category_name,
cs.submission_no AS source_submission_no
cs.submission_no AS source_submission_no,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
WHERE ci.source_submission_id = $1
ORDER BY ci.id DESC
LIMIT 1
@@ -1201,9 +1247,13 @@ export async function getConsignmentSubmissionById(
wc.name AS category_name,
wo.acceptance_json AS gift_evidence_json,
wo.work_order_no AS gift_work_order_no,
wo.status AS gift_work_order_status
wo.status AS gift_work_order_status,
ccs.name AS contact_supporter_name,
ccs.phone AS contact_supporter_phone,
ccs.wechat AS contact_supporter_wechat
FROM consignment_submissions cs
LEFT JOIN work_categories wc ON wc.id = cs.category_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id
LEFT JOIN consignment_inventories rinv ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id)
LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
WHERE cs.id = $1
@@ -1234,6 +1284,7 @@ export async function createConsignmentSubmission(input: {
workerName?: string
workerPhone?: string
depositAmountFen?: number
contactSupporterId?: number | null
now: string
}): Promise<ConsignmentSubmissionRow> {
return withTransaction((client) => createConsignmentSubmissionWithClient(client, input))
@@ -1262,6 +1313,7 @@ export async function createConsignmentSubmissionWithClient(
workerName?: string
workerPhone?: string
depositAmountFen?: number
contactSupporterId?: number | null
now: string
},
): Promise<ConsignmentSubmissionRow> {
@@ -1272,14 +1324,14 @@ 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,
status, created_at, updated_at
contact_supporter_id, 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,
'pending_review', $21, $21
$21, 'pending_review', $22, $22
)
RETURNING *
`
@@ -1304,6 +1356,7 @@ export async function createConsignmentSubmissionWithClient(
input.workerName || '',
input.workerPhone || '',
input.depositAmountFen || 0,
input.contactSupporterId || null,
input.now,
])
return res.rows[0]!
@@ -1371,6 +1424,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
workerName?: string | undefined
workerPhone?: string | undefined
depositAmountFen?: number | undefined
contactSupporterId?: number | null | undefined
now: string
}): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> {
return withTransaction(async (client: PoolClient) => {
@@ -1391,6 +1445,10 @@ export async function approveConsignmentSubmissionWithInbound(input: {
input.depositAmountFen !== undefined
? input.depositAmountFen
: submission.deposit_amount_fen || 0
const contactSupporterId =
input.contactSupporterId !== undefined
? input.contactSupporterId
: (submission.contact_supporter_id ?? null)
const invSql = `
INSERT INTO consignment_inventories (
@@ -1400,7 +1458,7 @@ 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,
inbound_at, created_at, updated_at
contact_supporter_id, inbound_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
@@ -1408,7 +1466,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
$16, $17::jsonb, $18::jsonb, $19,
TRUE, 'in_stock', '待安排', 'worker_portal',
$20, $21, $22, $23, $24,
$25, $25, $25
$25, $26, $26, $26
)
RETURNING *
`
@@ -1437,6 +1495,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
workerName,
workerPhone,
depositAmountFen,
contactSupporterId,
input.now,
]
const invRes = await client.query<ConsignmentInventoryRow>(invSql, invValues)
@@ -26,3 +26,4 @@ export * from './after-sales-repo.js'
export * from './product-match-repo.js'
export * from './sms-code-repo.js'
export * from './consignment-repo.js'
export * from './consignment-contact-repo.js'
@@ -13,6 +13,13 @@ import {
toggleAdminConsignmentMatching,
updateAdminConsignmentInventory,
} from '../../../services/worker-platform/consignment-admin-service.js'
import {
createAdminConsignmentContact,
deleteAdminConsignmentContact,
fetchAdminConsignmentContacts,
setAdminConsignmentContactEnabled,
updateAdminConsignmentContact,
} from '../../../services/worker-platform/consignment-contact-service.js'
import {
assignWorkOrderWithConsignmentWorker,
checkWorkOrderInventoryMatches,
@@ -54,6 +61,64 @@ router.get(
}),
)
// ---------------- 对接客服名单 ----------------
router.get(
'/worker-platform/consignment/contacts',
requireAdminPermission('consignment.view'),
createJsonHandler((req) => fetchAdminConsignmentContacts(req.query), {
successMessage: 'ok',
errorMessage: '读取对接客服名单失败',
scope: '[admin/consignment/contacts]',
}),
)
router.post(
'/worker-platform/consignment/contacts',
requireAdminPermission('consignment.manage'),
createJsonHandler((req) => createAdminConsignmentContact(req.body || {}), {
successMessage: '对接客服已新增',
errorMessage: '新增对接客服失败',
scope: '[admin/consignment/contacts/create]',
}),
)
router.put(
'/worker-platform/consignment/contacts/:id',
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) => updateAdminConsignmentContact(String(req.params.id || ''), req.body || {}),
{
successMessage: '对接客服已更新',
errorMessage: '更新对接客服失败',
scope: '[admin/consignment/contacts/update]',
},
),
)
router.patch(
'/worker-platform/consignment/contacts/:id/enabled',
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) => setAdminConsignmentContactEnabled(String(req.params.id || ''), req.body?.enabled),
{
successMessage: '对接客服状态已更新',
errorMessage: '更新对接客服状态失败',
scope: '[admin/consignment/contacts/enabled]',
},
),
)
router.delete(
'/worker-platform/consignment/contacts/:id',
requireAdminPermission('consignment.manage'),
createJsonHandler((req) => deleteAdminConsignmentContact(String(req.params.id || '')), {
successMessage: '对接客服已删除',
errorMessage: '删除对接客服失败',
scope: '[admin/consignment/contacts/delete]',
}),
)
router.get(
'/worker-platform/consignment/inventories/:id',
requireAdminPermission('consignment.view'),
@@ -7,11 +7,25 @@ import {
publishWorkerConsignment,
submitWorkerConsignmentGiftEvidence,
} from '../../services/worker-platform/consignment-worker-service.js'
import { fetchWorkerConsignmentContacts } from '../../services/worker-platform/consignment-contact-service.js'
import { sendRouteError } from '../../utils/http.js'
import { getRequiredWorkerSession, requireActiveWorker } from './session.js'
const router = Router()
/**
* GET /api/v1/worker/consignment/contacts
* 打手发布寄卖时的「对接客服」下拉:只返回启用中的名单
*/
router.get('/consignment/contacts', async (req, res) => {
try {
const result = await fetchWorkerConsignmentContacts()
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '获取对接客服失败', '[worker/consignment/contacts]')
}
})
/**
* POST /api/v1/worker/consignment/publish
* 打手在接单平台直接提报苏尔南手枪寄卖
@@ -226,6 +226,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
workerPhone: row.worker_phone || '',
assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null,
depositAmountFen: Number(row.deposit_amount_fen || 0),
contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null,
contactSupporterName: row.contact_supporter_name || '',
contactSupporterPhone: row.contact_supporter_phone || '',
contactSupporterWechat: row.contact_supporter_wechat || '',
inboundAt: row.inbound_at || row.created_at,
consumedAt: row.consumed_at,
createdAt: row.created_at,
@@ -266,6 +270,10 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
workerName: row.worker_name || '',
workerPhone: row.worker_phone || '',
depositAmountFen: Number(row.deposit_amount_fen || 0),
contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null,
contactSupporterName: row.contact_supporter_name || '',
contactSupporterPhone: row.contact_supporter_phone || '',
contactSupporterWechat: row.contact_supporter_wechat || '',
status: row.status,
reviewNote: row.review_note || '',
reviewerName: row.reviewer_name || '',
@@ -407,6 +415,12 @@ export async function updateAdminConsignmentInventory(
workerPhone: payload.workerPhone !== undefined ? String(payload.workerPhone).trim() : undefined,
depositAmountFen:
payload.depositAmountFen !== undefined ? Number(payload.depositAmountFen) : undefined,
contactSupporterId:
payload.contactSupporterId !== undefined
? payload.contactSupporterId
? Number(payload.contactSupporterId)
: null
: undefined,
now,
})
@@ -655,12 +669,25 @@ export async function approveAdminConsignmentSubmission(
workerName,
workerPhone,
depositAmountFen,
contactSupporterId:
payload.contactSupporterId !== undefined
? payload.contactSupporterId
? Number(payload.contactSupporterId)
: null
: undefined,
now,
})
// 入库 INSERT 的 RETURNING * 不带 join 出来的对接客服姓名,这里按 id 重读一次再出参,
// 避免审核成功后前端立即刷新时「对接客服」短暂显示为空。
const [joinedInventory, joinedSubmission] = await Promise.all([
getConsignmentInventoryById(res.inventory.id),
getConsignmentSubmissionById(id),
])
return {
submission: mapConsignmentSubmission(res.submission),
inventory: mapConsignmentInventory(res.inventory),
submission: mapConsignmentSubmission(joinedSubmission || res.submission),
inventory: mapConsignmentInventory(joinedInventory || res.inventory),
}
}
@@ -0,0 +1,232 @@
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import {
countConsignmentContactReferences,
createConsignmentContact,
deleteConsignmentContact,
findConsignmentContactByName,
getConsignmentContactById,
listConsignmentContacts,
listEnabledConsignmentContacts,
setConsignmentContactEnabled,
updateConsignmentContact,
type ConsignmentContactRow,
} from '../../repositories/worker-platform/consignment-contact-repo.js'
export function mapConsignmentContact(row: ConsignmentContactRow) {
return {
id: Number(row.id),
name: row.name,
phone: row.phone || '',
wechat: row.wechat || '',
note: row.note || '',
enabled: Boolean(row.enabled),
sortOrder: Number(row.sort_order || 0),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function normalizeText(value: unknown): string {
return String(value ?? '').trim()
}
function normalizeSortOrder(value: unknown): number {
const num = Number(value)
return Number.isFinite(num) ? Math.round(num) : 0
}
function requireContactName(value: unknown): string {
const name = normalizeText(value)
if (!name) {
throw createHttpError('请填写对接客服姓名', {
statusCode: 400,
errorCode: 'consignment_contact_name_required',
})
}
if (name.length > 60) {
throw createHttpError('对接客服姓名不能超过 60 个字符', {
statusCode: 400,
errorCode: 'consignment_contact_name_too_long',
})
}
return name
}
async function assertContactNameAvailable(name: string, excludeId?: number | string) {
const duplicated = await findConsignmentContactByName(name, excludeId)
if (duplicated) {
throw createHttpError(`已存在同名对接客服「${name}」,请换一个名字`, {
statusCode: 409,
errorCode: 'consignment_contact_name_duplicated',
})
}
}
/** 管理端:完整名单(含禁用),用于配置页。 */
export async function fetchAdminConsignmentContacts(query: JsonObject = {}) {
const keyword = normalizeText(query.keyword)
const enabledRaw = normalizeText(query.enabled)
const enabled = enabledRaw === 'true' ? true : enabledRaw === 'false' ? false : undefined
const rows = await listConsignmentContacts({
keyword: keyword || undefined,
enabled,
})
return {
items: rows.map(mapConsignmentContact),
total: rows.length,
}
}
/** 打手端:发布下拉只取启用项。 */
export async function fetchWorkerConsignmentContacts() {
const rows = await listEnabledConsignmentContacts()
return {
items: rows.map(mapConsignmentContact),
}
}
export async function createAdminConsignmentContact(payload: JsonObject) {
const name = requireContactName(payload.name)
await assertContactNameAvailable(name)
const row = await createConsignmentContact({
name,
phone: normalizeText(payload.phone),
wechat: normalizeText(payload.wechat),
note: normalizeText(payload.note),
enabled: payload.enabled !== false,
sortOrder: normalizeSortOrder(payload.sortOrder),
now: nowIso(),
})
return mapConsignmentContact(row)
}
export async function updateAdminConsignmentContact(id: number | string, payload: JsonObject) {
const current = await getConsignmentContactById(id)
if (!current) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
const name = payload.name !== undefined ? requireContactName(payload.name) : undefined
if (name !== undefined) {
await assertContactNameAvailable(name, id)
}
const updated = await updateConsignmentContact(id, {
name,
phone: payload.phone !== undefined ? normalizeText(payload.phone) : undefined,
wechat: payload.wechat !== undefined ? normalizeText(payload.wechat) : undefined,
note: payload.note !== undefined ? normalizeText(payload.note) : undefined,
sortOrder: payload.sortOrder !== undefined ? normalizeSortOrder(payload.sortOrder) : undefined,
now: nowIso(),
})
if (!updated) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
return mapConsignmentContact(updated)
}
export async function setAdminConsignmentContactEnabled(
id: number | string,
enabledValue: unknown,
) {
if (typeof enabledValue !== 'boolean') {
throw createHttpError('启用状态必须是布尔值', {
statusCode: 400,
errorCode: 'consignment_contact_enabled_invalid',
})
}
const enabled = enabledValue
const current = await getConsignmentContactById(id)
if (!current) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
if (!enabled && current.enabled) {
const enabledRows = await listEnabledConsignmentContacts()
if (enabledRows.length <= 1) {
throw createHttpError('至少保留一个启用中的对接客服,否则打手无法发布寄卖', {
statusCode: 409,
errorCode: 'consignment_contact_last_enabled',
})
}
}
const updated = await setConsignmentContactEnabled(id, enabled, nowIso())
if (!updated) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
return mapConsignmentContact(updated)
}
export async function deleteAdminConsignmentContact(id: number | string) {
const current = await getConsignmentContactById(id)
if (!current) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
const referenceCount = await countConsignmentContactReferences(id)
if (referenceCount > 0) {
throw createHttpError(
`该对接客服已被 ${referenceCount} 条寄卖记录使用,不能删除;请改为「禁用」,历史记录仍会保留。`,
{ statusCode: 409, errorCode: 'consignment_contact_in_use' },
)
}
if (current.enabled) {
const enabledRows = await listEnabledConsignmentContacts()
if (enabledRows.length <= 1) {
throw createHttpError('至少保留一个启用中的对接客服,请先新增并启用其他客服', {
statusCode: 409,
errorCode: 'consignment_contact_last_enabled',
})
}
}
const deleted = await deleteConsignmentContact(id)
if (!deleted) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
return { deleted: true }
}
/**
* 发布寄卖时校验「对接客服」:必填 + 必须存在于启用名单。
* 返回整行,调用方取 id 落库即可。
*/
export async function resolveEnabledConsignmentContact(
contactSupporterId: unknown,
): Promise<ConsignmentContactRow> {
const id = Number(contactSupporterId)
if (!Number.isFinite(id) || id <= 0) {
throw createHttpError('请选择对接客服', {
statusCode: 400,
errorCode: 'consignment_contact_required',
})
}
const row = await getConsignmentContactById(id)
if (!row || !row.enabled) {
throw createHttpError('对接客服不存在或已停用,请刷新后重新选择', {
statusCode: 400,
errorCode: 'consignment_contact_invalid',
})
}
return row
}
@@ -507,6 +507,12 @@ export async function fulfillWorkOrderWithConsignment(input: {
sellerGameId: inv.seller_game_id || '',
sellerContact: inv.seller_contact || '',
sellerPhone: inv.seller_phone || '',
contactSupporterId: inventory.contact_supporter_id
? Number(inventory.contact_supporter_id)
: null,
contactSupporterName: inventory.contact_supporter_name || '',
contactSupporterPhone: inventory.contact_supporter_phone || '',
contactSupporterWechat: inventory.contact_supporter_wechat || '',
workerName: inv.worker_name || '',
workerPhone: inv.worker_phone || '',
remark: inv.remark || '',
@@ -552,6 +558,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
priceAdjustReason: '寄卖内部现货履约按现货结算成本价更新发单金额',
buyerName: inv.buyer_name,
shopName,
contactSupporterId: inventory.contact_supporter_id
? Number(inventory.contact_supporter_id)
: null,
contactSupporterName: inventory.contact_supporter_name || '',
matchType:
specMatch?.matchType || (input.allowSpecMismatch === true ? 'conflict' : 'exact'),
manualMatch: Boolean(matchNote) || input.allowSpecMismatch === true,
@@ -757,6 +767,12 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
sellerGameId: claimed.seller_game_id || '',
sellerContact: claimed.seller_contact || '',
sellerPhone: claimed.seller_phone || '',
contactSupporterId: inventory.contact_supporter_id
? Number(inventory.contact_supporter_id)
: null,
contactSupporterName: inventory.contact_supporter_name || '',
contactSupporterPhone: inventory.contact_supporter_phone || '',
contactSupporterWechat: inventory.contact_supporter_wechat || '',
workerId: Number(targetWorkerId),
workerName: claimed.worker_name || '',
workerPhone: claimed.worker_phone || '',
@@ -15,6 +15,7 @@ import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-ord
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 { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
import type { JsonObject } from '../../types/json.js'
@@ -43,6 +44,21 @@ function toRecord(value: unknown): Record<string, unknown> | null {
: null
}
/** 提报单 / 现货都可能带对接客服信息,统一成前端字段。 */
function mapConsignmentContactSupporter(source: {
contact_supporter_id?: string | number | null
contact_supporter_name?: string
contact_supporter_phone?: string
contact_supporter_wechat?: string
}) {
return {
contactSupporterId: source.contact_supporter_id ? Number(source.contact_supporter_id) : null,
contactSupporterName: source.contact_supporter_name || '',
contactSupporterPhone: source.contact_supporter_phone || '',
contactSupporterWechat: source.contact_supporter_wechat || '',
}
}
function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) {
if (!boundWo) {
return {
@@ -162,6 +178,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
// 对接客服必填:发布时选定,后续按人统计提成 / 方便联系。
const contactSupporter = await resolveEnabledConsignmentContact(payload.contactSupporterId)
const system = String(payload.system || '').trim()
const serverZone = String(payload.serverZone || '').trim()
const series = String(payload.series || '').trim()
@@ -298,6 +317,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
depositAmountFen,
contactSupporterId: Number(contactSupporter.id),
now,
})
@@ -330,6 +350,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
workerName: submission.worker_name,
workerPhone: submission.worker_phone,
depositAmountFen: submission.deposit_amount_fen,
contactSupporterId: submission.contact_supporter_id
? Number(submission.contact_supporter_id)
: null,
contactSupporterName: contactSupporter.name,
contactSupporterPhone: contactSupporter.phone || '',
contactSupporterWechat: contactSupporter.wechat || '',
createdAt: submission.created_at,
}
} catch (error) {
@@ -438,6 +464,8 @@ export async function listWorkerConsignments(
const evidence = showingMatchedBuyer
? extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
// 现货上的对接客服优先级更高:客服可能在审核入库后改派对接人,卖家端应看到最新归属。
const contactSupporter = inv?.contact_supporter_id ? inv : sub
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
@@ -452,6 +480,7 @@ export async function listWorkerConsignments(
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen,
...mapConsignmentContactSupporter(contactSupporter),
reviewNote: sub.review_note,
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
@@ -496,6 +525,7 @@ export async function listWorkerConsignments(
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen,
...mapConsignmentContactSupporter(inv),
reviewNote: '',
screenshotsJson: [],
giftEvidenceImages: evidence.giftEvidenceImages,
@@ -62,3 +62,4 @@ export * from './worker-announcement-config-service.js'
export * from './worker-hall-config-service.js'
export * from './worker-acceptance-config-service.js'
export * from './sync-worker-orders-service.js'
export * from './consignment-contact-service.js'