diff --git a/apps/backend/src/services/worker-platform/admin-work-order-sharing-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-sharing-service.ts index b850c799..2272cb45 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-sharing-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-sharing-service.ts @@ -1,3 +1,4 @@ +import { query } from '../../db/client.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { createWorkOrderEvent, @@ -18,12 +19,59 @@ import { import { resolveSharingQuantity } from './admin-worker-catalog-service.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' +/** + * 拼单份额的寄卖价格:优先取匹配时固化的 allocation.unit_price_fen, + * 老数据缺失时回退库存当前售价。普通打手份额返回 null。 + */ +async function loadConsignmentShareSalePrices( + shares: Array<{ id: number; consignment_inventory_id?: number | null }>, +): Promise> { + const shareIds = shares + .filter((share) => Number(share.consignment_inventory_id || 0) > 0) + .map((share) => Number(share.id)) + const priceByShareId = new Map() + if (shareIds.length === 0) return priceByShareId + + const result = await query<{ + share_id: number | string + unit_price_fen: number | string | null + inventory_sale_price_fen: number | string | null + }>( + ` + SELECT + wos.id AS share_id, + cia.unit_price_fen, + ci.sale_price_fen AS inventory_sale_price_fen + FROM work_order_shares wos + LEFT JOIN LATERAL ( + SELECT unit_price_fen + FROM consignment_inventory_allocations + WHERE work_order_share_id = wos.id + ORDER BY id DESC + LIMIT 1 + ) cia ON TRUE + LEFT JOIN consignment_inventories ci ON ci.id = wos.consignment_inventory_id + WHERE wos.id = ANY($1::bigint[]) + `, + [shareIds], + ) + for (const row of result.rows) { + const price = Number(row.unit_price_fen ?? row.inventory_sale_price_fen ?? 0) + if (price > 0) priceByShareId.set(Number(row.share_id), Math.round(price)) + } + return priceByShareId +} + export async function getAdminWorkOrderSharing(workOrderId: number | string) { const workOrder = await getRequiredWorkOrder(workOrderId) const shares = await listWorkOrderShares(workOrder.id) + const priceByShareId = await loadConsignmentShareSalePrices(shares) return { order: mapWorkOrderAdmin(workOrder, shares), - shares: shares.map(mapWorkOrderShare), + shares: shares.map((share) => ({ + ...mapWorkOrderShare(share), + consignmentSalePriceFen: priceByShareId.get(Number(share.id)) ?? null, + })), } } diff --git a/apps/backend/src/services/worker-platform/consignment-price-display.test.ts b/apps/backend/src/services/worker-platform/consignment-price-display.test.ts new file mode 100644 index 00000000..01c0014d --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-price-display.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' +import { resolveConsignmentSalePriceDisplay } from './work-order-display-mappers.js' + +function order(overrides: Record = {}): WorkOrderRow { + return { + id: 1, + sharing_enabled: false, + material_json: {}, + ...overrides, + } as WorkOrderRow +} + +test('寄卖价格:整单优先使用匹配时快照', () => { + const result = resolveConsignmentSalePriceDisplay( + order({ + material_json: { + consignment: { salePriceFen: 27500, quantity: 1 }, + }, + }), + null, + ) + assert.deepEqual(result, { unitPriceFen: 27500, quantity: 1, totalFen: 27500 }) +}) + +test('寄卖价格:整单数量 > 1 时总价 = 单价 × 数量', () => { + const result = resolveConsignmentSalePriceDisplay( + order({ + material_json: { + consignment: { salePriceFen: 27500, quantity: 2 }, + }, + }), + null, + ) + assert.deepEqual(result, { unitPriceFen: 27500, quantity: 2, totalFen: 55000 }) +}) + +test('寄卖价格:老手动寄售没有快照时回退库存当前售价', () => { + const result = resolveConsignmentSalePriceDisplay( + order({ + material_json: { + consignment: { inventoryId: 739, costPriceFen: 0 }, + }, + }), + { sale_price_fen: 30000 } as ConsignmentInventoryRow, + ) + assert.deepEqual(result, { unitPriceFen: 30000, quantity: 1, totalFen: 30000 }) +}) + +test('寄卖价格:拼单主列不汇总,返回 null', () => { + const result = resolveConsignmentSalePriceDisplay( + order({ + sharing_enabled: true, + material_json: { + consignment: { salePriceFen: 27500, quantity: 1 }, + }, + }), + null, + ) + assert.equal(result, null) +}) + +test('寄卖价格:没有任何价格时返回 null', () => { + const result = resolveConsignmentSalePriceDisplay( + order({ + material_json: { + consignment: { inventoryId: 739 }, + }, + }), + null, + ) + assert.equal(result, null) +}) diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 47660f02..60cffd8b 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -244,6 +244,26 @@ function resolveWorkOrderCancelSource(cancelEventType: string | null | undefined return eventType || '未知来源' } +/** + * 工单列表「寄卖价格」:整单匹配优先用 material 中的匹配时快照, + * 老「手动匹配寄售」没有快照时回退库存当前售价;拼单由拼单明细逐份额展示。 + */ +export function resolveConsignmentSalePriceDisplay( + workOrder: WorkOrderRow, + consignmentInventory?: ConsignmentInventoryRow | null, +): { unitPriceFen: number; quantity: number; totalFen: number } | null { + if (workOrder.sharing_enabled === true) return null + const material = safeParseJson(workOrder.material_json) + const consignment = safeParseJson(material.consignment) + const unitPriceFen = Math.max( + 0, + Math.round(Number(consignment?.salePriceFen || consignmentInventory?.sale_price_fen || 0)), + ) + if (unitPriceFen <= 0) return null + const quantity = Math.max(1, Math.round(Number(consignment?.quantity || 1))) + return { unitPriceFen, quantity, totalFen: unitPriceFen * quantity } +} + export function mapWorkOrderAdmin( workOrder: WorkOrderRow, shares?: WorkOrderShareRow[], @@ -253,6 +273,7 @@ export function mapWorkOrderAdmin( const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) const acceptedShareQuantity = Math.max(0, Number(workOrder.accepted_share_quantity || 0)) + const consignmentSalePrice = resolveConsignmentSalePriceDisplay(workOrder, consignmentInventory) const hasActiveShares = activeShareQuantity > 0 const sharingFilled = workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity @@ -390,6 +411,10 @@ export function mapWorkOrderAdmin( }, capabilities, rewardAmount: Number(workOrder.reward_amount || 0), + /** 整单寄卖匹配价格(分);拼单为 null,价格在拼单明细展示 */ + consignmentSalePriceFen: consignmentSalePrice?.unitPriceFen ?? null, + consignmentSaleQuantity: consignmentSalePrice?.quantity ?? null, + consignmentSaleTotalFen: consignmentSalePrice?.totalFen ?? null, requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), /** 来源订单实付金额(分):取消部分时用于预填退款金额。 */ sourceOrderAmount: Number(workOrder.source_order_amount_fen || 0), diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx index cdebb7c4..faa1f26b 100644 --- a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -1,4 +1,16 @@ -import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd' +import { + App, + Alert, + Button, + Card, + Checkbox, + Empty, + Input, + Modal, + Space, + Tag, + Typography, +} from 'antd' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' @@ -8,11 +20,26 @@ import { matchAdminConsignmentWorkOrder, } from '@/services/admin/consignment' import type { ConsignmentMatchCandidate } from '@/types/consignment' +import { + computeConsignmentMatchScore, + describeConsignmentMatch, + resolveMatchCoverage, +} from '@/utils/consignment-match-coverage' function money(fen: number) { return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}` } +function resolveScoreTag(item: ConsignmentMatchCandidate) { + const type = item.matchType || 'exact' + const score = computeConsignmentMatchScore(item) + if (type === 'conflict') return { color: 'default', label: '不匹配' } + if (type === 'exact') return { color: 'green', label: '精确' } + if (score >= 90) return { color: 'blue', label: '非精确' } + if (score >= 70) return { color: 'orange', label: '非精确' } + return { color: 'gold', label: '需核对' } +} + export default function ConsignmentWorkOrderMatchModal({ open, workOrderId, @@ -26,6 +53,7 @@ export default function ConsignmentWorkOrderMatchModal({ }) { const { message } = App.useApp() const [selectedIds, setSelectedIds] = useState([]) + const [includeConflicts, setIncludeConflicts] = useState(false) const [submitting, setSubmitting] = useState(false) const [nonExactReason, setNonExactReason] = useState('') @@ -35,55 +63,127 @@ export default function ConsignmentWorkOrderMatchModal({ enabled: open && Boolean(workOrderId), }) const data = candidatesQuery.data?.data - const options = data?.options || [] + const options = useMemo(() => data?.options || [], [data?.options]) const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1)) - // 整单匹配只需一条库存(由该库存一次占满订单总数量);拼单才按剩余份数选多条。 - const needed = data?.sharingEnabled - ? Math.max(1, totalQuantity - Number(data?.activeQuantity || 0)) - : 1 + const activeQuantity = Math.max(0, Number(data?.activeQuantity || 0)) + const sharingEnabled = Boolean(data?.sharingEnabled) + const remainingNeeded = sharingEnabled + ? Math.max(0, totalQuantity - activeQuantity) + : totalQuantity + + const visibleOptions = useMemo( + () => + includeConflicts + ? options + : options.filter((item) => (item.matchType || 'exact') !== 'conflict'), + [includeConflicts, options], + ) + + const selectedOptions = useMemo( + () => options.filter((item) => selectedIds.includes(item.inventoryId)), + [options, selectedIds], + ) + + const coverage = useMemo( + () => + resolveMatchCoverage({ + sharingEnabled, + totalQuantity, + activeQuantity, + selected: selectedOptions.map((item) => ({ + inventoryId: item.inventoryId, + remainingQuantity: item.remainingQuantity, + })), + }), + [activeQuantity, selectedOptions, sharingEnabled, totalQuantity], + ) + + const counts = useMemo(() => { + let exact = 0 + let partial = 0 + let conflict = 0 + for (const item of options) { + const type = item.matchType || 'exact' + if (type === 'exact') exact += 1 + else if (type === 'conflict') conflict += 1 + else partial += 1 + } + return { exact, partial, conflict } + }, [options]) useEffect(() => { if (!open) { - setSelectedIds([]) + setSelectedIds((prev) => (prev.length ? [] : prev)) + setIncludeConflicts(false) setNonExactReason('') return } - if (options.length === 0) return - // 默认只选精确匹配;非精确库存必须人工勾选并填写原因。 - // 整单还要保证一条库存能一次满足订单总数量,否则默认选中也会被后端拒绝。 + if (options.length === 0 || remainingNeeded <= 0) { + setSelectedIds((prev) => (prev.length ? [] : prev)) + return + } + + // 默认只选精确库存;整单必须一条满足总量,拼单按剩余份数累加到一次拼满。 const exactOptions = options.filter( - (item) => - (item.matchType || 'exact') === 'exact' && - (!data?.sharingEnabled || item.remainingQuantity >= 1) && - (data?.sharingEnabled || item.remainingQuantity >= totalQuantity), + (item) => (item.matchType || 'exact') === 'exact' && Number(item.remainingQuantity || 0) > 0, ) - setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId)) - }, [open, options, needed, data?.sharingEnabled, totalQuantity]) + if (!sharingEnabled) { + const chosen = exactOptions.find( + (item) => Number(item.remainingQuantity || 0) >= totalQuantity, + ) + setSelectedIds(chosen ? [chosen.inventoryId] : []) + return + } + + let covered = 0 + const ids: number[] = [] + for (const item of exactOptions) { + if (covered >= remainingNeeded) break + ids.push(item.inventoryId) + covered += Math.max(0, Number(item.remainingQuantity || 0)) + } + setSelectedIds(ids) + }, [open, options, remainingNeeded, sharingEnabled, totalQuantity]) + + // 隐藏不匹配项时,顺手清掉已选中的冲突库存,避免提交到不可见的行。 + useEffect(() => { + if (includeConflicts) return + setSelectedIds((prev) => { + const next = prev.filter((id) => { + const item = options.find((option) => option.inventoryId === id) + return !item || (item.matchType || 'exact') !== 'conflict' + }) + return next.length === prev.length ? prev : next + }) + }, [includeConflicts, options]) const nonExactSelected = useMemo( () => - selectedIds.some((id) => { - const item = options.find((option) => option.inventoryId === id) - return Boolean(item?.matchType && item.matchType !== 'exact') + selectedOptions.some((item) => { + const type = item.matchType || 'exact' + return type !== 'exact' }), - [options, selectedIds], + [selectedOptions], ) - const totalPrice = useMemo( - () => - selectedIds.reduce((sum, id) => { - const item = options.find((option) => option.inventoryId === id) - return sum + Number(item?.salePriceFen || 0) - }, 0), - [options, selectedIds], - ) + const canSubmit = + selectedIds.length > 0 && + coverage.sufficient && + (!nonExactSelected || nonExactReason.trim().length > 0) + + const infoMessage = + remainingNeeded <= 0 + ? '该工单已匹配满,无需再次匹配。' + : sharingEnabled + ? `该工单拼单 ${totalQuantity} 份,已匹配 ${activeQuantity} 份,本次还需 ${remainingNeeded} 份;必须一次选满才能提交。` + : `该工单为整单匹配,请选择 1 条可一次满足 ${totalQuantity} 件的库存。` return ( @@ -91,7 +191,7 @@ export default function ConsignmentWorkOrderMatchModal({ ) : null} {canCustomerRefund && @@ -2471,7 +2502,7 @@ export default function WorkOrdersPanel() { setPageSize(nextPageSize) }, })} - scroll={{ x: 1240 }} + scroll={{ x: 1360 }} /> share.problemMarked ? 'admin-share-row-problem' @@ -3716,6 +3747,25 @@ export default function WorkOrdersPanel() { align: 'right', render: (_, share) => formatMoney(share.shareReward), }, + { + title: '寄卖价格', + width: 124, + align: 'right', + render: (_, share) => { + if (!share.consignmentInventoryId) return '-' + const unitPriceFen = Number(share.consignmentSalePriceFen || 0) + if (unitPriceFen <= 0) return '-' + const quantity = Math.max(1, Number(share.quantity || 1)) + return ( + + {formatMoney(unitPriceFen)}/份 + + 合计 {formatMoney(unitPriceFen * quantity)} + + + ) + }, + }, { title: '状态', // 状态 + 取消原因两个 Tag 纵向排列;宽度按最长原因「后台取消部分」留足, @@ -4567,9 +4617,13 @@ export default function WorkOrdersPanel() { workOrderId={consignmentMatchOrder?.workOrderId ?? null} onClose={() => setConsignmentMatchOrder(null)} onMatched={() => { - void queryClient.invalidateQueries({ queryKey: ['admin-work-orders'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-work-orders-stats'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-consignment-inventories'] }) + void refreshAll() + const matchedOrderId = consignmentMatchOrder?.workOrderId + if (matchedOrderId) { + void queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', matchedOrderId], + }) + } }} /> @@ -4586,7 +4640,7 @@ export default function WorkOrdersPanel() { : null } onMatched={() => { - void ordersQuery.refetch() + void refreshAll() }} /> diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 7961547a..8127caec 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -617,6 +617,8 @@ export type WorkOrderShare = { cancelQuantity?: number /** 寄卖拼单:本份额对应的库存与加好友/赠送冷却状态 */ consignmentInventoryId?: number | null + /** 本份额寄卖价格(分,匹配时快照;普通份额为 null) */ + consignmentSalePriceFen?: number | null friendAddedAt?: string | null giftAvailableAt?: string | null /** 份额级问题单:只影响该份额持有人,母单状态不变 */ @@ -704,6 +706,10 @@ export type WorkOrder = { canResetGiftFlow?: boolean } rewardAmount: number + /** 整单寄卖匹配价格(分);拼单为 null,价格在拼单明细展示 */ + consignmentSalePriceFen?: number | null + consignmentSaleQuantity?: number | null + consignmentSaleTotalFen?: number | null /** 来源订单实付金额(分):取消部分时用于预填退款金额。 */ sourceOrderAmount?: number /** 最近一次修改用户付款金额前的金额(分);NULL 表示修改前金额未知,仅后台可见。 */ diff --git a/apps/frontend/src/utils/admin-worker-platform-tabs.ts b/apps/frontend/src/utils/admin-worker-platform-tabs.ts index 3374f63e..0f085416 100644 --- a/apps/frontend/src/utils/admin-worker-platform-tabs.ts +++ b/apps/frontend/src/utils/admin-worker-platform-tabs.ts @@ -46,7 +46,7 @@ export const WORKER_PLATFORM_TAB_QUERY_KEYS: Record = { ['admin-consignment-submissions'], ['admin-consignment-submissions-pending-total'], ], - orders: [['admin-work-orders'], ['admin-work-orders-stats']], + orders: [['admin-worker-platform-orders'], ['admin-worker-platform-summary']], 'cancel-requests': [['admin-work-order-cancel-requests']], 'after-sales': [['admin-after-sales-orders'], ['admin-after-sales-stats']], feedbacks: [['admin-feedbacks']], diff --git a/apps/frontend/src/utils/consignment-match-coverage.ts b/apps/frontend/src/utils/consignment-match-coverage.ts new file mode 100644 index 00000000..db5cd27f --- /dev/null +++ b/apps/frontend/src/utils/consignment-match-coverage.ts @@ -0,0 +1,82 @@ +import type { ConsignmentMatchCandidate } from '../types/consignment' + +/** 与寄售仓库「一键查询待匹配订单」同一口径的 0-100 匹配打分。 */ +export function computeConsignmentMatchScore( + item: Pick, +): number { + const details = (item.matchDetails || []).filter((detail) => detail.status !== 'ignored') + if (details.length === 0) { + const type = item.matchType || 'exact' + if (type === 'exact') return 100 + if (type === 'partial') return 85 + if (type === 'unknown') return 50 + return 0 + } + const matched = details.reduce((sum, detail) => { + if (detail.status === 'match') return sum + 1 + if (detail.status === 'partial') return sum + 0.85 + if (detail.status === 'unknown') return sum + 0.5 + return sum + }, 0) + return Math.round((matched / details.length) * 100) +} + +/** 匹配说明:精确 / 高配 / 缺规格 / 冲突原因。 */ +export function describeConsignmentMatch( + item: Pick, +): string { + const type = item.matchType || 'exact' + if (type === 'exact') return '规格完全一致' + const reasons = (item.matchReasons || []).filter(Boolean) + if (reasons.length > 0) { + return `${type === 'conflict' ? '不匹配' : '待核对'}:${reasons.join(';')}` + } + if (type === 'partial') return '规格高于要求(非精确)' + return '需人工核对' +} + +/** + * 选中的库存是否够一次拼满。 + * + * - 拼单:按剩余份数累加,必须覆盖「总份数 - 已匹配份数」; + * - 整单:只能选一条,且该条剩余数量必须 >= 订单总数量。 + */ +export function resolveMatchCoverage(input: { + sharingEnabled: boolean + totalQuantity: number + activeQuantity: number + selected: Array> +}): { + requiredQuantity: number + selectedQuantity: number + sufficient: boolean + gap: number +} { + const total = Math.max(1, Math.round(Number(input.totalQuantity || 0))) + const active = Math.max(0, Math.round(Number(input.activeQuantity || 0))) + const remaining = input.selected.reduce( + (sum, item) => sum + Math.max(0, Math.round(Number(item.remainingQuantity || 0))), + 0, + ) + + if (input.sharingEnabled) { + const required = Math.max(0, total - active) + return { + requiredQuantity: required, + selectedQuantity: remaining, + sufficient: required > 0 && remaining >= required, + gap: Math.max(0, required - remaining), + } + } + + const selectedQuantity = input.selected.reduce( + (max, item) => Math.max(max, Math.round(Number(item.remainingQuantity || 0))), + 0, + ) + return { + requiredQuantity: total, + selectedQuantity, + sufficient: input.selected.length === 1 && selectedQuantity >= total, + gap: Math.max(0, total - selectedQuantity), + } +} diff --git a/apps/frontend/tests/admin-worker-platform-tabs.test.ts b/apps/frontend/tests/admin-worker-platform-tabs.test.ts index 7ce2a2e0..d8ef3585 100644 --- a/apps/frontend/tests/admin-worker-platform-tabs.test.ts +++ b/apps/frontend/tests/admin-worker-platform-tabs.test.ts @@ -25,6 +25,13 @@ test('对接客服 tab 必须被 isWorkerPlatformTab 接受,并登记切页刷 ]) }) +test('订单 tab 切页刷新必须指向真实列表与统计查询键', () => { + assert.deepEqual(WORKER_PLATFORM_TAB_QUERY_KEYS.orders, [ + ['admin-worker-platform-orders'], + ['admin-worker-platform-summary'], + ]) +}) + test('每个登记了切页缓存的 tab 都必须在合法 tab 名单里', () => { const nonTabs = new Set(WORKER_PLATFORM_NON_TAB_QUERY_KEYS) for (const key of Object.keys(WORKER_PLATFORM_TAB_QUERY_KEYS)) { diff --git a/apps/frontend/tests/consignment-match-coverage.test.ts b/apps/frontend/tests/consignment-match-coverage.test.ts new file mode 100644 index 00000000..f3ee96bd --- /dev/null +++ b/apps/frontend/tests/consignment-match-coverage.test.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +import { + computeConsignmentMatchScore, + describeConsignmentMatch, + resolveMatchCoverage, +} from '../src/utils/consignment-match-coverage.ts' + +test('匹配打分:与仓库列表同口径(精确 100 / 高配 85 / 未知 50 / 冲突 0)', () => { + assert.equal(computeConsignmentMatchScore({ matchType: 'exact', matchDetails: [] }), 100) + assert.equal(computeConsignmentMatchScore({ matchType: 'partial', matchDetails: [] }), 85) + assert.equal(computeConsignmentMatchScore({ matchType: 'unknown', matchDetails: [] }), 50) + assert.equal(computeConsignmentMatchScore({ matchType: 'conflict', matchDetails: [] }), 0) + assert.equal( + computeConsignmentMatchScore({ + matchType: 'partial', + matchDetails: [ + { + key: 'grade', + label: '等级', + orderValue: 'A', + inventoryValue: 'S', + status: 'match', + }, + { + key: 'series', + label: '系列', + orderValue: 'A', + inventoryValue: 'S', + status: 'partial', + }, + ], + }), + 93, + ) +}) + +test('匹配说明:精确/非精确/冲突都必须有可读文案', () => { + assert.equal(describeConsignmentMatch({ matchType: 'exact' }), '规格完全一致') + assert.equal( + describeConsignmentMatch({ matchType: 'partial', matchReasons: [] }), + '规格高于要求(非精确)', + ) + assert.equal( + describeConsignmentMatch({ + matchType: 'conflict', + matchReasons: ['系统不一致', '区服不一致'], + }), + '不匹配:系统不一致;区服不一致', + ) +}) + +test('拼单覆盖:1+1 不够 3 份,2+1 才允许提交', () => { + const short = resolveMatchCoverage({ + sharingEnabled: true, + totalQuantity: 3, + activeQuantity: 0, + selected: [ + { inventoryId: 1, remainingQuantity: 1 }, + { inventoryId: 2, remainingQuantity: 1 }, + ], + }) + assert.equal(short.requiredQuantity, 3) + assert.equal(short.selectedQuantity, 2) + assert.equal(short.sufficient, false) + assert.equal(short.gap, 1) + + const enough = resolveMatchCoverage({ + sharingEnabled: true, + totalQuantity: 3, + activeQuantity: 0, + selected: [ + { inventoryId: 1, remainingQuantity: 2 }, + { inventoryId: 2, remainingQuantity: 1 }, + ], + }) + assert.equal(enough.selectedQuantity, 3) + assert.equal(enough.sufficient, true) + assert.equal(enough.gap, 0) +}) + +test('拼单覆盖:已匹配 1 份时只需再选 2 份', () => { + const result = resolveMatchCoverage({ + sharingEnabled: true, + totalQuantity: 3, + activeQuantity: 1, + selected: [{ inventoryId: 1, remainingQuantity: 2 }], + }) + assert.equal(result.requiredQuantity, 2) + assert.equal(result.sufficient, true) +}) + +test('整单覆盖:必须一条库存满足全部数量', () => { + const short = resolveMatchCoverage({ + sharingEnabled: false, + totalQuantity: 3, + activeQuantity: 0, + selected: [{ inventoryId: 1, remainingQuantity: 2 }], + }) + assert.equal(short.requiredQuantity, 3) + assert.equal(short.selectedQuantity, 2) + assert.equal(short.sufficient, false) + assert.equal(short.gap, 1) + + const enough = resolveMatchCoverage({ + sharingEnabled: false, + totalQuantity: 3, + activeQuantity: 0, + selected: [{ inventoryId: 1, remainingQuantity: 3 }], + }) + assert.equal(enough.sufficient, true) +}) + +test('工单页:internal_matching 显示「手动匹配寄卖」入口', () => { + const source = readFileSync( + fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)), + 'utf8', + ) + assert.ok(source.includes('手动匹配寄卖'), '内部匹配中的按钮文案必须是手动匹配寄卖') + assert.ok( + source.includes("String(row.status || '') === 'internal_matching'"), + '按钮文案必须只在 internal_matching 下切换', + ) +}) + +test('工单页:手动匹配寄卖成功后必须按真实查询键刷新列表与库存', () => { + const source = readFileSync( + fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)), + 'utf8', + ) + const modalIndex = source.indexOf(' 0, '工单页必须挂载手动匹配寄卖弹窗') + const matchedIndex = source.indexOf('onMatched={() => {', modalIndex) + const matchedBlock = source.slice(matchedIndex, source.indexOf('}}', matchedIndex) + 2) + assert.ok(matchedBlock.includes('refreshAll()'), '匹配成功必须调用 refreshAll() 重取列表') + assert.ok( + !matchedBlock.includes("'admin-work-orders'") && + !matchedBlock.includes("'admin-work-orders-stats'"), + '不得 invalidate 不存在的 admin-work-orders / admin-work-orders-stats 查询键', + ) + + const refreshIndex = source.indexOf('async function refreshAll()') + const refreshBlock = source.slice( + refreshIndex, + source.indexOf('async function runAction', refreshIndex), + ) + for (const key of [ + 'admin-worker-platform-orders', + 'admin-worker-platform-summary', + 'admin-consignment-inventories', + 'admin-consignment-stats', + ]) { + assert.ok(refreshBlock.includes(key), `refreshAll() 必须刷新 ${key}`) + } +}) diff --git a/apps/frontend/tests/consignment-price-display.test.ts b/apps/frontend/tests/consignment-price-display.test.ts new file mode 100644 index 00000000..18b30847 --- /dev/null +++ b/apps/frontend/tests/consignment-price-display.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +function readWorkOrdersPanel() { + return readFileSync( + fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)), + 'utf8', + ) +} + +test('工单表:发单金额后必须有独立「寄卖价格」列', () => { + const source = readWorkOrdersPanel() + assert.ok(source.includes("title: '发单金额'"), '找不到发单金额列') + assert.ok(source.includes("title: '寄卖价格'"), '缺少寄卖价格列') + assert.ok(source.includes('consignmentSaleTotalFen'), '主列必须读取整单寄卖总价') + assert.ok(source.includes('consignmentSaleQuantity'), '数量 > 1 时必须展示单价 × 数量') + const amountIndex = source.indexOf("title: '发单金额'") + const priceIndex = source.indexOf("title: '寄卖价格'", amountIndex) + assert.ok(priceIndex > amountIndex, '寄卖价格列必须在发单金额之后') +}) + +test('拼单明细:必须有「寄卖价格」列并展示单价 + 份额合计', () => { + const source = readWorkOrdersPanel() + assert.ok(source.includes("title: '份额报酬'"), '找不到份额报酬列') + const rewardIndex = source.indexOf("title: '份额报酬'") + const priceIndex = source.indexOf("title: '寄卖价格'", rewardIndex) + assert.ok(priceIndex > rewardIndex, '寄卖价格列必须在份额报酬之后') + assert.ok(source.includes('/份'), '寄卖价格必须展示每份单价') + assert.ok(source.includes('合计 {formatMoney(unitPriceFen * quantity)}'), '必须展示该份额合计') +})