feat(consignment): 停用客服手动入库,寄卖货源只走平台提报

背景:手动入库是唯一会造出「没有提报单的现货」的入口,这类货没有寄卖单号,
统一状态机/凭证链/搜索/卖家端单号锚定都串不起来,只能在界面回退显示内部仓库号 INV,
正是"一件货两个号"的残留。业务定调:寄卖货源只走「打手平台提报 → 客服审核入库」。

- 前端寄售仓库的「手动入库」入口整块删除(按钮 + 入库弹窗 + 提交逻辑 + 服务函数)
- 后端同时挡住,防旧页面缓存或直接调接口绕过:
  POST /worker-platform/consignment/inventories 返回 409 consignment_manual_inbound_disabled;
  死代码一并清掉(服务层原实现、仓库层 createConsignmentInventory)
- 提报审核入库路径不受影响,照旧生成内部仓库号 INV 并写 source_submission_id,
  即每件货都必然挂着寄卖单号,INV 只作为内部编号存在、界面不展示

真实库验证:接口被拒(409),且「没有提报单的现货行数」前后均为 0。
This commit is contained in:
yml2213
2026-09-17 11:41:46 +08:00
parent 47e09d72ce
commit 0c10f0e135
6 changed files with 50 additions and 454 deletions
@@ -321,88 +321,6 @@ export async function getConsignmentInventoryById(
return res.rows[0] || null
}
export async function createConsignmentInventory(input: {
inventoryNo: string
gameName?: string
categoryId?: number | null
itemName: string
system?: string
serverZone?: string
series?: string
diamondLevel?: number
brillianceCount?: number
costPriceFen?: number
buyerName?: string
inboundBatch?: string
sellerContact?: string
sellerGameId?: string
sellerPaymentInfo?: string
sellerPhone?: string
credentialsJson?: Record<string, unknown>
screenshotsJson?: unknown[]
remark?: string
allowMatching?: boolean
sourceType?: 'manual' | 'worker_portal' | 'recycle'
sourceSubmissionId?: number | null
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
now: string
}): Promise<ConsignmentInventoryRow> {
const sql = `
INSERT INTO consignment_inventories (
inventory_no, game_name, category_id, item_name, system, server_zone,
series, diamond_level, brilliance_count, cost_price_fen, buyer_name,
inbound_batch, seller_contact, seller_game_id, seller_payment_info,
seller_phone, credentials_json, screenshots_json, remark,
allow_matching, status, allocation_status, source_type,
source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen,
inbound_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15,
$16, $17::jsonb, $18::jsonb, $19,
$20, 'in_stock', '待安排', $21,
$22, $23, $24, $25, $26,
$27, $27, $27
)
RETURNING *
`
const values = [
input.inventoryNo,
input.gameName || '和平精英',
input.categoryId || null,
input.itemName,
input.system || '',
input.serverZone || '',
input.series || '',
input.diamondLevel || 1,
input.brillianceCount || 0,
input.costPriceFen || 0,
input.buyerName || '',
input.inboundBatch || '',
input.sellerContact || '',
input.sellerGameId || '',
input.sellerPaymentInfo || '',
input.sellerPhone || '',
toJsonString(input.credentialsJson || {}),
toJsonString(input.screenshotsJson || []),
input.remark || '',
input.allowMatching !== false,
input.sourceType || 'manual',
input.sourceSubmissionId || null,
input.workerId || null,
input.workerName || '',
input.workerPhone || '',
input.depositAmountFen || 0,
input.now,
]
const res = await query<ConsignmentInventoryRow>(sql, values)
return res.rows[0]!
}
export async function updateConsignmentInventory(
id: number | string,
input: {
@@ -3,7 +3,6 @@ import {
type ConsignmentInventoryRow,
type ConsignmentSubmissionRow,
approveConsignmentSubmissionWithInbound,
createConsignmentInventory,
getConsignmentInventoryById,
getConsignmentInventoryStats,
getConsignmentSubmissionById,
@@ -279,46 +278,19 @@ export async function getAdminConsignmentInventory(id: number | string) {
return mapConsignmentInventory(item)
}
export async function createAdminConsignmentInventory(payload: JsonObject, actorName = '') {
const itemName = String(payload.itemName || '').trim()
if (!itemName) {
throw createHttpError('物品名称不能为空', { statusCode: 400, errorCode: 'invalid_item_name' })
}
const now = nowIso()
const inventoryNo = generateConsignmentInventoryNo()
const costPriceFen = Math.max(0, Math.round(Number(payload.costPriceFen || 0)))
const row = await createConsignmentInventory({
inventoryNo,
gameName: String(payload.gameName || '和平精英').trim(),
categoryId: payload.categoryId ? Number(payload.categoryId) : null,
itemName,
system: String(payload.system || '').trim(),
serverZone: String(payload.serverZone || '').trim(),
series: String(payload.series || '').trim(),
diamondLevel: Math.max(1, Math.min(7, Number(payload.diamondLevel || 1))),
brillianceCount: Math.max(0, Math.min(7, Number(payload.brillianceCount || 0))),
costPriceFen,
buyerName: String(payload.buyerName || actorName || '').trim(),
inboundBatch: String(payload.inboundBatch || '').trim(),
sellerContact: String(payload.sellerContact || '').trim(),
sellerGameId: String(payload.sellerGameId || '').trim(),
sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(),
sellerPhone: String(payload.sellerPhone || '').trim(),
credentialsJson: pickCredentialsJson(payload) ?? {},
screenshotsJson: pickScreenshotsJson(payload) ?? [],
remark: String(payload.remark || '').trim(),
allowMatching: payload.allowMatching !== false,
sourceType: 'manual',
workerId: payload.workerId ? Number(payload.workerId) : null,
workerName: String(payload.workerName || '').trim(),
workerPhone: String(payload.workerPhone || '').trim(),
depositAmountFen: payload.depositAmountFen ? Number(payload.depositAmountFen) : 0,
now,
/**
* 客服手动入库——**业务已停用**。
*
* 寄卖的货源只允许走「打手在平台提报 → 客服审核入库」这一条路:手动入库的货没有提报单,
* 也就没有寄卖单号,全流程(状态机/凭证链/搜索/卖家端)都无法用同一个号锚定。
* 这里保留一个明确的拒绝而不是静默删除,是为了挡住旧页面缓存或直接调接口的写入,
* 避免又冒出没有提报单的货;将来要恢复也只需回退这个守卫。
*/
export async function createAdminConsignmentInventory(_payload: JsonObject, _actorName = '') {
throw createHttpError('客服手动入库已停用:寄卖货源请统一走打手在平台提报后审核入库', {
statusCode: 409,
errorCode: 'consignment_manual_inbound_disabled',
})
return mapConsignmentInventory(row)
}
export async function updateAdminConsignmentInventory(
@@ -144,3 +144,17 @@ test('提报行同样带出赠送凭证(审核界面要看这件货有没有
assert.equal(mapped.giftEvidenceReviewStatus, 'accepted')
assert.equal(mapped.giftWorkOrderNo, 'WO1')
})
test('客服手动入库已停用:接口必须明确拒绝,不能再造出没有提报单的货', async () => {
const { createAdminConsignmentInventory } = await import('./consignment-admin-service.js')
// 手动入库的货没有提报单 → 没有寄卖单号 → 全流程无法用一个号锚定,业务已改为只允许平台提报
await assert.rejects(
() => createAdminConsignmentInventory({ itemName: '不该被创建的货' }, 'admin'),
(error: unknown) => {
const err = error as { errorCode?: string; code?: string; statusCode?: number }
assert.equal(err.errorCode || err.code, 'consignment_manual_inbound_disabled')
assert.equal(err.statusCode, 409)
return true
},
)
})
@@ -1,10 +1,4 @@
import {
EyeOutlined,
PlusOutlined,
ReloadOutlined,
RollbackOutlined,
StopOutlined,
} from '@ant-design/icons'
import { EyeOutlined, ReloadOutlined, RollbackOutlined, StopOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
@@ -35,7 +29,6 @@ import { useState } from 'react'
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
import {
archiveAdminConsignmentInventory,
createAdminConsignmentInventory,
fetchAdminConsignmentInventories,
fetchAdminConsignmentStats,
returnAdminConsignmentInventory,
@@ -147,13 +140,11 @@ export default function ConsignmentInventoryPanel() {
const [pageSize, setPageSize] = useState<number>(20)
// 弹窗状态
const [inboundModalOpen, setInboundModalOpen] = useState(false)
const [editingItem, setEditingItem] = useState<ConsignmentInventory | null>(null)
const [viewingItem, setViewingItem] = useState<ConsignmentInventory | null>(null)
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
const [returnReason, setReturnReason] = useState<string>('')
const [inboundForm] = Form.useForm()
const [editForm] = Form.useForm()
// 写操作按钮统一按 consignment.manage 门控:后端全部挂了 requireAdminPermission,
@@ -230,60 +221,6 @@ export default function ConsignmentInventoryPanel() {
}
}
// 手动入库提交
async function handleInboundSubmit() {
try {
const values = await inboundForm.validateFields()
const count = Number(values.count || 1)
const costPriceFen = Math.round(Number(values.costPrice || 0) * 100)
const depositAmountFen = values.depositAmount
? Math.round(Number(values.depositAmount) * 100)
: undefined
const selectedWorker = workerOptions.find((w) => w.value === values.workerId)
for (let i = 0; i < count; i++) {
await createAdminConsignmentInventory({
itemName: values.itemName,
system: values.system,
serverZone: values.serverZone,
series: values.series,
diamondLevel: Number(values.diamondLevel || 1),
brillianceCount: Number(values.brillianceCount || 0),
costPriceFen,
buyerName: values.buyerName,
shopName: values.shopName,
inboundBatch: values.inboundBatch,
sellerContact: values.sellerContact,
sellerGameId: values.sellerGameId,
sellerPaymentInfo: values.sellerPaymentInfo,
sellerPhone: values.sellerPhone,
workerId: values.workerId || undefined,
workerName: selectedWorker?.name || undefined,
workerPhone: selectedWorker?.phone || undefined,
depositAmountFen,
credentialsJson: {
loginMethod: values.loginMethod,
account: values.account,
password: values.password,
authNote: values.authNote,
},
screenshotsJson: values.screenshots ? [values.screenshots] : [],
remark: values.remark,
allowMatching: values.allowMatching !== false,
sourceType: 'manual',
})
}
message.success(`成功入库 ${count} 件寄售物品!`)
setInboundModalOpen(false)
inboundForm.resetFields()
await refresh()
} catch (err: any) {
if (err.errorFields) return
message.error(err.message || '创建寄售物品失败')
}
}
// 编辑更新提交
async function handleEditSubmit() {
if (!editingItem) return
@@ -807,28 +744,6 @@ export default function ConsignmentInventoryPanel() {
<Button icon={<ReloadOutlined />} onClick={refresh}>
刷新
</Button>
{canManageConsignment ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
inboundForm.resetFields()
inboundForm.setFieldsValue({
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: 1,
brillianceCount: 0,
count: 1,
allowMatching: true,
itemName: '苹果微信暗潮1钻',
})
setInboundModalOpen(true)
}}
>
手动入库
</Button>
) : null}
</Space>
</Space>
</Card>
@@ -873,245 +788,6 @@ export default function ConsignmentInventoryPanel() {
/>
</Card>
{/* 手动入库弹窗 */}
<Modal
title="寄售物品入库 (和平精英)"
open={inboundModalOpen}
width={720}
onCancel={() => setInboundModalOpen(false)}
onOk={handleInboundSubmit}
destroyOnClose
>
<Form form={inboundForm} layout="vertical">
<Row gutter={16}>
<Col span={6}>
<Form.Item name="system" label="系统" rules={[{ required: true }]}>
<Select
options={[
{ label: '苹果 (iOS)', value: '苹果' },
{ label: '安卓 (Android)', value: '安卓' },
{ label: '通用/双端', value: '通用' },
]}
onChange={() => autoFillItemName(inboundForm)}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="serverZone" label="区服" rules={[{ required: true }]}>
<Select
options={[
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
]}
onChange={() => autoFillItemName(inboundForm)}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="series" label="系列/枪系" rules={[{ required: true }]}>
<Select
options={[
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
]}
onChange={() => autoFillItemName(inboundForm)}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="diamondLevel" label="钻石等级" rules={[{ required: true }]}>
<Select
options={[
{ label: '1 钻', value: 1 },
{ label: '2 钻', value: 2 },
{ label: '3 钻', value: 3 },
{ label: '4 钻', value: 4 },
{ label: '5 钻', value: 5 },
{ label: '6 钻', value: 6 },
{ label: '7 钻', value: 7 },
]}
onChange={() => autoFillItemName(inboundForm)}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={6}>
<Form.Item name="brillianceCount" label="璀璨数">
<InputNumber
min={0}
max={7}
style={{ width: '100%' }}
onChange={() => autoFillItemName(inboundForm)}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="itemName"
label="物品显示名称"
rules={[{ required: true, message: '请输入物品名称' }]}
>
<Input placeholder="如:苹果微信暗潮1钻" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="costPrice"
label="收购价 (元)"
rules={[{ required: true, message: '请输入收购价格' }]}
>
<InputNumber min={0} step={1} style={{ width: '100%' }} placeholder="如 80" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="buyerName"
label="收购人 (客服姓名)"
rules={[{ required: true, message: '请填写收购人' }]}
>
<Input placeholder="如:小明" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="shopName" label="关联店铺">
<Input placeholder="如:淘宝旗舰店 / 拼多多" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="inboundBatch" label="收购批次/日期">
<Input placeholder="如:202609-01" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="sellerContact" label="卖家微信号/联系方式">
<Input placeholder="微信号或QQ" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="sellerPhone" label="卖家电话">
<Input placeholder="手机号" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="sellerGameId" label="卖家游戏ID/角色名">
<Input placeholder="游戏内名称" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="workerId"
label="走打手接单平台 (供货/履约打手)"
tooltip="关联打手账号后,工单匹配推荐时将直接指派该打手接单丢枪并冻结押金"
>
<Select
allowClear
showSearch
filterOption={(input, option) =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
placeholder="选择关联的接单平台打手账号 (选填)"
options={workerOptions}
/>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
name="depositAmount"
label="履约押金金额 (元)"
tooltip="指派该打手接单交付手枪时,平台自动冻结的押金金额(默认100元)"
>
<InputNumber
min={0}
step={10}
addonBefore="¥"
style={{ width: '100%' }}
placeholder="如 100"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="sellerPaymentInfo" label="卖家收款信息 (支付宝/微信号/银行卡)">
<Input placeholder="如:支付宝 13800000000 (张三)" />
</Form.Item>
<Card
type="inner"
size="small"
title="账号与上号凭据信息 (仅内部及打手履约可见)"
style={{ marginBottom: 16 }}
>
<Row gutter={16}>
<Col span={6}>
<Form.Item name="loginMethod" label="登录方式" style={{ marginBottom: 8 }}>
<Select
options={[
{ label: '微信扫码', value: 'wechat_qrcode' },
{ label: 'QQ扫码', value: 'qq_qrcode' },
{ label: '账号密码', value: 'account_pwd' },
{ label: '游戏内转赠/投喂', value: 'in_game_transfer' },
]}
/>
</Form.Item>
</Col>
<Col span={9}>
<Form.Item name="account" label="账号/手机" style={{ marginBottom: 8 }}>
<Input placeholder="账号" />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item name="password" label="密码/令牌" style={{ marginBottom: 8 }}>
<Input.Password placeholder="密码" />
</Form.Item>
</Col>
</Row>
<Form.Item name="authNote" label="上号说明/安全码" style={{ marginBottom: 0 }}>
<Input placeholder="备用验证码/二次验证说明等" />
</Form.Item>
</Card>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="count"
label="入库数量 (批量录入同类物品)"
tooltip="如果大于1,系统将自动创建对应数量的独立库存物品编号"
>
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="allowMatching"
label="是否立即允许匹配"
valuePropName="checked"
tooltip="开启后,发布新工单时系统会自动检测此库存进行推荐或一键核销"
>
<Switch defaultChecked checkedChildren="允许匹配" unCheckedChildren="禁止匹配" />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="内部备注">
<Input.TextArea rows={2} placeholder="如:来源某老客户置换、质押等" />
</Form.Item>
</Form>
</Modal>
{/* 编辑物品弹窗 */}
<Modal
title="编辑寄售物品"
@@ -23,13 +23,6 @@ export function getAdminConsignmentInventory(id: number | string) {
return apiGet<ConsignmentInventory>(`/api/v1/admin/worker-platform/consignment/inventories/${id}`)
}
export function createAdminConsignmentInventory(payload: Record<string, unknown>) {
return apiPost<ConsignmentInventory>(
'/api/v1/admin/worker-platform/consignment/inventories',
payload,
)
}
export function updateAdminConsignmentInventory(
id: number | string,
payload: Record<string, unknown>,
+23
View File
@@ -21,6 +21,7 @@
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
| `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` |
| 本批提交(批次 M,同一提交内含本文档,故不写自身 hash) | 批次 M:停用客服手动入库(前端入口删除 + 后端 409 守卫),寄卖货源只走平台提报 | `consignment-admin-service.ts`、`consignment-repo.ts`、`ConsignmentInventoryPanel.tsx` |
| 本批提交(批次 L,同一提交内含本文档,故不写自身 hash) | 批次 L:匹配弹窗两个动作一律二次确认 + 按钮分区/图标/后果说明,防点错 | `WorkOrdersPanel.tsx` |
| 本批提交(批次 K,同一提交内含本文档,故不写自身 hash) | 批次 K:匹配弹窗改用寄卖单号(候选查询补 join),客服界面不再展示内部仓库号 | `consignment-repo.ts`、`WorkOrdersPanel.tsx`、`ConsignmentInventoryPanel.tsx` |
| 本批提交(批次 J,同一提交内含本文档,故不写自身 hash) | 批次 J:凭证链可视化(提报凭证 + 赠送凭证),客服审核/仓库面板统一展示 | `consignment-admin-service.ts`、`consignment-repo.ts`、`ConsignmentSubmissionsPanel.tsx`、`ConsignmentInventoryPanel.tsx` |
@@ -282,6 +283,28 @@
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
### 批次 M · 停用客服手动入库(本轮)
**背景**:手动入库是唯一会造出「没有提报单的现货」的入口,而这类货**没有寄卖单号**,
于是全流程(统一状态机、凭证链、搜索、卖家端的单号锚定)都没法用一个号串起来——
它只能在界面上回退显示内部仓库号 INV…,正是"一件货两个号"的残留。
**业务定调**:寄卖货源**只走「打手在平台提报 → 客服审核入库」这一条路**。
**改法**:
1. ✅ 前端「寄售仓库」的「手动入库」入口整块删除(按钮 + 入库弹窗 + 提交逻辑 + 服务函数),
客服无法再建无提报单的货。
2. ✅ 后端同时挡住,避免旧页面缓存或直接调接口绕过:
`POST /worker-platform/consignment/inventories` 返回 409 `consignment_manual_inbound_disabled`
(保留明确的拒绝而不是静默删除,将来要恢复只需回退这个守卫)。死代码一并清掉:
服务层的原实现与仓库层的 `createConsignmentInventory` 都已删除(要恢复从 git 历史取)。
3. ✅ 提报审核入库(正常路径)不受影响:它照旧生成内部仓库号 INV… 并写入 `source_submission_id`,
即**每件货都必然挂着一个寄卖单号**,INV 只作为内部编号存在、界面不展示。
**真实库验证**:调用手动入库接口被拒(409 `consignment_manual_inbound_disabled`),
且库里「没有提报单的现货行数」前后均为 0(没有偷偷写入)。
### 批次 L · 匹配弹窗防点错(本轮)
**问题**:匹配弹窗里「走接单平台指派打手(冻结押金交付)」与「内部现货履约」两个按钮