fix(consignment): 手动匹配寄卖后刷新工单列表并展示寄卖价格
This commit is contained in:
@@ -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<Map<number, number>> {
|
||||
const shareIds = shares
|
||||
.filter((share) => Number(share.consignment_inventory_id || 0) > 0)
|
||||
.map((share) => Number(share.id))
|
||||
const priceByShareId = new Map<number, number>()
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}): 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)
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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<number[]>([])
|
||||
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 (
|
||||
<Modal
|
||||
title="寄卖库存匹配"
|
||||
title="手动匹配寄卖"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={860}
|
||||
width="min(1180px, 96vw)"
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space>
|
||||
@@ -91,7 +191,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={selectedIds.length === 0 || (nonExactSelected && !nonExactReason.trim())}
|
||||
disabled={!canSubmit}
|
||||
onClick={async () => {
|
||||
if (!workOrderId) return
|
||||
setSubmitting(true)
|
||||
@@ -128,29 +228,62 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
{options.length > 0 ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
type={remainingNeeded <= 0 ? 'warning' : 'info'}
|
||||
showIcon
|
||||
message={
|
||||
data?.sharingEnabled
|
||||
? `该工单拼单 ${totalQuantity} 份,已匹配 ${data.activeQuantity} 份,本次还需 ${needed} 份库存。`
|
||||
: `该工单为整单匹配,请选择 1 条可一次满足 ${totalQuantity} 件的库存。`
|
||||
}
|
||||
description="自动匹配只做精确规格;拼单工单可多选,每条库存按剩余数量生成份额。选择非精确库存必须填写原因。"
|
||||
message={infoMessage}
|
||||
description="系统候选按寄卖配置打分;非精确/冲突库存必须人工勾选并填写原因。拼单必须一次拼满,失败不会占用任何库存。"
|
||||
/>
|
||||
<Space wrap size={8}>
|
||||
<Tag color="blue">
|
||||
{sharingEnabled ? `待匹配 ${remainingNeeded} 份` : `整单 ${totalQuantity} 件`}
|
||||
</Tag>
|
||||
{sharingEnabled ? <Tag>已匹配 {activeQuantity} 份</Tag> : null}
|
||||
<Tag>候选 {visibleOptions.length} 条</Tag>
|
||||
<Tag color="green">精确 {counts.exact}</Tag>
|
||||
<Tag color="orange">非精确 {counts.partial}</Tag>
|
||||
<Tag>冲突 {counts.conflict}</Tag>
|
||||
<Checkbox
|
||||
checked={includeConflicts}
|
||||
onChange={(event) => setIncludeConflicts(event.target.checked)}
|
||||
>
|
||||
显示不匹配项
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<PercentTable<ConsignmentMatchCandidate>
|
||||
rowKey="inventoryId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={options}
|
||||
loading={candidatesQuery.isFetching}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
scroll={{ x: 1050, y: 420 }}
|
||||
dataSource={visibleOptions}
|
||||
rowSelection={{
|
||||
// 整单只能选一条;拼单可多选,每条库存按剩余数量生成份额。
|
||||
type: data?.sharingEnabled ? 'checkbox' : 'radio',
|
||||
// 整单只能选一条;拼单可多选,必须覆盖剩余份数才能提交。
|
||||
type: sharingEnabled ? 'checkbox' : 'radio',
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '匹配打分',
|
||||
key: 'score',
|
||||
width: 100,
|
||||
render: (_, item) => {
|
||||
const score = computeConsignmentMatchScore(item)
|
||||
const tag = resolveScoreTag(item)
|
||||
return (
|
||||
<Space direction="vertical" size={3} style={{ width: '100%' }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: 1 }}>{score}</span>
|
||||
<Tag color={tag.color} style={{ marginInlineEnd: 0 }}>
|
||||
{tag.label}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '寄卖库存',
|
||||
key: 'inventory',
|
||||
width: 210,
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{item.itemName}</Typography.Text>
|
||||
@@ -162,33 +295,15 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
},
|
||||
{
|
||||
title: '规格',
|
||||
key: 'spec',
|
||||
width: 210,
|
||||
render: (_, item) =>
|
||||
item.specText ||
|
||||
`${item.system}/${item.serverZone}/${item.series}/${item.diamondLevel}钻/${item.brillianceCount}璀`,
|
||||
},
|
||||
{
|
||||
title: '匹配',
|
||||
width: 90,
|
||||
render: (_, item) => {
|
||||
const type = item.matchType || 'exact'
|
||||
const label =
|
||||
type === 'exact'
|
||||
? '精确'
|
||||
: type === 'partial'
|
||||
? '高配'
|
||||
: type === 'unknown'
|
||||
? '未知'
|
||||
: '冲突'
|
||||
const color = type === 'exact' ? 'green' : type === 'partial' ? 'orange' : 'red'
|
||||
return (
|
||||
<Tag color={color} title={item.matchReasons?.join('、') || ''}>
|
||||
{label}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
key: 'stock',
|
||||
width: 90,
|
||||
render: (_, item) => (
|
||||
<Tag color={item.remainingQuantity > 1 ? 'blue' : 'default'}>
|
||||
@@ -198,11 +313,44 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
},
|
||||
{
|
||||
title: '售价',
|
||||
width: 110,
|
||||
key: 'price',
|
||||
width: 100,
|
||||
render: (_, item) => money(item.salePriceFen),
|
||||
},
|
||||
{
|
||||
title: '匹配说明',
|
||||
key: 'reason',
|
||||
width: 280,
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Typography.Text style={{ fontSize: 12 }}>
|
||||
{describeConsignmentMatch(item)}
|
||||
</Typography.Text>
|
||||
{(item.matchDetails || [])
|
||||
.filter((detail) => detail.status !== 'match' && detail.status !== 'ignored')
|
||||
.slice(0, 3)
|
||||
.map((detail) => (
|
||||
<Typography.Text key={detail.key} type="secondary" style={{ fontSize: 11 }}>
|
||||
{detail.label}:订单「{detail.orderValue || '-'}」/ 库存「
|
||||
{detail.inventoryValue || '-'}」
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{selectedIds.length > 0 && !coverage.sufficient ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
sharingEnabled
|
||||
? `还差 ${coverage.gap} 份,拼单必须一次拼满才能提交`
|
||||
: `所选库存仅剩 ${coverage.selectedQuantity} 件,整单需要 ${coverage.requiredQuantity} 件`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{nonExactSelected ? (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
@@ -219,7 +367,8 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
</Space>
|
||||
) : null}
|
||||
<Typography.Text type="secondary">
|
||||
已选 {selectedIds.length} 条,合计成本 {money(totalPrice)}
|
||||
已选 {selectedIds.length} 条,覆盖 {coverage.selectedQuantity}{' '}
|
||||
{sharingEnabled ? '份' : '件'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
@@ -1834,6 +1834,32 @@ export default function WorkOrdersPanel() {
|
||||
<AmountEditableCell order={row} field="rewardAmount" onSaved={refreshAll} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '寄卖价格',
|
||||
width: 120,
|
||||
render: (_, row) => {
|
||||
// 拼单的价格按份额展示在「拼单明细」,主列不汇总,避免口径混淆。
|
||||
if (row.sharing?.enabled) {
|
||||
return <Typography.Text type="secondary">-</Typography.Text>
|
||||
}
|
||||
const totalPriceFen = Number(row.consignmentSaleTotalFen || 0)
|
||||
if (totalPriceFen <= 0) {
|
||||
return <Typography.Text type="secondary">-</Typography.Text>
|
||||
}
|
||||
const unitPriceFen = Number(row.consignmentSalePriceFen || 0)
|
||||
const quantity = Math.max(1, Number(row.consignmentSaleQuantity || 1))
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{formatMoney(totalPriceFen)}</Typography.Text>
|
||||
{quantity > 1 ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
单价 {formatMoney(unitPriceFen)} × {quantity}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '押金',
|
||||
width: 130,
|
||||
@@ -2055,11 +2081,16 @@ export default function WorkOrdersPanel() {
|
||||
{canFulfillConsignment &&
|
||||
['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? (
|
||||
<Button
|
||||
type={String(row.status || '') === 'internal_matching' ? 'primary' : 'default'}
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setConsignmentMatchOrder(row)}
|
||||
title="按寄卖配置精确匹配库存;拼单工单可选多条库存一起拼"
|
||||
title={
|
||||
String(row.status || '') === 'internal_matching'
|
||||
? '手动选择寄卖库存并立即匹配;拼单必须一次拼满'
|
||||
: '按寄卖配置精确匹配库存;拼单工单可选多条库存一起拼'
|
||||
}
|
||||
>
|
||||
内部库存匹配
|
||||
{String(row.status || '') === 'internal_matching' ? '手动匹配寄卖' : '内部库存匹配'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canCustomerRefund &&
|
||||
@@ -2471,7 +2502,7 @@ export default function WorkOrdersPanel() {
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
scroll={{ x: 1240 }}
|
||||
scroll={{ x: 1360 }}
|
||||
/>
|
||||
</Card>
|
||||
<Drawer
|
||||
@@ -3693,7 +3724,7 @@ export default function WorkOrdersPanel() {
|
||||
loading={sharingQuery.isFetching}
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1914 }}
|
||||
scroll={{ x: 2038 }}
|
||||
rowClassName={(share) =>
|
||||
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 (
|
||||
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||
<span>{formatMoney(unitPriceFen)}/份</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
合计 {formatMoney(unitPriceFen * quantity)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
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()
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 表示修改前金额未知,仅后台可见。 */
|
||||
|
||||
@@ -46,7 +46,7 @@ export const WORKER_PLATFORM_TAB_QUERY_KEYS: Record<string, string[][]> = {
|
||||
['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']],
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ConsignmentMatchCandidate } from '../types/consignment'
|
||||
|
||||
/** 与寄售仓库「一键查询待匹配订单」同一口径的 0-100 匹配打分。 */
|
||||
export function computeConsignmentMatchScore(
|
||||
item: Pick<ConsignmentMatchCandidate, 'matchType' | 'matchDetails'>,
|
||||
): 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<ConsignmentMatchCandidate, 'matchType' | 'matchReasons'>,
|
||||
): 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<Pick<ConsignmentMatchCandidate, 'inventoryId' | 'remainingQuantity'>>
|
||||
}): {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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('<ConsignmentWorkOrderMatchModal')
|
||||
assert.ok(modalIndex > 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}`)
|
||||
}
|
||||
})
|
||||
@@ -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)}'), '必须展示该份额合计')
|
||||
})
|
||||
Reference in New Issue
Block a user