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 的枚举注释。 -- 「取消部分」把被撤销的份额标记为 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 worker_phone?: string
assigned_worker_id?: string | number | null assigned_worker_id?: string | number | null
deposit_amount_fen?: number 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 inbound_at: string
consumed_at: string | null consumed_at: string | null
created_at: string created_at: string
@@ -79,6 +84,11 @@ export type ConsignmentSubmissionRow = {
worker_name?: string worker_name?: string
worker_phone?: string worker_phone?: string
deposit_amount_fen?: number 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' status: 'pending_review' | 'approved' | 'rejected'
review_note: string review_note: string
reviewer_name: string reviewer_name: string
@@ -175,13 +185,13 @@ export async function listConsignmentInventories(filters: {
if (filters.keyword) { if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`) values.push(`%${filters.keyword.trim()}%`)
conditions.push( 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 ')}` : '' const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
// 计数也要带上提报单 join:关键字现在包含 cs.submission_no(卖家端主标识就是它) // 计数也要带上提报单 / 对接客服 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 ${whereClause}` 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 countRes = await query<{ total: number }>(countSql, values)
const total = countRes.rows[0]?.total || 0 const total = countRes.rows[0]?.total || 0
@@ -202,10 +212,14 @@ export async function listConsignmentInventories(filters: {
wo.gift_phase AS gift_phase, wo.gift_phase AS gift_phase,
wo.friend_added_at AS gift_friend_added_at, wo.friend_added_at AS gift_friend_added_at,
wo.material_json AS bound_work_order_material_json, 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 FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id 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_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 LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
${whereClause} ${whereClause}
ORDER BY ci.id DESC ORDER BY ci.id DESC
@@ -321,10 +335,14 @@ export async function getConsignmentInventoryById(
wo.gift_phase AS gift_phase, wo.gift_phase AS gift_phase,
wo.friend_added_at AS gift_friend_added_at, wo.friend_added_at AS gift_friend_added_at,
wo.material_json AS bound_work_order_material_json, 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 FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id 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_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 LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE ci.id = $1 WHERE ci.id = $1
LIMIT 1 LIMIT 1
@@ -344,10 +362,14 @@ export async function listConsignmentInventoriesByIds(
ci.*, ci.*,
wc.name AS category_name, wc.name AS category_name,
cs.submission_no AS source_submission_no, 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 FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id 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_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[]) WHERE ci.id = ANY($1::bigint[])
` `
const res = await query<ConsignmentInventoryRow>(sql, [numericIds]) const res = await query<ConsignmentInventoryRow>(sql, [numericIds])
@@ -379,6 +401,7 @@ export async function updateConsignmentInventory(
workerName?: string | undefined workerName?: string | undefined
workerPhone?: string | undefined workerPhone?: string | undefined
depositAmountFen?: number | undefined depositAmountFen?: number | undefined
contactSupporterId?: number | null | undefined
now: string now: string
}, },
): Promise<ConsignmentInventoryRow | null> { ): Promise<ConsignmentInventoryRow | null> {
@@ -473,6 +496,10 @@ export async function updateConsignmentInventory(
values.push(input.depositAmountFen) values.push(input.depositAmountFen)
updates.push(`deposit_amount_fen = $${values.length}`) 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) if (updates.length === 0) return getConsignmentInventoryById(id)
@@ -484,10 +511,13 @@ export async function updateConsignmentInventory(
UPDATE consignment_inventories UPDATE consignment_inventories
SET ${updates.join(', ')} SET ${updates.join(', ')}
WHERE id = $${values.length} WHERE id = $${values.length}
RETURNING * RETURNING id
` `
const res = await query<ConsignmentInventoryRow>(sql, values) const res = await query<{ id: string | number }>(sql, values)
return res.rows[0] || null 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 = ` const sql = `
SELECT SELECT
ci.*, 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 FROM consignment_inventories ci
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_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 ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
ORDER BY ci.id ASC ORDER BY ci.id ASC
` `
@@ -913,12 +947,12 @@ export async function listConsignmentSubmissions(filters: {
if (filters.keyword) { if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`) values.push(`%${filters.keyword.trim()}%`)
conditions.push( 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 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 countRes = await query<{ total: number }>(countSql, values)
const total = countRes.rows[0]?.total || 0 const total = countRes.rows[0]?.total || 0
@@ -934,9 +968,13 @@ export async function listConsignmentSubmissions(filters: {
wc.name AS category_name, wc.name AS category_name,
wo.acceptance_json AS gift_evidence_json, wo.acceptance_json AS gift_evidence_json,
wo.work_order_no AS gift_work_order_no, 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 FROM consignment_submissions cs
LEFT JOIN work_categories wc ON wc.id = cs.category_id 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 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 LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
${whereClause} ${whereClause}
@@ -1152,9 +1190,13 @@ export async function listConsignmentSubmissionsByIds(
wc.name AS category_name, wc.name AS category_name,
wo.acceptance_json AS gift_evidence_json, wo.acceptance_json AS gift_evidence_json,
wo.work_order_no AS gift_work_order_no, 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 FROM consignment_submissions cs
LEFT JOIN work_categories wc ON wc.id = cs.category_id 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 LEFT JOIN consignment_inventories rinv
ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id) 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 LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
@@ -1179,10 +1221,14 @@ export async function getConsignmentInventoryBySubmissionId(
SELECT SELECT
ci.*, ci.*,
wc.name AS category_name, 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 FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id 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_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 WHERE ci.source_submission_id = $1
ORDER BY ci.id DESC ORDER BY ci.id DESC
LIMIT 1 LIMIT 1
@@ -1201,9 +1247,13 @@ export async function getConsignmentSubmissionById(
wc.name AS category_name, wc.name AS category_name,
wo.acceptance_json AS gift_evidence_json, wo.acceptance_json AS gift_evidence_json,
wo.work_order_no AS gift_work_order_no, 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 FROM consignment_submissions cs
LEFT JOIN work_categories wc ON wc.id = cs.category_id 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 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 LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
WHERE cs.id = $1 WHERE cs.id = $1
@@ -1234,6 +1284,7 @@ export async function createConsignmentSubmission(input: {
workerName?: string workerName?: string
workerPhone?: string workerPhone?: string
depositAmountFen?: number depositAmountFen?: number
contactSupporterId?: number | null
now: string now: string
}): Promise<ConsignmentSubmissionRow> { }): Promise<ConsignmentSubmissionRow> {
return withTransaction((client) => createConsignmentSubmissionWithClient(client, input)) return withTransaction((client) => createConsignmentSubmissionWithClient(client, input))
@@ -1262,6 +1313,7 @@ export async function createConsignmentSubmissionWithClient(
workerName?: string workerName?: string
workerPhone?: string workerPhone?: string
depositAmountFen?: number depositAmountFen?: number
contactSupporterId?: number | null
now: string now: string
}, },
): Promise<ConsignmentSubmissionRow> { ): Promise<ConsignmentSubmissionRow> {
@@ -1272,14 +1324,14 @@ export async function createConsignmentSubmissionWithClient(
seller_game_id, seller_payment_info, seller_phone, contact_info, seller_game_id, seller_payment_info, seller_phone, contact_info,
content_json, screenshots_json, asking_price_fen, content_json, screenshots_json, asking_price_fen,
worker_id, worker_name, worker_phone, deposit_amount_fen, worker_id, worker_name, worker_phone, deposit_amount_fen,
status, created_at, updated_at contact_supporter_id, status, created_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $1, $2, $3, $4,
$5, $6, $7, $8, $9, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $10, $11, $12, $13,
$14::jsonb, $15::jsonb, $16, $14::jsonb, $15::jsonb, $16,
$17, $18, $19, $20, $17, $18, $19, $20,
'pending_review', $21, $21 $21, 'pending_review', $22, $22
) )
RETURNING * RETURNING *
` `
@@ -1304,6 +1356,7 @@ export async function createConsignmentSubmissionWithClient(
input.workerName || '', input.workerName || '',
input.workerPhone || '', input.workerPhone || '',
input.depositAmountFen || 0, input.depositAmountFen || 0,
input.contactSupporterId || null,
input.now, input.now,
]) ])
return res.rows[0]! return res.rows[0]!
@@ -1371,6 +1424,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
workerName?: string | undefined workerName?: string | undefined
workerPhone?: string | undefined workerPhone?: string | undefined
depositAmountFen?: number | undefined depositAmountFen?: number | undefined
contactSupporterId?: number | null | undefined
now: string now: string
}): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> { }): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> {
return withTransaction(async (client: PoolClient) => { return withTransaction(async (client: PoolClient) => {
@@ -1391,6 +1445,10 @@ export async function approveConsignmentSubmissionWithInbound(input: {
input.depositAmountFen !== undefined input.depositAmountFen !== undefined
? input.depositAmountFen ? input.depositAmountFen
: submission.deposit_amount_fen || 0 : submission.deposit_amount_fen || 0
const contactSupporterId =
input.contactSupporterId !== undefined
? input.contactSupporterId
: (submission.contact_supporter_id ?? null)
const invSql = ` const invSql = `
INSERT INTO consignment_inventories ( INSERT INTO consignment_inventories (
@@ -1400,7 +1458,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
seller_phone, credentials_json, screenshots_json, remark, seller_phone, credentials_json, screenshots_json, remark,
allow_matching, status, allocation_status, source_type, allow_matching, status, allocation_status, source_type,
source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen, 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 ( ) VALUES (
$1, $2, $3, $4, $5, $6, $1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $7, $8, $9, $10, $11,
@@ -1408,7 +1466,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
$16, $17::jsonb, $18::jsonb, $19, $16, $17::jsonb, $18::jsonb, $19,
TRUE, 'in_stock', '待安排', 'worker_portal', TRUE, 'in_stock', '待安排', 'worker_portal',
$20, $21, $22, $23, $24, $20, $21, $22, $23, $24,
$25, $25, $25 $25, $26, $26, $26
) )
RETURNING * RETURNING *
` `
@@ -1437,6 +1495,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
workerName, workerName,
workerPhone, workerPhone,
depositAmountFen, depositAmountFen,
contactSupporterId,
input.now, input.now,
] ]
const invRes = await client.query<ConsignmentInventoryRow>(invSql, invValues) 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 './product-match-repo.js'
export * from './sms-code-repo.js' export * from './sms-code-repo.js'
export * from './consignment-repo.js' export * from './consignment-repo.js'
export * from './consignment-contact-repo.js'
@@ -13,6 +13,13 @@ import {
toggleAdminConsignmentMatching, toggleAdminConsignmentMatching,
updateAdminConsignmentInventory, updateAdminConsignmentInventory,
} from '../../../services/worker-platform/consignment-admin-service.js' } from '../../../services/worker-platform/consignment-admin-service.js'
import {
createAdminConsignmentContact,
deleteAdminConsignmentContact,
fetchAdminConsignmentContacts,
setAdminConsignmentContactEnabled,
updateAdminConsignmentContact,
} from '../../../services/worker-platform/consignment-contact-service.js'
import { import {
assignWorkOrderWithConsignmentWorker, assignWorkOrderWithConsignmentWorker,
checkWorkOrderInventoryMatches, 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( router.get(
'/worker-platform/consignment/inventories/:id', '/worker-platform/consignment/inventories/:id',
requireAdminPermission('consignment.view'), requireAdminPermission('consignment.view'),
@@ -7,11 +7,25 @@ import {
publishWorkerConsignment, publishWorkerConsignment,
submitWorkerConsignmentGiftEvidence, submitWorkerConsignmentGiftEvidence,
} from '../../services/worker-platform/consignment-worker-service.js' } 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 { sendRouteError } from '../../utils/http.js'
import { getRequiredWorkerSession, requireActiveWorker } from './session.js' import { getRequiredWorkerSession, requireActiveWorker } from './session.js'
const router = Router() 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 * POST /api/v1/worker/consignment/publish
* 打手在接单平台直接提报苏尔南手枪寄卖 * 打手在接单平台直接提报苏尔南手枪寄卖
@@ -226,6 +226,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
workerPhone: row.worker_phone || '', workerPhone: row.worker_phone || '',
assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null, assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null,
depositAmountFen: Number(row.deposit_amount_fen || 0), 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, inboundAt: row.inbound_at || row.created_at,
consumedAt: row.consumed_at, consumedAt: row.consumed_at,
createdAt: row.created_at, createdAt: row.created_at,
@@ -266,6 +270,10 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
workerName: row.worker_name || '', workerName: row.worker_name || '',
workerPhone: row.worker_phone || '', workerPhone: row.worker_phone || '',
depositAmountFen: Number(row.deposit_amount_fen || 0), 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, status: row.status,
reviewNote: row.review_note || '', reviewNote: row.review_note || '',
reviewerName: row.reviewer_name || '', reviewerName: row.reviewer_name || '',
@@ -407,6 +415,12 @@ export async function updateAdminConsignmentInventory(
workerPhone: payload.workerPhone !== undefined ? String(payload.workerPhone).trim() : undefined, workerPhone: payload.workerPhone !== undefined ? String(payload.workerPhone).trim() : undefined,
depositAmountFen: depositAmountFen:
payload.depositAmountFen !== undefined ? Number(payload.depositAmountFen) : undefined, payload.depositAmountFen !== undefined ? Number(payload.depositAmountFen) : undefined,
contactSupporterId:
payload.contactSupporterId !== undefined
? payload.contactSupporterId
? Number(payload.contactSupporterId)
: null
: undefined,
now, now,
}) })
@@ -655,12 +669,25 @@ export async function approveAdminConsignmentSubmission(
workerName, workerName,
workerPhone, workerPhone,
depositAmountFen, depositAmountFen,
contactSupporterId:
payload.contactSupporterId !== undefined
? payload.contactSupporterId
? Number(payload.contactSupporterId)
: null
: undefined,
now, now,
}) })
// 入库 INSERT 的 RETURNING * 不带 join 出来的对接客服姓名,这里按 id 重读一次再出参,
// 避免审核成功后前端立即刷新时「对接客服」短暂显示为空。
const [joinedInventory, joinedSubmission] = await Promise.all([
getConsignmentInventoryById(res.inventory.id),
getConsignmentSubmissionById(id),
])
return { return {
submission: mapConsignmentSubmission(res.submission), submission: mapConsignmentSubmission(joinedSubmission || res.submission),
inventory: mapConsignmentInventory(res.inventory), 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 || '', sellerGameId: inv.seller_game_id || '',
sellerContact: inv.seller_contact || '', sellerContact: inv.seller_contact || '',
sellerPhone: inv.seller_phone || '', 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 || '', workerName: inv.worker_name || '',
workerPhone: inv.worker_phone || '', workerPhone: inv.worker_phone || '',
remark: inv.remark || '', remark: inv.remark || '',
@@ -552,6 +558,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
priceAdjustReason: '寄卖内部现货履约按现货结算成本价更新发单金额', priceAdjustReason: '寄卖内部现货履约按现货结算成本价更新发单金额',
buyerName: inv.buyer_name, buyerName: inv.buyer_name,
shopName, shopName,
contactSupporterId: inventory.contact_supporter_id
? Number(inventory.contact_supporter_id)
: null,
contactSupporterName: inventory.contact_supporter_name || '',
matchType: matchType:
specMatch?.matchType || (input.allowSpecMismatch === true ? 'conflict' : 'exact'), specMatch?.matchType || (input.allowSpecMismatch === true ? 'conflict' : 'exact'),
manualMatch: Boolean(matchNote) || input.allowSpecMismatch === true, manualMatch: Boolean(matchNote) || input.allowSpecMismatch === true,
@@ -757,6 +767,12 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
sellerGameId: claimed.seller_game_id || '', sellerGameId: claimed.seller_game_id || '',
sellerContact: claimed.seller_contact || '', sellerContact: claimed.seller_contact || '',
sellerPhone: claimed.seller_phone || '', 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), workerId: Number(targetWorkerId),
workerName: claimed.worker_name || '', workerName: claimed.worker_name || '',
workerPhone: claimed.worker_phone || '', 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 type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js' import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js' import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js'
import { resolveEnabledConsignmentContact } from './consignment-contact-service.js'
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js' import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js' import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
import type { JsonObject } from '../../types/json.js' import type { JsonObject } from '../../types/json.js'
@@ -43,6 +44,21 @@ function toRecord(value: unknown): Record<string, unknown> | null {
: 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) { function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) {
if (!boundWo) { if (!boundWo) {
return { 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 system = String(payload.system || '').trim()
const serverZone = String(payload.serverZone || '').trim() const serverZone = String(payload.serverZone || '').trim()
const series = String(payload.series || '').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, workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '', workerPhone: sellerPhone || worker.phone || '',
depositAmountFen, depositAmountFen,
contactSupporterId: Number(contactSupporter.id),
now, now,
}) })
@@ -330,6 +350,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
workerName: submission.worker_name, workerName: submission.worker_name,
workerPhone: submission.worker_phone, workerPhone: submission.worker_phone,
depositAmountFen: submission.deposit_amount_fen, 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, createdAt: submission.created_at,
} }
} catch (error) { } catch (error) {
@@ -438,6 +464,8 @@ export async function listWorkerConsignments(
const evidence = showingMatchedBuyer const evidence = showingMatchedBuyer
? extractConsignmentGiftEvidenceFromWorkOrder(boundWo) ? extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' } : { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
// 现货上的对接客服优先级更高:客服可能在审核入库后改派对接人,卖家端应看到最新归属。
const contactSupporter = inv?.contact_supporter_id ? inv : sub
return { return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */ /** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no, consignmentNo: sub.submission_no,
@@ -452,6 +480,7 @@ export async function listWorkerConsignments(
brillianceCount: sub.brilliance_count, brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen, askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen, depositAmountFen: sub.deposit_amount_fen,
...mapConsignmentContactSupporter(contactSupporter),
reviewNote: sub.review_note, reviewNote: sub.review_note,
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图 // 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : []) screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
@@ -496,6 +525,7 @@ export async function listWorkerConsignments(
brillianceCount: inv.brilliance_count, brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen, askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen, depositAmountFen: inv.deposit_amount_fen,
...mapConsignmentContactSupporter(inv),
reviewNote: '', reviewNote: '',
screenshotsJson: [], screenshotsJson: [],
giftEvidenceImages: evidence.giftEvidenceImages, giftEvidenceImages: evidence.giftEvidenceImages,
@@ -62,3 +62,4 @@ export * from './worker-announcement-config-service.js'
export * from './worker-hall-config-service.js' export * from './worker-hall-config-service.js'
export * from './worker-acceptance-config-service.js' export * from './worker-acceptance-config-service.js'
export * from './sync-worker-orders-service.js' export * from './sync-worker-orders-service.js'
export * from './consignment-contact-service.js'
+4
View File
@@ -24,6 +24,9 @@ const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage')) const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage'))
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage')) const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage')) const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
const AdminConsignmentContactsPage = lazy(
() => import('@/pages/admin/AdminConsignmentContactsPage'),
)
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage')) const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage')) const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
@@ -166,6 +169,7 @@ function AdminApplication() {
</Route> </Route>
<Route element={<RequireRole roles={['admin']} />}> <Route element={<RequireRole roles={['admin']} />}>
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="consignment-contacts" element={<AdminConsignmentContactsPage />} />
<Route path="platform-shops" element={<AdminPlatformShopsPage />} /> <Route path="platform-shops" element={<AdminPlatformShopsPage />} />
<Route path="platform-fulfillment" element={<AdminPlatformFulfillmentPage />} /> <Route path="platform-fulfillment" element={<AdminPlatformFulfillmentPage />} />
<Route path="audit-logs" element={<AdminAuditLogsPage />} /> <Route path="audit-logs" element={<AdminAuditLogsPage />} />
@@ -28,6 +28,7 @@ import {
resolveManualMatchRequirement, resolveManualMatchRequirement,
type ManualMatchType, type ManualMatchType,
} from '@/utils/consignment-manual-match' } from '@/utils/consignment-manual-match'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatDateTime } from '@/utils/date-time' import { formatDateTime } from '@/utils/date-time'
import { notifyVoucherConsume } from '@/utils/voucher-consume' import { notifyVoucherConsume } from '@/utils/voucher-consume'
@@ -45,6 +46,10 @@ type Selected = {
/** 供货打手(用于回显供货来源) */ /** 供货打手(用于回显供货来源) */
workerId?: number | null workerId?: number | null
workerName?: string workerName?: string
/** 对接客服(用于履约前核对归属) */
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
} }
export type ConsignmentManualMatchModalProps = { export type ConsignmentManualMatchModalProps = {
@@ -79,6 +84,11 @@ export default function ConsignmentManualMatchModal({
workerId: number | null workerId: number | null
workerName: string workerName: string
} | null>(null) } | null>(null)
const [inventoryContact, setInventoryContact] = useState<{
contactSupporterName: string
contactSupporterPhone: string
contactSupporterWechat: string
} | null>(null)
const [selected, setSelected] = useState<Selected | null>(null) const [selected, setSelected] = useState<Selected | null>(null)
const [confirmedPartial, setConfirmedPartial] = useState(false) const [confirmedPartial, setConfirmedPartial] = useState(false)
const [confirmedMismatch, setConfirmedMismatch] = useState(false) const [confirmedMismatch, setConfirmedMismatch] = useState(false)
@@ -94,6 +104,7 @@ export default function ConsignmentManualMatchModal({
setConfirmedMismatch(false) setConfirmedMismatch(false)
setMatchNote('') setMatchNote('')
setInventoryInboundAt('') setInventoryInboundAt('')
setInventoryContact(null)
} }
const load = async () => { const load = async () => {
@@ -116,6 +127,11 @@ export default function ConsignmentManualMatchModal({
workerId: res.data?.inventory?.workerId ?? null, workerId: res.data?.inventory?.workerId ?? null,
workerName: res.data?.inventory?.workerName || '', workerName: res.data?.inventory?.workerName || '',
}) })
setInventoryContact({
contactSupporterName: res.data?.inventory?.contactSupporterName || '',
contactSupporterPhone: res.data?.inventory?.contactSupporterPhone || '',
contactSupporterWechat: res.data?.inventory?.contactSupporterWechat || '',
})
} else if (workOrder) { } else if (workOrder) {
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId) const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
setOptions(res.data?.options || []) setOptions(res.data?.options || [])
@@ -319,7 +335,8 @@ export default function ConsignmentManualMatchModal({
? `${option.inventory.diamondLevel}钻` ? `${option.inventory.diamondLevel}钻`
: ''}{' '} : ''}{' '}
· 货源 {option.inventory.workerName || '未关联打手'} · 结算价 ¥ · 货源 {option.inventory.workerName || '未关联打手'} · 结算价 ¥
{(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)} {(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)} ·
对接客服 {formatConsignmentContactSupporter(option.inventory).text}
{option.inventory.inboundAt || option.inventory.createdAt ? ( {option.inventory.inboundAt || option.inventory.createdAt ? (
<> <>
{' '} {' '}
@@ -356,6 +373,9 @@ export default function ConsignmentManualMatchModal({
conflicts: option.conflicts, conflicts: option.conflicts,
workerId: option.inventory.workerId, workerId: option.inventory.workerId,
workerName: option.inventory.workerName, workerName: option.inventory.workerName,
contactSupporterName: option.inventory.contactSupporterName,
contactSupporterPhone: option.inventory.contactSupporterPhone,
contactSupporterWechat: option.inventory.contactSupporterWechat,
}) })
setConfirmedPartial(false) setConfirmedPartial(false)
setConfirmedMismatch(false) setConfirmedMismatch(false)
@@ -439,6 +459,9 @@ export default function ConsignmentManualMatchModal({
conflicts: order.conflicts, conflicts: order.conflicts,
workerId: inventoryWorker?.workerId ?? null, workerId: inventoryWorker?.workerId ?? null,
workerName: inventoryWorker?.workerName || '', workerName: inventoryWorker?.workerName || '',
contactSupporterName: inventoryContact?.contactSupporterName || '',
contactSupporterPhone: inventoryContact?.contactSupporterPhone || '',
contactSupporterWechat: inventoryContact?.contactSupporterWechat || '',
}) })
setConfirmedPartial(false) setConfirmedPartial(false)
setConfirmedMismatch(false) setConfirmedMismatch(false)
@@ -492,6 +515,9 @@ export default function ConsignmentManualMatchModal({
<Tag color="default">内部库存现货(无打手关联)</Tag> <Tag color="default">内部库存现货(无打手关联)</Tag>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="对接客服">
{formatConsignmentContactSupporter(selected).text}
</Descriptions.Item>
<Descriptions.Item label="匹配判定"> <Descriptions.Item label="匹配判定">
<Tag color={describeManualMatchType(selected.matchType).color}> <Tag color={describeManualMatchType(selected.matchType).color}>
{describeManualMatchType(selected.matchType).label} {describeManualMatchType(selected.matchType).label}
@@ -4,6 +4,7 @@ import {
ApartmentOutlined, ApartmentOutlined,
BellOutlined, BellOutlined,
BugOutlined, BugOutlined,
CustomerServiceOutlined,
DashboardOutlined, DashboardOutlined,
FileSearchOutlined, FileSearchOutlined,
LogoutOutlined, LogoutOutlined,
@@ -282,6 +283,11 @@ export default function AdminLayout() {
label: '配置', label: '配置',
children: [ children: [
{ key: '/admin/users', icon: <TeamOutlined />, label: '后台用户' }, { key: '/admin/users', icon: <TeamOutlined />, label: '后台用户' },
{
key: '/admin/consignment-contacts',
icon: <CustomerServiceOutlined />,
label: '对接客服',
},
{ key: '/admin/platform-shops', icon: <SettingOutlined />, label: '平台配置' }, { key: '/admin/platform-shops', icon: <SettingOutlined />, label: '平台配置' },
{ key: '/admin/platform-fulfillment', icon: <ShopOutlined />, label: '履约配置' }, { key: '/admin/platform-fulfillment', icon: <ShopOutlined />, label: '履约配置' },
], ],
@@ -850,6 +856,7 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/admin/finance')) return '/admin/finance' if (pathname.startsWith('/admin/finance')) return '/admin/finance'
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks' if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform' if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
if (pathname.startsWith('/admin/consignment-contacts')) return '/admin/consignment-contacts'
if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry' if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry'
if (pathname === '/admin/platform-shops') return '/admin/platform-shops' if (pathname === '/admin/platform-shops') return '/admin/platform-shops'
return pathname return pathname
@@ -0,0 +1,343 @@
import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Tag,
Typography,
} from 'antd'
import { useEffect, useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import PercentTable from '@/components/common/PercentTable'
import {
createAdminConsignmentContact,
deleteAdminConsignmentContact,
fetchAdminConsignmentContacts,
setAdminConsignmentContactEnabled,
updateAdminConsignmentContact,
} from '@/services/admin/consignment'
import type { ConsignmentContactSupporter } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
import type { TableColumnsType } from 'antd'
const { Text } = Typography
type ContactForm = {
name: string
phone?: string
wechat?: string
note?: string
sortOrder?: number
enabled?: boolean
}
const ENABLED_FILTER_OPTIONS = [
{ label: '全部状态', value: '' },
{ label: '启用中', value: 'true' },
{ label: '已禁用', value: 'false' },
]
/**
* 配置 → 对接客服。
*
* 独立名单(不复用后台账号):打手发布寄卖时必选,后台按人统计提成与快速联系。
* 已被寄卖记录引用的客服只能禁用,不能删除,保证历史单归属不丢。
*/
export default function AdminConsignmentContactsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ContactForm>()
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
const [enabledFilter, setEnabledFilter] = useState('')
const [editingContact, setEditingContact] = useState<ConsignmentContactSupporter | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [actionId, setActionId] = useState<number | null>(null)
useEffect(() => {
const timer = window.setTimeout(() => setKeyword(keywordInput.trim()), 300)
return () => window.clearTimeout(timer)
}, [keywordInput])
const query = useQuery({
queryKey: ['admin-consignment-contacts', keyword, enabledFilter],
queryFn: () =>
fetchAdminConsignmentContacts({
keyword: keyword || undefined,
enabled: enabledFilter || undefined,
}),
})
const items = query.data?.data.items || []
const total = Number(query.data?.data.total || 0)
const enabledCount = items.filter((item) => item.enabled).length
async function reload() {
await queryClient.invalidateQueries({ queryKey: ['admin-consignment-contacts'] })
}
function openCreate() {
setEditingContact(null)
form.resetFields()
form.setFieldsValue({ enabled: true, sortOrder: 0 })
setFormOpen(true)
}
function openEdit(record: ConsignmentContactSupporter) {
setEditingContact(record)
form.setFieldsValue({
name: record.name,
phone: record.phone,
wechat: record.wechat,
note: record.note,
sortOrder: record.sortOrder,
enabled: record.enabled,
})
setFormOpen(true)
}
async function handleSubmit() {
try {
const values = await form.validateFields()
setSubmitting(true)
const payload = {
name: values.name,
phone: values.phone || '',
wechat: values.wechat || '',
note: values.note || '',
sortOrder: Number(values.sortOrder || 0),
enabled: values.enabled !== false,
}
if (editingContact) {
await updateAdminConsignmentContact(editingContact.id, payload)
message.success('对接客服已更新')
} else {
await createAdminConsignmentContact(payload)
message.success('对接客服已新增')
}
setFormOpen(false)
setEditingContact(null)
await reload()
} catch (error: any) {
if (error?.errorFields) return
message.error(error?.message || '保存对接客服失败')
} finally {
setSubmitting(false)
}
}
async function handleToggleEnabled(record: ConsignmentContactSupporter, enabled: boolean) {
setActionId(record.id)
try {
await setAdminConsignmentContactEnabled(record.id, enabled)
message.success(enabled ? '已启用该对接客服' : '已禁用该对接客服')
await reload()
} catch (error: any) {
message.error(error?.message || '更新对接客服状态失败')
} finally {
setActionId(null)
}
}
function handleDelete(record: ConsignmentContactSupporter) {
modal.confirm({
title: `删除对接客服「${record.name}」?`,
content:
'只有未被任何寄卖记录使用的客服可以删除。已被使用的请改为「禁用」,历史寄卖单仍会保留对接客服信息。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminConsignmentContact(record.id)
message.success('对接客服已删除')
await reload()
} catch (error: any) {
message.error(error?.message || '删除对接客服失败')
throw error
}
},
})
}
const columns: TableColumnsType<ConsignmentContactSupporter> = [
{
title: '姓名',
key: 'name',
width: 160,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text strong>{record.name}</Text>
{record.note ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.note}
</Text>
) : null}
</Space>
),
},
{
title: '手机号',
dataIndex: 'phone',
width: 150,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '微信号',
dataIndex: 'wechat',
width: 160,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 80,
render: (value: number) => value || 0,
},
{
title: '状态',
key: 'enabled',
width: 110,
render: (_, record) => (
<Space size={6}>
<Switch
size="small"
checked={record.enabled}
loading={actionId === record.id}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => void handleToggleEnabled(record, checked)}
/>
<Tag color={record.enabled ? 'green' : 'default'} style={{ marginInlineEnd: 0 }}>
{record.enabled ? '启用中' : '已禁用'}
</Tag>
</Space>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (value: string) => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (_, record) => (
<Space size={4}>
<Button size="small" type="link" onClick={() => openEdit(record)}>
编辑
</Button>
<Button size="small" type="link" danger onClick={() => handleDelete(record)}>
删除
</Button>
</Space>
),
},
]
return (
<div>
<PageHeader
title="对接客服"
description="打手发布寄卖时必须选择对接客服;本名单独立于后台账号,用于提成归属与联系。"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新增对接客服
</Button>
}
/>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size={12}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索姓名 / 手机 / 微信 / 备注"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
style={{ width: 280 }}
/>
<Select
value={enabledFilter}
options={ENABLED_FILTER_OPTIONS}
onChange={setEnabledFilter}
style={{ width: 140 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
<Text type="secondary">
共 {total} 个,本页启用 {enabledCount} 个
</Text>
</Space>
</Card>
<PercentTable<ConsignmentContactSupporter>
rowKey="id"
loading={query.isLoading}
columns={columns}
dataSource={items}
pagination={false}
size="small"
/>
<Modal
title={editingContact ? `编辑对接客服 · ${editingContact.name}` : '新增对接客服'}
open={formOpen}
onCancel={() => {
setFormOpen(false)
setEditingContact(null)
}}
onOk={() => void handleSubmit()}
confirmLoading={submitting}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请填写对接客服姓名' }]}
>
<Input placeholder="如:小张" maxLength={60} />
</Form.Item>
<Space size={12} style={{ width: '100%' }} align="start">
<Form.Item name="phone" label="手机号" style={{ width: 240 }}>
<Input placeholder="选填" maxLength={30} />
</Form.Item>
<Form.Item name="wechat" label="微信号" style={{ width: 240 }}>
<Input placeholder="选填" maxLength={60} />
</Form.Item>
</Space>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} placeholder="如:负责苏尔南寄卖 / 白班" maxLength={200} />
</Form.Item>
<Space size={24} align="start">
<Form.Item
name="sortOrder"
label="排序"
tooltip="数字越小越靠前;打手端默认选中第一个启用项"
>
<InputNumber min={0} max={9999} style={{ width: 140 }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
)
}
@@ -29,6 +29,7 @@ import { useState } from 'react'
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery' import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
import { import {
archiveAdminConsignmentInventory, archiveAdminConsignmentInventory,
fetchAdminConsignmentContacts,
fetchAdminConsignmentInventories, fetchAdminConsignmentInventories,
fetchAdminConsignmentStats, fetchAdminConsignmentStats,
returnAdminConsignmentInventory, returnAdminConsignmentInventory,
@@ -40,6 +41,7 @@ import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMat
import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment' import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment'
import { fetchAdminSession } from '@/services/admin/auth' import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth' import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec' import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time' import { formatDateTime } from '@/utils/date-time'
@@ -174,6 +176,18 @@ export default function ConsignmentInventoryPanel() {
name: w.displayName || w.username, name: w.displayName || w.username,
})) }))
// 对接客服名单:编辑现货时可改派对接人(含已禁用项,历史数据需要能显示原值)
const contactsQuery = useQuery({
queryKey: ['admin-consignment-contacts'],
queryFn: () => fetchAdminConsignmentContacts(),
})
const contactSupporterOptions = (contactsQuery.data?.data.items || []).map((item) => ({
label: `${item.name}${item.enabled ? '' : '(已禁用)'}${
item.phone ? ` · ${item.phone}` : item.wechat ? ` · ${item.wechat}` : ''
}`,
value: item.id,
}))
// 数据查询 // 数据查询
const inventoriesQuery = useQuery({ const inventoriesQuery = useQuery({
queryKey: [ queryKey: [
@@ -255,6 +269,7 @@ export default function ConsignmentInventoryPanel() {
workerId: values.workerId || undefined, workerId: values.workerId || undefined,
workerName: selectedWorker?.name || editingItem.workerName || undefined, workerName: selectedWorker?.name || editingItem.workerName || undefined,
workerPhone: selectedWorker?.phone || editingItem.workerPhone || undefined, workerPhone: selectedWorker?.phone || editingItem.workerPhone || undefined,
contactSupporterId: values.contactSupporterId ?? null,
remark: values.remark, remark: values.remark,
allowMatching: values.allowMatching !== false, allowMatching: values.allowMatching !== false,
}) })
@@ -456,6 +471,22 @@ export default function ConsignmentInventoryPanel() {
</div> </div>
), ),
}, },
{
title: '对接客服',
key: 'contactSupporter',
width: 150,
render: (_, record) => {
const contact = formatConsignmentContactSupporter(record)
return (
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{contact.name || contact.text}</div>
{contact.contact ? (
<div style={{ color: '#8c8c8c', marginTop: 2 }}>{contact.contact}</div>
) : null}
</div>
)
},
},
{ {
title: '凭证', title: '凭证',
key: 'evidence', key: 'evidence',
@@ -990,6 +1021,24 @@ export default function ConsignmentInventoryPanel() {
</Col> </Col>
</Row> </Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="contactSupporterId"
label="对接客服"
tooltip="用于提成归属与联系;发布寄卖时由卖家必选"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择对接客服(选填)"
options={contactSupporterOptions}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="sellerPaymentInfo" label="卖家收款信息"> <Form.Item name="sellerPaymentInfo" label="卖家收款信息">
<Input /> <Input />
</Form.Item> </Form.Item>
@@ -1082,6 +1131,23 @@ export default function ConsignmentInventoryPanel() {
<Text type="secondary">内部自营现货</Text> <Text type="secondary">内部自营现货</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="对接客服">
{(() => {
const contact = formatConsignmentContactSupporter(viewingItem)
return contact.name ? (
<Space direction="vertical" size={2}>
<Text strong>{contact.name}</Text>
{contact.contact ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{contact.contact}
</Text>
) : null}
</Space>
) : (
<Text type="secondary">未指定</Text>
)
})()}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求"> <Descriptions.Item label="履约押金要求">
{viewingItem.depositAmountFen ? ( {viewingItem.depositAmountFen ? (
<Tag color="gold">¥{(viewingItem.depositAmountFen / 100).toFixed(0)}</Tag> <Tag color="gold">¥{(viewingItem.depositAmountFen / 100).toFixed(0)}</Tag>
@@ -40,6 +40,7 @@ import { fetchAdminSession } from '@/services/admin/auth'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform' import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/consignment' import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/consignment'
import { resolveAdminPermission } from '@/utils/admin-auth' import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec' import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time' import { formatDateTime } from '@/utils/date-time'
@@ -271,6 +272,22 @@ export default function ConsignmentSubmissionsPanel() {
</div> </div>
), ),
}, },
{
title: '对接客服',
key: 'contactSupporter',
width: 150,
render: (_, record) => {
const contact = formatConsignmentContactSupporter(record)
return (
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{contact.name || contact.text}</div>
{contact.contact ? (
<div style={{ color: '#8c8c8c', marginTop: 2 }}>{contact.contact}</div>
) : null}
</div>
)
},
},
{ {
title: '提报凭证(游戏主页截图)', title: '提报凭证(游戏主页截图)',
key: 'submissionScreenshots', key: 'submissionScreenshots',
@@ -536,6 +553,10 @@ export default function ConsignmentSubmissionsPanel() {
<span> <span>
联系方式:<Text strong>{approveModalItem?.contactInfo || '-'}</Text> 联系方式:<Text strong>{approveModalItem?.contactInfo || '-'}</Text>
</span> </span>
<span>
对接客服:
<Text strong>{formatConsignmentContactSupporter(approveModalItem).text}</Text>
</span>
<span> <span>
游戏ID:<Text strong>{approveModalItem?.sellerGameId || '-'}</Text> 游戏ID:<Text strong>{approveModalItem?.sellerGameId || '-'}</Text>
</span> </span>
@@ -852,6 +873,23 @@ export default function ConsignmentSubmissionsPanel() {
<Text type="secondary">未关联打手账号</Text> <Text type="secondary">未关联打手账号</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="对接客服">
{(() => {
const contact = formatConsignmentContactSupporter(viewingSubmission)
return contact.name ? (
<Space direction="vertical" size={2}>
<Text strong>{contact.name}</Text>
{contact.contact ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{contact.contact}
</Text>
) : null}
</Space>
) : (
<Text type="secondary">未指定</Text>
)
})()}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求"> <Descriptions.Item label="履约押金要求">
{viewingSubmission.depositAmountFen ? ( {viewingSubmission.depositAmountFen ? (
<Tag color="gold">¥{(viewingSubmission.depositAmountFen / 100).toFixed(0)}</Tag> <Tag color="gold">¥{(viewingSubmission.depositAmountFen / 100).toFixed(0)}</Tag>
@@ -92,6 +92,7 @@ import { notifyVoucherConsume } from '@/utils/voucher-consume'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment' import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match' import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { import {
isConsignmentOrder, isConsignmentOrder,
isInternalConsignmentOrder, isInternalConsignmentOrder,
@@ -1644,10 +1645,15 @@ export default function WorkOrdersPanel() {
</Typography.Text> </Typography.Text>
) : null} ) : null}
{consignment ? ( {consignment ? (
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}> <Space size={[4, 4]} wrap>
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥ <Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)}) 寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
</Tag> {((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
</Tag>
<Tag color="cyan" style={{ marginTop: 2, width: 'fit-content' }}>
对接客服: {formatConsignmentContactSupporter(consignment).text}
</Tag>
</Space>
) : null} ) : null}
</div> </div>
) )
@@ -2317,6 +2323,11 @@ export default function WorkOrdersPanel() {
</Typography.Text> </Typography.Text>
</Descriptions.Item> </Descriptions.Item>
) : null} ) : null}
{detailConsignment ? (
<Descriptions.Item label="对接客服">
{formatConsignmentContactSupporter(detailConsignment).text}
</Descriptions.Item>
) : null}
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag color={resolveStatusColor(detailOrder.status)}> <Tag color={resolveStatusColor(detailOrder.status)}>
{formatStatus(detailOrder.status)} {formatStatus(detailOrder.status)}
@@ -2655,6 +2666,13 @@ export default function WorkOrdersPanel() {
</Space> </Space>
</Descriptions.Item> </Descriptions.Item>
) : null} ) : null}
{isConsignment ? (
<Descriptions.Item label="对接客服" span={2}>
<span className="worker-order-detail-value">
{formatConsignmentContactSupporter(consignmentInfo).text}
</span>
</Descriptions.Item>
) : null}
<Descriptions.Item <Descriptions.Item
label={isConsignment ? '卖家备注' : '打手备注'} label={isConsignment ? '卖家备注' : '打手备注'}
span={2} span={2}
@@ -4095,6 +4113,9 @@ export default function WorkOrdersPanel() {
供货打手: {inv.workerName} ({inv.workerPhone || '无手机号'}) 供货打手: {inv.workerName} ({inv.workerPhone || '无手机号'})
</Tag> </Tag>
) : null} ) : null}
<Tag color="cyan">
对接客服: {formatConsignmentContactSupporter(inv).text}
</Tag>
{inv.depositAmountFen != null ? ( {inv.depositAmountFen != null ? (
<Tag color="warning"> <Tag color="warning">
履约押金: ¥{(inv.depositAmountFen / 100).toFixed(2)} 履约押金: ¥{(inv.depositAmountFen / 100).toFixed(2)}
@@ -31,6 +31,7 @@ import {
Pagination, Pagination,
Radio, Radio,
Row, Row,
Select,
Space, Space,
Tabs, Tabs,
Tag, Tag,
@@ -43,6 +44,7 @@ import ImageUpload from '@/components/files/ImageUpload'
import { WorkerContactModal } from '@/components/worker/WorkerContactModal' import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
import { import {
confirmWorkerConsignmentFriendAdded, confirmWorkerConsignmentFriendAdded,
fetchWorkerConsignmentContacts,
fetchWorkerConsignmentPriceSuggestion, fetchWorkerConsignmentPriceSuggestion,
fetchWorkerConsignments, fetchWorkerConsignments,
fetchWorkerProfile, fetchWorkerProfile,
@@ -56,6 +58,7 @@ import {
SURNAN_PUBLISH_SYSTEM_OPTIONS, SURNAN_PUBLISH_SYSTEM_OPTIONS,
SURNAN_PUBLISH_ZONE_OPTIONS, SURNAN_PUBLISH_ZONE_OPTIONS,
} from '@/utils/consignment-spec' } from '@/utils/consignment-spec'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown' import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist' import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
import { formatDateTime } from '@/utils/date-time' import { formatDateTime } from '@/utils/date-time'
@@ -199,6 +202,15 @@ export default function WorkerConsignmentPage() {
}), }),
}) })
// 发布寄卖必填「对接客服」:只取启用名单,默认选第一个
const consignmentContactsQuery = useQuery({
queryKey: ['worker-consignment-contacts'],
queryFn: () => fetchWorkerConsignmentContacts(),
staleTime: 60_000,
})
const contactSupporters = consignmentContactsQuery.data?.data.items || []
const firstContactSupporterId = contactSupporters[0]?.id ?? null
// 切换标签页时自动刷新最新数据 // 切换标签页时自动刷新最新数据
useEffect(() => { useEffect(() => {
if (activeTab === 'records') { if (activeTab === 'records') {
@@ -261,6 +273,14 @@ export default function WorkerConsignmentPage() {
} }
}, [worker, form]) }, [worker, form])
// 名单异步加载完成后补默认值;用户已经选过则不覆盖
useEffect(() => {
if (firstContactSupporterId == null) return
if (form.getFieldValue('contactSupporterId') == null) {
form.setFieldsValue({ contactSupporterId: firstContactSupporterId })
}
}, [firstContactSupporterId, form])
useEffect(() => { useEffect(() => {
if (profileQuery.isLoading) return if (profileQuery.isLoading) return
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -368,6 +388,15 @@ export default function WorkerConsignmentPage() {
return return
} }
if (contactSupporters.length === 0) {
message.error('暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布')
return
}
if (values.contactSupporterId == null) {
message.error('请选择对接客服')
return
}
const urls = screenshots.map((f) => f.url).filter(Boolean) const urls = screenshots.map((f) => f.url).filter(Boolean)
if (urls.length === 0) { if (urls.length === 0) {
message.error('请上传游戏主页截图以供核验') message.error('请上传游戏主页截图以供核验')
@@ -412,10 +441,14 @@ export default function WorkerConsignmentPage() {
screenshotsJson: urls, screenshotsJson: urls,
sellerNote: values.sellerNote, sellerNote: values.sellerNote,
depositAmountFen: publishDepositFen, depositAmountFen: publishDepositFen,
contactSupporterId: Number(values.contactSupporterId),
}) })
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`) message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
form.resetFields() form.resetFields()
if (firstContactSupporterId != null) {
form.setFieldsValue({ contactSupporterId: firstContactSupporterId })
}
setPriceState({ status: 'idle' }) setPriceState({ status: 'idle' })
lastPriceQueryKeyRef.current = '' lastPriceQueryKeyRef.current = ''
setScreenshots([]) setScreenshots([])
@@ -662,6 +695,14 @@ export default function WorkerConsignmentPage() {
} }
/> />
{!consignmentContactsQuery.isLoading && contactSupporters.length === 0 ? (
<Alert
type="warning"
showIcon
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
/>
) : null}
<Divider plain className="worker-consignment-divider"> <Divider plain className="worker-consignment-divider">
规格与价格 规格与价格
</Divider> </Divider>
@@ -807,6 +848,35 @@ export default function WorkerConsignmentPage() {
</Divider> </Divider>
<Row gutter={[12, 0]}> <Row gutter={[12, 0]}>
<Col xs={24} sm={12}>
<Form.Item
name="contactSupporterId"
label="对接客服"
rules={[{ required: true, message: '请选择对接客服' }]}
extra={
contactSupporters.length === 0
? '暂无可选客服,请联系管理员配置'
: '用于后续提成归属与联系'
}
>
<Select
placeholder="请选择对接客服"
options={contactSupporters.map((item) => ({
value: item.id,
label: `${item.name}${
item.phone
? ` · ${item.phone}`
: item.wechat
? ` · ${item.wechat}`
: ''
}`,
}))}
notFoundContent="暂无启用中的对接客服"
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Form.Item <Form.Item
name="sellerContact" name="sellerContact"
@@ -1334,6 +1404,9 @@ function ConsignmentRecordCard({
<span> <span>
押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong> 押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong>
</span> </span>
<span>
对接客服 <strong>{formatConsignmentContactSupporter(record).text}</strong>
</span>
{record.workOrderId ? <span>履约单 #{record.workOrderId}</span> : null} {record.workOrderId ? <span>履约单 #{record.workOrderId}</span> : null}
</div> </div>
{record.reviewNote ? ( {record.reviewNote ? (
@@ -1,5 +1,6 @@
import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/http' import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from '@/lib/http'
import type { import type {
ConsignmentContactSupporter,
ConsignmentInventory, ConsignmentInventory,
ConsignmentStatsSummary, ConsignmentStatsSummary,
ConsignmentSubmission, ConsignmentSubmission,
@@ -8,6 +9,43 @@ import type {
WorkOrderInventoryMatchOptions, WorkOrderInventoryMatchOptions,
} from '@/types/consignment' } from '@/types/consignment'
// --- 对接客服名单 ---
export function fetchAdminConsignmentContacts(params?: { keyword?: string; enabled?: string }) {
return apiGet<{ items: ConsignmentContactSupporter[]; total: number }>(
'/api/v1/admin/worker-platform/consignment/contacts',
params,
)
}
export function createAdminConsignmentContact(payload: Record<string, unknown>) {
return apiPost<ConsignmentContactSupporter>(
'/api/v1/admin/worker-platform/consignment/contacts',
payload,
)
}
export function updateAdminConsignmentContact(
id: number | string,
payload: Record<string, unknown>,
) {
return apiPut<ConsignmentContactSupporter>(
`/api/v1/admin/worker-platform/consignment/contacts/${id}`,
payload,
)
}
export function setAdminConsignmentContactEnabled(id: number | string, enabled: boolean) {
return apiPatch<ConsignmentContactSupporter>(
`/api/v1/admin/worker-platform/consignment/contacts/${id}/enabled`,
{ enabled },
)
}
export function deleteAdminConsignmentContact(id: number | string) {
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/consignment/contacts/${id}`)
}
// --- 库存管理 --- // --- 库存管理 ---
export function fetchAdminConsignmentInventories(params?: Record<string, unknown>) { export function fetchAdminConsignmentInventories(params?: Record<string, unknown>) {
+20 -1
View File
@@ -25,7 +25,11 @@ import type {
WorkerUser, WorkerUser,
WorkerWalletLedger, WorkerWalletLedger,
} from '@/types/worker-platform' } from '@/types/worker-platform'
import type { ConsignmentBuyerInfo, ConsignmentSellerInfo } from '@/types/consignment' import type {
ConsignmentBuyerInfo,
ConsignmentContactSupporter,
ConsignmentSellerInfo,
} from '@/types/consignment'
import { getWorkerDeviceId } from '@/utils/worker-auth' import { getWorkerDeviceId } from '@/utils/worker-auth'
@@ -392,6 +396,8 @@ export type WorkerConsignmentPublishPayload = {
screenshotsJson: string[] screenshotsJson: string[]
sellerNote?: string sellerNote?: string
depositAmountFen?: number depositAmountFen?: number
/** 对接客服名单 id(必填,发布时选定) */
contactSupporterId: number
} }
export type WorkerConsignmentItem = { export type WorkerConsignmentItem = {
@@ -409,6 +415,10 @@ export type WorkerConsignmentItem = {
resultInventoryId?: number | string | null resultInventoryId?: number | string | null
screenshotsJson: string[] screenshotsJson: string[]
contentJson: Record<string, unknown> contentJson: Record<string, unknown>
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
createdAt: string createdAt: string
} }
@@ -450,6 +460,10 @@ export type WorkerConsignmentRecord = {
brillianceCount: number brillianceCount: number
askingPriceFen: number askingPriceFen: number
depositAmountFen: number depositAmountFen: number
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
reviewNote: string reviewNote: string
screenshotsJson: string[] screenshotsJson: string[]
giftEvidenceImages?: string[] giftEvidenceImages?: string[]
@@ -507,6 +521,11 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa
return apiPost<WorkerConsignmentItem>('/api/v1/worker/consignment/publish', payload) return apiPost<WorkerConsignmentItem>('/api/v1/worker/consignment/publish', payload)
} }
/** 发布寄卖时的「对接客服」下拉(仅启用中的名单)。 */
export function fetchWorkerConsignmentContacts() {
return apiGet<{ items: ConsignmentContactSupporter[] }>('/api/v1/worker/consignment/contacts')
}
export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {}) { export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {}) {
return apiGet<WorkerConsignmentListResponse>( return apiGet<WorkerConsignmentListResponse>(
'/api/v1/worker/consignment/my-items', '/api/v1/worker/consignment/my-items',
+108 -76
View File
@@ -7,6 +7,31 @@
*/ */
export type GiftEvidenceReviewStatus = 'none' | 'pending' | 'accepted' | 'submitted' export type GiftEvidenceReviewStatus = 'none' | 'pending' | 'accepted' | 'submitted'
/**
* 寄卖「对接客服」名单项。
*
* 独立名单(不是后台账号):发布寄卖时必选,后台用于统计提成与联系。
*/
export type ConsignmentContactSupporter = {
id: number
name: string
phone: string
wechat: string
note: string
enabled: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
/** 提报单 / 现货 / 工单物料上的对接客服快照字段。 */
export type ConsignmentContactFields = {
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
}
/** /**
* 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。 * 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。
* *
@@ -34,51 +59,52 @@ export type ConsignmentEvidenceFields = {
giftEvidenceReviewStatus?: GiftEvidenceReviewStatus giftEvidenceReviewStatus?: GiftEvidenceReviewStatus
} }
export type ConsignmentInventory = ConsignmentEvidenceFields & { export type ConsignmentInventory = ConsignmentEvidenceFields &
id: number ConsignmentContactFields & {
inventoryNo: string id: number
gameName: string inventoryNo: string
categoryId: number | null gameName: string
categoryName?: string categoryId: number | null
itemName: string categoryName?: string
system: string itemName: string
serverZone: string system: string
series: string serverZone: string
diamondLevel: number series: string
brillianceCount: number diamondLevel: number
costPriceFen: number brillianceCount: number
buyerName: string costPriceFen: number
shopName: string buyerName: string
inboundBatch: string shopName: string
sellerContact: string inboundBatch: string
sellerGameId: string sellerContact: string
sellerPaymentInfo: string sellerGameId: string
sellerPhone: string sellerPaymentInfo: string
credentialsJson: Record<string, unknown> sellerPhone: string
screenshotsJson: string[] credentialsJson: Record<string, unknown>
remark: string screenshotsJson: string[]
allowMatching: boolean remark: string
status: 'in_stock' | 'consumed' | 'archived' allowMatching: boolean
allocationStatus: string status: 'in_stock' | 'consumed' | 'archived'
boundWorkOrderId: number | null allocationStatus: string
boundPlatformOrderId: string boundWorkOrderId: number | null
sourceType: 'manual' | 'worker_portal' | 'recycle' boundPlatformOrderId: string
sourceSubmissionId: number | null sourceType: 'manual' | 'worker_portal' | 'recycle'
/** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */ sourceSubmissionId: number | null
sourceSubmissionNo?: string /** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */
workerId?: number | null sourceSubmissionNo?: string
workerName?: string workerId?: number | null
workerPhone?: string workerName?: string
/** 被指派执行本次履约的打手(卖家之外的第三人);workerId 始终是卖家。 */ workerPhone?: string
assignedWorkerId?: number | null /** 被指派执行本次履约的打手(卖家之外的第三人);workerId 始终是卖家。 */
depositAmountFen?: number assignedWorkerId?: number | null
inboundAt: string depositAmountFen?: number
consumedAt: string | null inboundAt: string
createdAt: string consumedAt: string | null
updatedAt: string createdAt: string
/** 关联履约工单的买家资料(加好友及主页截图) */ updatedAt: string
buyerInfo?: ConsignmentBuyerInfo | null /** 关联履约工单的买家资料(加好友及主页截图) */
} buyerInfo?: ConsignmentBuyerInfo | null
}
export type ConsignmentSellerInfo = { export type ConsignmentSellerInfo = {
characterName: string characterName: string
@@ -112,40 +138,46 @@ export type ConsignmentFulfillmentInfo = {
costPriceFen?: number costPriceFen?: number
fulfillmentType?: string fulfillmentType?: string
workerId?: number | null workerId?: number | null
/** 对接客服(履约时从现货复制到工单物料,用于工单侧联系) */
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
[key: string]: unknown [key: string]: unknown
} }
export type ConsignmentSubmission = ConsignmentEvidenceFields & { export type ConsignmentSubmission = ConsignmentEvidenceFields &
id: number ConsignmentContactFields & {
submissionNo: string id: number
gameName: string submissionNo: string
categoryId: number | null gameName: string
categoryName?: string categoryId: number | null
itemName: string categoryName?: string
system: string itemName: string
serverZone: string system: string
series: string serverZone: string
diamondLevel: number series: string
brillianceCount: number diamondLevel: number
sellerGameId: string brillianceCount: number
sellerPaymentInfo: string sellerGameId: string
sellerPhone: string sellerPaymentInfo: string
contactInfo: string sellerPhone: string
contentJson: Record<string, unknown> contactInfo: string
screenshotsJson: string[] contentJson: Record<string, unknown>
askingPriceFen: number screenshotsJson: string[]
workerId?: number | null askingPriceFen: number
workerName?: string workerId?: number | null
workerPhone?: string workerName?: string
depositAmountFen?: number workerPhone?: string
status: 'pending_review' | 'approved' | 'rejected' depositAmountFen?: number
reviewNote: string status: 'pending_review' | 'approved' | 'rejected'
reviewerName: string reviewNote: string
reviewedAt: string | null reviewerName: string
resultInventoryId: number | null reviewedAt: string | null
createdAt: string resultInventoryId: number | null
updatedAt: string createdAt: string
} updatedAt: string
}
export type ConsignmentStatsSummary = { export type ConsignmentStatsSummary = {
totalInStockCount: number totalInStockCount: number
@@ -0,0 +1,37 @@
/** 寄卖「对接客服」展示工具:后台列表、详情、工单与打手端统一口径。 */
export type ConsignmentContactDisplay = {
/** 姓名;没有对接客服时为空串 */
name: string
/** 联系方式(电话 / 微信),没有时为空串 */
contact: string
/** 姓名 + 联系方式,列表里直接展示 */
text: string
}
type ConsignmentContactLike =
| {
contactSupporterName?: string | null
contactSupporterPhone?: string | null
contactSupporterWechat?: string | null
}
| null
| undefined
export function formatConsignmentContactSupporter(
source: ConsignmentContactLike,
emptyText = '未指定',
): ConsignmentContactDisplay {
const name = String(source?.contactSupporterName || '').trim()
const phone = String(source?.contactSupporterPhone || '').trim()
const wechat = String(source?.contactSupporterWechat || '').trim()
const parts: string[] = []
if (phone) parts.push(`电话 ${phone}`)
if (wechat) parts.push(`微信 ${wechat}`)
const contact = parts.join(' · ')
if (!name) {
return { name: '', contact, text: contact || emptyText }
}
return { name, contact, text: contact ? `${name} · ${contact}` : name }
}
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatConsignmentContactSupporter } from '../src/utils/consignment-contact.ts'
test('对接客服:姓名 + 电话/微信一起展示', () => {
const display = formatConsignmentContactSupporter({
contactSupporterName: '小张',
contactSupporterPhone: '13800001111',
contactSupporterWechat: 'wx_zhang',
})
assert.equal(display.name, '小张')
assert.equal(display.contact, '电话 13800001111 · 微信 wx_zhang')
assert.equal(display.text, '小张 · 电话 13800001111 · 微信 wx_zhang')
})
test('对接客服:只有姓名时不拼联系方式', () => {
const display = formatConsignmentContactSupporter({ contactSupporterName: '小李' })
assert.equal(display.text, '小李')
})
test('对接客服:历史数据没有姓名时回退为未指定', () => {
assert.equal(formatConsignmentContactSupporter(null).text, '未指定')
assert.equal(formatConsignmentContactSupporter({}).text, '未指定')
})
test('对接客服:自定义空值文案', () => {
assert.equal(formatConsignmentContactSupporter(undefined, '-').text, '-')
})