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
@@ -226,6 +226,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
workerPhone: row.worker_phone || '',
assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null,
depositAmountFen: Number(row.deposit_amount_fen || 0),
contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null,
contactSupporterName: row.contact_supporter_name || '',
contactSupporterPhone: row.contact_supporter_phone || '',
contactSupporterWechat: row.contact_supporter_wechat || '',
inboundAt: row.inbound_at || row.created_at,
consumedAt: row.consumed_at,
createdAt: row.created_at,
@@ -266,6 +270,10 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
workerName: row.worker_name || '',
workerPhone: row.worker_phone || '',
depositAmountFen: Number(row.deposit_amount_fen || 0),
contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null,
contactSupporterName: row.contact_supporter_name || '',
contactSupporterPhone: row.contact_supporter_phone || '',
contactSupporterWechat: row.contact_supporter_wechat || '',
status: row.status,
reviewNote: row.review_note || '',
reviewerName: row.reviewer_name || '',
@@ -407,6 +415,12 @@ export async function updateAdminConsignmentInventory(
workerPhone: payload.workerPhone !== undefined ? String(payload.workerPhone).trim() : undefined,
depositAmountFen:
payload.depositAmountFen !== undefined ? Number(payload.depositAmountFen) : undefined,
contactSupporterId:
payload.contactSupporterId !== undefined
? payload.contactSupporterId
? Number(payload.contactSupporterId)
: null
: undefined,
now,
})
@@ -655,12 +669,25 @@ export async function approveAdminConsignmentSubmission(
workerName,
workerPhone,
depositAmountFen,
contactSupporterId:
payload.contactSupporterId !== undefined
? payload.contactSupporterId
? Number(payload.contactSupporterId)
: null
: undefined,
now,
})
// 入库 INSERT 的 RETURNING * 不带 join 出来的对接客服姓名,这里按 id 重读一次再出参,
// 避免审核成功后前端立即刷新时「对接客服」短暂显示为空。
const [joinedInventory, joinedSubmission] = await Promise.all([
getConsignmentInventoryById(res.inventory.id),
getConsignmentSubmissionById(id),
])
return {
submission: mapConsignmentSubmission(res.submission),
inventory: mapConsignmentInventory(res.inventory),
submission: mapConsignmentSubmission(joinedSubmission || res.submission),
inventory: mapConsignmentInventory(joinedInventory || res.inventory),
}
}
@@ -0,0 +1,232 @@
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import {
countConsignmentContactReferences,
createConsignmentContact,
deleteConsignmentContact,
findConsignmentContactByName,
getConsignmentContactById,
listConsignmentContacts,
listEnabledConsignmentContacts,
setConsignmentContactEnabled,
updateConsignmentContact,
type ConsignmentContactRow,
} from '../../repositories/worker-platform/consignment-contact-repo.js'
export function mapConsignmentContact(row: ConsignmentContactRow) {
return {
id: Number(row.id),
name: row.name,
phone: row.phone || '',
wechat: row.wechat || '',
note: row.note || '',
enabled: Boolean(row.enabled),
sortOrder: Number(row.sort_order || 0),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function normalizeText(value: unknown): string {
return String(value ?? '').trim()
}
function normalizeSortOrder(value: unknown): number {
const num = Number(value)
return Number.isFinite(num) ? Math.round(num) : 0
}
function requireContactName(value: unknown): string {
const name = normalizeText(value)
if (!name) {
throw createHttpError('请填写对接客服姓名', {
statusCode: 400,
errorCode: 'consignment_contact_name_required',
})
}
if (name.length > 60) {
throw createHttpError('对接客服姓名不能超过 60 个字符', {
statusCode: 400,
errorCode: 'consignment_contact_name_too_long',
})
}
return name
}
async function assertContactNameAvailable(name: string, excludeId?: number | string) {
const duplicated = await findConsignmentContactByName(name, excludeId)
if (duplicated) {
throw createHttpError(`已存在同名对接客服「${name}」,请换一个名字`, {
statusCode: 409,
errorCode: 'consignment_contact_name_duplicated',
})
}
}
/** 管理端:完整名单(含禁用),用于配置页。 */
export async function fetchAdminConsignmentContacts(query: JsonObject = {}) {
const keyword = normalizeText(query.keyword)
const enabledRaw = normalizeText(query.enabled)
const enabled = enabledRaw === 'true' ? true : enabledRaw === 'false' ? false : undefined
const rows = await listConsignmentContacts({
keyword: keyword || undefined,
enabled,
})
return {
items: rows.map(mapConsignmentContact),
total: rows.length,
}
}
/** 打手端:发布下拉只取启用项。 */
export async function fetchWorkerConsignmentContacts() {
const rows = await listEnabledConsignmentContacts()
return {
items: rows.map(mapConsignmentContact),
}
}
export async function createAdminConsignmentContact(payload: JsonObject) {
const name = requireContactName(payload.name)
await assertContactNameAvailable(name)
const row = await createConsignmentContact({
name,
phone: normalizeText(payload.phone),
wechat: normalizeText(payload.wechat),
note: normalizeText(payload.note),
enabled: payload.enabled !== false,
sortOrder: normalizeSortOrder(payload.sortOrder),
now: nowIso(),
})
return mapConsignmentContact(row)
}
export async function updateAdminConsignmentContact(id: number | string, payload: JsonObject) {
const current = await getConsignmentContactById(id)
if (!current) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
const name = payload.name !== undefined ? requireContactName(payload.name) : undefined
if (name !== undefined) {
await assertContactNameAvailable(name, id)
}
const updated = await updateConsignmentContact(id, {
name,
phone: payload.phone !== undefined ? normalizeText(payload.phone) : undefined,
wechat: payload.wechat !== undefined ? normalizeText(payload.wechat) : undefined,
note: payload.note !== undefined ? normalizeText(payload.note) : undefined,
sortOrder: payload.sortOrder !== undefined ? normalizeSortOrder(payload.sortOrder) : undefined,
now: nowIso(),
})
if (!updated) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
return mapConsignmentContact(updated)
}
export async function setAdminConsignmentContactEnabled(
id: number | string,
enabledValue: unknown,
) {
if (typeof enabledValue !== 'boolean') {
throw createHttpError('启用状态必须是布尔值', {
statusCode: 400,
errorCode: 'consignment_contact_enabled_invalid',
})
}
const enabled = enabledValue
const current = await getConsignmentContactById(id)
if (!current) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
if (!enabled && current.enabled) {
const enabledRows = await listEnabledConsignmentContacts()
if (enabledRows.length <= 1) {
throw createHttpError('至少保留一个启用中的对接客服,否则打手无法发布寄卖', {
statusCode: 409,
errorCode: 'consignment_contact_last_enabled',
})
}
}
const updated = await setConsignmentContactEnabled(id, enabled, nowIso())
if (!updated) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
return mapConsignmentContact(updated)
}
export async function deleteAdminConsignmentContact(id: number | string) {
const current = await getConsignmentContactById(id)
if (!current) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
const referenceCount = await countConsignmentContactReferences(id)
if (referenceCount > 0) {
throw createHttpError(
`该对接客服已被 ${referenceCount} 条寄卖记录使用,不能删除;请改为「禁用」,历史记录仍会保留。`,
{ statusCode: 409, errorCode: 'consignment_contact_in_use' },
)
}
if (current.enabled) {
const enabledRows = await listEnabledConsignmentContacts()
if (enabledRows.length <= 1) {
throw createHttpError('至少保留一个启用中的对接客服,请先新增并启用其他客服', {
statusCode: 409,
errorCode: 'consignment_contact_last_enabled',
})
}
}
const deleted = await deleteConsignmentContact(id)
if (!deleted) {
throw createHttpError('对接客服不存在', {
statusCode: 404,
errorCode: 'consignment_contact_not_found',
})
}
return { deleted: true }
}
/**
* 发布寄卖时校验「对接客服」:必填 + 必须存在于启用名单。
* 返回整行,调用方取 id 落库即可。
*/
export async function resolveEnabledConsignmentContact(
contactSupporterId: unknown,
): Promise<ConsignmentContactRow> {
const id = Number(contactSupporterId)
if (!Number.isFinite(id) || id <= 0) {
throw createHttpError('请选择对接客服', {
statusCode: 400,
errorCode: 'consignment_contact_required',
})
}
const row = await getConsignmentContactById(id)
if (!row || !row.enabled) {
throw createHttpError('对接客服不存在或已停用,请刷新后重新选择', {
statusCode: 400,
errorCode: 'consignment_contact_invalid',
})
}
return row
}
@@ -507,6 +507,12 @@ export async function fulfillWorkOrderWithConsignment(input: {
sellerGameId: inv.seller_game_id || '',
sellerContact: inv.seller_contact || '',
sellerPhone: inv.seller_phone || '',
contactSupporterId: inventory.contact_supporter_id
? Number(inventory.contact_supporter_id)
: null,
contactSupporterName: inventory.contact_supporter_name || '',
contactSupporterPhone: inventory.contact_supporter_phone || '',
contactSupporterWechat: inventory.contact_supporter_wechat || '',
workerName: inv.worker_name || '',
workerPhone: inv.worker_phone || '',
remark: inv.remark || '',
@@ -552,6 +558,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
priceAdjustReason: '寄卖内部现货履约按现货结算成本价更新发单金额',
buyerName: inv.buyer_name,
shopName,
contactSupporterId: inventory.contact_supporter_id
? Number(inventory.contact_supporter_id)
: null,
contactSupporterName: inventory.contact_supporter_name || '',
matchType:
specMatch?.matchType || (input.allowSpecMismatch === true ? 'conflict' : 'exact'),
manualMatch: Boolean(matchNote) || input.allowSpecMismatch === true,
@@ -757,6 +767,12 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
sellerGameId: claimed.seller_game_id || '',
sellerContact: claimed.seller_contact || '',
sellerPhone: claimed.seller_phone || '',
contactSupporterId: inventory.contact_supporter_id
? Number(inventory.contact_supporter_id)
: null,
contactSupporterName: inventory.contact_supporter_name || '',
contactSupporterPhone: inventory.contact_supporter_phone || '',
contactSupporterWechat: inventory.contact_supporter_wechat || '',
workerId: Number(targetWorkerId),
workerName: claimed.worker_name || '',
workerPhone: claimed.worker_phone || '',
@@ -15,6 +15,7 @@ import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-ord
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js'
import { resolveEnabledConsignmentContact } from './consignment-contact-service.js'
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
import type { JsonObject } from '../../types/json.js'
@@ -43,6 +44,21 @@ function toRecord(value: unknown): Record<string, unknown> | null {
: null
}
/** 提报单 / 现货都可能带对接客服信息,统一成前端字段。 */
function mapConsignmentContactSupporter(source: {
contact_supporter_id?: string | number | null
contact_supporter_name?: string
contact_supporter_phone?: string
contact_supporter_wechat?: string
}) {
return {
contactSupporterId: source.contact_supporter_id ? Number(source.contact_supporter_id) : null,
contactSupporterName: source.contact_supporter_name || '',
contactSupporterPhone: source.contact_supporter_phone || '',
contactSupporterWechat: source.contact_supporter_wechat || '',
}
}
function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) {
if (!boundWo) {
return {
@@ -162,6 +178,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
// 对接客服必填:发布时选定,后续按人统计提成 / 方便联系。
const contactSupporter = await resolveEnabledConsignmentContact(payload.contactSupporterId)
const system = String(payload.system || '').trim()
const serverZone = String(payload.serverZone || '').trim()
const series = String(payload.series || '').trim()
@@ -298,6 +317,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
depositAmountFen,
contactSupporterId: Number(contactSupporter.id),
now,
})
@@ -330,6 +350,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
workerName: submission.worker_name,
workerPhone: submission.worker_phone,
depositAmountFen: submission.deposit_amount_fen,
contactSupporterId: submission.contact_supporter_id
? Number(submission.contact_supporter_id)
: null,
contactSupporterName: contactSupporter.name,
contactSupporterPhone: contactSupporter.phone || '',
contactSupporterWechat: contactSupporter.wechat || '',
createdAt: submission.created_at,
}
} catch (error) {
@@ -438,6 +464,8 @@ export async function listWorkerConsignments(
const evidence = showingMatchedBuyer
? extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
// 现货上的对接客服优先级更高:客服可能在审核入库后改派对接人,卖家端应看到最新归属。
const contactSupporter = inv?.contact_supporter_id ? inv : sub
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
@@ -452,6 +480,7 @@ export async function listWorkerConsignments(
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen,
...mapConsignmentContactSupporter(contactSupporter),
reviewNote: sub.review_note,
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
@@ -496,6 +525,7 @@ export async function listWorkerConsignments(
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen,
...mapConsignmentContactSupporter(inv),
reviewNote: '',
screenshotsJson: [],
giftEvidenceImages: evidence.giftEvidenceImages,
@@ -62,3 +62,4 @@ export * from './worker-announcement-config-service.js'
export * from './worker-hall-config-service.js'
export * from './worker-acceptance-config-service.js'
export * from './sync-worker-orders-service.js'
export * from './consignment-contact-service.js'