diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index ca896f96..9ce5c252 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 - 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 { - 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(sql, values) - return res.rows[0]! -} - export async function updateConsignmentInventory( id: number | string, input: { diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 89713abc..7146962a 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -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( diff --git a/apps/backend/src/services/worker-platform/consignment-evidence.test.ts b/apps/backend/src/services/worker-platform/consignment-evidence.test.ts index 18b7b24d..b12c17d7 100644 --- a/apps/backend/src/services/worker-platform/consignment-evidence.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-evidence.test.ts @@ -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 + }, + ) +}) diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index bff2b352..1838fe3c 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -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(20) // 弹窗状态 - const [inboundModalOpen, setInboundModalOpen] = useState(false) const [editingItem, setEditingItem] = useState(null) const [viewingItem, setViewingItem] = useState(null) const [returnItem, setReturnItem] = useState(null) const [returnReason, setReturnReason] = useState('') - 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() { - {canManageConsignment ? ( - - ) : null} @@ -873,245 +788,6 @@ export default function ConsignmentInventoryPanel() { /> - {/* 手动入库弹窗 */} - setInboundModalOpen(false)} - onOk={handleInboundSubmit} - destroyOnClose - > -
- - - - autoFillItemName(inboundForm)} - /> - - - - - autoFillItemName(inboundForm)} - /> - - - - - - - - autoFillItemName(inboundForm)} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {/* 编辑物品弹窗 */} (`/api/v1/admin/worker-platform/consignment/inventories/${id}`) } -export function createAdminConsignmentInventory(payload: Record) { - return apiPost( - '/api/v1/admin/worker-platform/consignment/inventories', - payload, - ) -} - export function updateAdminConsignmentInventory( id: number | string, payload: Record, diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index 562e66da..922ee543 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -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 · 匹配弹窗防点错(本轮) **问题**:匹配弹窗里「走接单平台指派打手(冻结押金交付)」与「内部现货履约」两个按钮