feat(consignment): 规格口径定案——互通通配 + 后台全量/发布端按物品展示

业务定案:
- 操作后台全量:系统 苹果/安卓/通用,区服 微信区/QQ区/互通;提报审核与
  库存编辑的系统/区服从自由文本改为受控下拉(单一来源 utils/consignment-spec.ts)
- 打手端发布按物品类型展示:苏尔南系统二选一(必须区分端),区服唯一
  「互通」选项且默认选中;后续其他品类在共享常量按品类加条目
- 匹配引擎通配符对称化:区服通配=互通(通用/全区/不限归一化为别名),
  系统通配=通用(双端/全端同理);订单侧写通配同样放行,只增不减匹配面;
  差异描述不把通配对当冲突
- 提报白名单校验(API 层):系统只收 苹果/安卓,区服只收 微信/QQ/互通,
  旧页面缓存或直调接口绕不过
- 物品名拼接:区服为互通时不拼入(对买家无信息量),前后端同口径

后端 481(+互通双向匹配/通配不算差异/真冲突仍排除/白名单校验)、
前端 54(+选项口径/表单引用/默认值)测试全绿;typecheck/lint/format/check:sql 通过
docs/寄卖系统优化方案.md 记录批次 W
This commit is contained in:
yml2213
2026-09-17 13:01:59 +08:00
parent b666a9c951
commit f8101c1730
10 changed files with 265 additions and 21 deletions
@@ -3,6 +3,7 @@ import test from 'node:test'
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
import {
describeConsignmentSpecDifferences,
extractPeacekeeperSpecs,
findMatchingPeacekeeperInventories,
matchPeacekeeperOrderWithInventory,
@@ -336,3 +337,62 @@ test('通用件与明确规格冲突时仍然排除', () => {
const wrongZone = buildInventory({ server_zone: 'QQ区' })
assert.equal(matchPeacekeeperOrderWithInventory(order, wrongZone), null)
})
test('区服「互通」与微信/QQ 订单互相匹配,且不算差异', () => {
const wxOrder = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
const qqOrder = extractPeacekeeperSpecs('安卓QQ区 暗潮 1钻')
const interflowInventory = buildInventory({ server_zone: '互通' })
// 互通现货:微信 / QQ 订单都能精确匹配
assert.equal(matchPeacekeeperOrderWithInventory(wxOrder, interflowInventory)?.matchType, 'exact')
assert.equal(matchPeacekeeperOrderWithInventory(qqOrder, interflowInventory)?.matchType, 'exact')
// 订单写「互通」:微信 / QQ 现货也能匹配(卖家选哪个区服的货都能交付)
const interflowOrder = extractPeacekeeperSpecs('安卓互通 暗潮 1钻')
assert.equal(
matchPeacekeeperOrderWithInventory(interflowOrder, buildInventory())?.matchType,
'exact',
)
assert.equal(
matchPeacekeeperOrderWithInventory(interflowOrder, buildInventory({ server_zone: 'QQ区' }))
?.matchType,
'exact',
)
// 差异描述不把「互通」当冲突(手动指定时别吓唬客服)
const diffs = describeConsignmentSpecDifferences(wxOrder, interflowInventory)
assert.equal(
diffs.some((item) => item.startsWith('区服')),
false,
)
})
test('订单写「通用/双端」时买家不限系统,两端现货都匹配;旧数据里的「通用」库存不回归', () => {
const dualOrder = extractPeacekeeperSpecs('通用微信区 暗潮 1钻')
assert.ok(matchPeacekeeperOrderWithInventory(dualOrder, buildInventory()))
assert.ok(matchPeacekeeperOrderWithInventory(dualOrder, buildInventory({ system: '苹果' })))
// 新提报已禁「通用」,但历史库存行仍是「通用」:与任一端订单照常匹配(既有行为)
const legacyUniversal = buildInventory({ system: '通用' })
assert.ok(
matchPeacekeeperOrderWithInventory(
extractPeacekeeperSpecs('苹果微信区 暗潮 1钻'),
legacyUniversal,
),
)
})
test('真冲突仍然排除:互通救不了系统不符,也救不了系列不符', () => {
const order = extractPeacekeeperSpecs('苹果微信区 暗潮 1钻')
assert.equal(
matchPeacekeeperOrderWithInventory(order, buildInventory({ server_zone: '互通' })),
null,
)
assert.equal(
matchPeacekeeperOrderWithInventory(
order,
buildInventory({ server_zone: '互通', series: '怒嚎' }),
),
null,
)
})
@@ -35,6 +35,8 @@ function extractPeacekeeperServerZone(value: string): string {
.trim()
.toLowerCase()
if (!text) return ''
// 互通 = QQ/微信都能收,是与 微信区/QQ区 平级的第三种区服取值
if (text.includes('互通')) return '互通'
if (text.includes('通用') || text.includes('全区') || text.includes('不限')) return '通用'
if (text.includes('微信') || text === 'wx' || text.includes('wx区')) return '微信区'
if (text.includes('qq') || text.includes('手q') || text.includes('企鹅')) return 'QQ区'
@@ -126,18 +128,29 @@ export function extractPeacekeeperSpecs(
/**
* 单项规格比对结论:
* - `match` 双方都有值且一致(或库存侧是"通用"件)
* - `match` 双方都有值且一致,或任一侧是通配值(区服的「互通」、系统的「通用」都表示不限,
* 订单写"通用/双端"意味着买家两端都收,与具体库存同样算 match)
* - `mismatch` 双方都有值且冲突 → 直接排除该库存
* - `unknown` 任一侧没值,无法确认 → **不能算精确匹配**,只能作为"部分匹配"候选
*/
type SpecVerdict = 'match' | 'mismatch' | 'unknown'
function compareSpec(orderValue: string, inventoryValue: string): SpecVerdict {
/** 系统侧通配值:新提报已不允许「通用」,但旧库存行与订单文案里仍可能出现 */
const SYSTEM_WILDCARDS = ['通用']
/** 区服侧通配值:「互通」= QQ/微信都能收;「通用/全区/不限」是订单文案里的旧写法 */
const ZONE_WILDCARDS = ['通用', '互通']
function compareSpec(
orderValue: string,
inventoryValue: string,
wildcards: string[] = SYSTEM_WILDCARDS,
): SpecVerdict {
const order = String(orderValue || '').trim()
const inventory = String(inventoryValue || '').trim()
if (!inventory) return 'unknown'
if (inventory === '通用') return 'match'
if (wildcards.includes(inventory)) return 'match'
if (!order) return 'unknown'
if (wildcards.includes(order)) return 'match'
return order === inventory ? 'match' : 'mismatch'
}
@@ -160,7 +173,7 @@ export function matchPeacekeeperOrderWithInventory(
const verdictSystem = compareSpec(order.system, invSystem)
if (verdictSystem === 'mismatch') return null
const verdictZone = compareSpec(order.serverZone, invZone)
const verdictZone = compareSpec(order.serverZone, invZone, ZONE_WILDCARDS)
if (verdictZone === 'mismatch') return null
const verdictSeries = compareSpec(order.series, invSeries)
if (verdictSeries === 'mismatch') return null
@@ -268,15 +281,27 @@ export function describeConsignmentSpecDifferences(
inventory: ConsignmentInventoryRow,
): string[] {
const diffs: string[] = []
const compare = (label: string, orderValue: string, inventoryValue: string) => {
const compare = (
label: string,
orderValue: string,
inventoryValue: string,
wildcards: string[] = SYSTEM_WILDCARDS,
) => {
const left = String(orderValue || '').trim() || '未指定'
const right = String(inventoryValue || '').trim() || '未填写'
// 通配值(互通/通用)表示"不限",不算差异,别在冲突清单里吓唬客服
if (left === '未指定' || left === right) return
if (wildcards.includes(left) || wildcards.includes(right)) return
diffs.push(`${label}:订单「${left}」/ 现货「${right}」`)
}
compare('系统', order.system, normalizePeacekeeperSystem(inventory.system))
compare('区服', order.serverZone, normalizePeacekeeperServerZone(inventory.server_zone))
compare(
'区服',
order.serverZone,
normalizePeacekeeperServerZone(inventory.server_zone),
ZONE_WILDCARDS,
)
compare('系列', order.series, normalizePeacekeeperSeries(inventory.series))
const orderDiamond = Number(order.diamondLevel || 0)
const invDiamond = Number(inventory.diamond_level || 0)
@@ -55,3 +55,19 @@ test('没有提报单的兜底行不给卖家操作能力,也不暴露内部
assert.ok(serviceSource.includes("consignmentNo: '客服手工入库'"))
assert.ok(serviceSource.includes('canConfirmFriend: false, canSubmitEvidence: false'))
})
test('苏尔南必须区分苹果/安卓(不收「通用」),区服固定 微信区/QQ区/互通 三选', async () => {
const serviceSource = await readServiceSource()
assert.ok(
serviceSource.includes("errorCode: 'consignment_system_invalid'"),
'系统取值不合法要有显式拒绝,防止旧页面缓存绕过前端选项',
)
assert.ok(serviceSource.includes("errorCode: 'consignment_server_zone_invalid'"))
// 校验必须是白名单式(只认 苹果/安卓 与 微信区/QQ区/互通),而不是只挡「通用」
assert.ok(serviceSource.includes("system !== '苹果' && system !== '安卓'"))
assert.ok(
serviceSource.includes(
"serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通'",
),
)
})
@@ -41,6 +41,20 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
errorCode: 'specs_required',
})
}
// 苏尔南寄卖必须区分苹果/安卓(不收「通用」);区服支持 微信区/QQ区/互通 三选
if (system !== '苹果' && system !== '安卓') {
throw createHttpError('苏尔南寄卖的操作系统只能选 苹果 或 安卓', {
statusCode: 400,
errorCode: 'consignment_system_invalid',
})
}
// 互通 = QQ/微信都能收(苏尔南发布端默认且唯一展示项)
if (serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通') {
throw createHttpError('游戏区服只能选 微信区 / QQ区 / 互通', {
statusCode: 400,
errorCode: 'consignment_server_zone_invalid',
})
}
if (
payload.diamondLevel === undefined ||
@@ -107,7 +121,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const depositAmountFen = getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
const defaultItemName = `${system}${serverZone}${series}${diamondLevel}钻${briStr} 苏尔南`
// 区服是「互通」(通配)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
const zoneLabel = serverZone === '互通' ? '' : serverZone
const defaultItemName = `${system}${zoneLabel}${series}${diamondLevel}钻${briStr} 苏尔南`
const itemName = String(payload.itemName || defaultItemName).trim()
const submissionNo = generateConsignmentSubmissionNo()
const now = nowIso()
@@ -41,6 +41,7 @@ import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/con
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
@@ -56,6 +57,7 @@ const ZONE_OPTIONS = [
{ label: '全部区服', value: '' },
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
{ label: '互通', value: '互通' },
]
const SERIES_OPTIONS = [
@@ -842,12 +844,13 @@ export default function ConsignmentInventoryPanel() {
<Row gutter={16}>
<Col span={5}>
<Form.Item name="system" label="系统">
<Input />
{/* 后台全量选项:系统 苹果/安卓/通用,区服 微信/QQ/互通(互通=QQ/微信都能收) */}
<Select options={[...CONSIGNMENT_SYSTEM_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item name="serverZone" label="区服">
<Input />
<Select options={[...CONSIGNMENT_ZONE_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
@@ -40,6 +40,7 @@ import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/consignment'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
@@ -512,12 +513,13 @@ export default function ConsignmentSubmissionsPanel() {
<Row gutter={16}>
<Col span={5}>
<Form.Item name="system" label="系统">
<Input />
{/* 审核可修正规格:与打手端发布同一套选项(苹果/安卓;微信/QQ/互通) */}
<Select options={[...CONSIGNMENT_SYSTEM_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item name="serverZone" label="区服">
<Input />
<Select options={[...CONSIGNMENT_ZONE_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
@@ -42,6 +42,11 @@ import {
} from '@/services/worker'
import type { WorkerConsignmentRecord } from '@/services/worker'
import type { UploadedFile } from '@/types/worker-platform'
import {
SURNAN_DEFAULT_ZONE,
SURNAN_PUBLISH_SYSTEM_OPTIONS,
SURNAN_PUBLISH_ZONE_OPTIONS,
} from '@/utils/consignment-spec'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { formatDateTime } from '@/utils/date-time'
import { useIsMobile } from '@/utils/use-is-mobile'
@@ -187,8 +192,10 @@ export default function WorkerConsignmentPage() {
if (sys && zone && ser && dia != null && bri != null) {
const briNum = Number(bri)
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
// 区服是通配值(互通/通用)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
const zoneLabel = zone === '互通' || zone === '通用' ? '' : zone
form.setFieldsValue({
itemName: `${sys}${zone}${ser}${dia}钻${briStr} 苏尔南`,
itemName: `${sys}${zoneLabel}${ser}${dia}钻${briStr} 苏尔南`,
})
}
}
@@ -499,27 +506,22 @@ export default function WorkerConsignmentPage() {
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[
{ label: '苹果', value: '苹果' },
{ label: '安卓', value: '安卓' },
{ label: '通用', value: '通用' },
]}
options={[...SURNAN_PUBLISH_SYSTEM_OPTIONS]}
/>
</Form.Item>
<Form.Item
name="serverZone"
label="游戏区服"
rules={[{ required: true, message: '请选择区服' }]}
// 苏尔南区服固定「通用」(QQ/微信互通),唯一选项默认选中
initialValue={SURNAN_DEFAULT_ZONE}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
]}
options={[...SURNAN_PUBLISH_ZONE_OPTIONS]}
/>
</Form.Item>
<Form.Item
@@ -0,0 +1,36 @@
/**
* 寄卖规格选项(单一来源)。
*
* 两套口径:
* 1. 后台全量(CONSIGNMENT_SYSTEM_OPTIONS / CONSIGNMENT_ZONE_OPTIONS):给客服端
* 提报审核、库存编辑用 —— 系统 苹果/安卓/通用,区服 微信区/QQ区/互通。
* 2. 发布端按物品类型展示(SURNAN_*):打手端「我要寄卖」按物品类型给选项 ——
* 苏尔南必须区分 苹果/安卓(不给通用),区服固定「互通」(QQ/微信互通,默认选中)。
*
* 「互通」在匹配引擎里是区服通配值:互通现货 = 微信/QQ 订单都匹配,反之亦然;
* 订单文案里的「通用/全区/不限」会归一化成通配(见后端 ZONE_WILDCARDS)。
* 系统侧的「通用」同理是系统通配(订单文案里的「双端/全端」也归一化过来)。
* 后续其他品类规则不同(有的不分端),在这里按品类加条目即可。
*/
export const CONSIGNMENT_SYSTEM_OPTIONS = [
{ label: '苹果', value: '苹果' },
{ label: '安卓', value: '安卓' },
{ label: '通用', value: '通用' },
] as const
export const CONSIGNMENT_ZONE_OPTIONS = [
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
{ label: '互通(QQ/微信)', value: '互通' },
] as const
/** 打手端发布 · 苏尔南:系统必须二选一,区服固定「互通」 */
export const SURNAN_PUBLISH_SYSTEM_OPTIONS = [
{ label: '苹果', value: '苹果' },
{ label: '安卓', value: '安卓' },
] as const
export const SURNAN_PUBLISH_ZONE_OPTIONS = [{ label: '互通(QQ/微信)', value: '互通' }] as const
/** 苏尔南发布时区服的默认值(唯一选项,自动选中) */
export const SURNAN_DEFAULT_ZONE = '互通'
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
/**
* 寄卖规格口径(2026-09 定案):
* - 操作后台全量:系统 苹果/安卓/通用,区服 微信区/QQ区/互通;
* - 打手端发布按物品类型展示:苏尔南 = 系统二选一(苹果/安卓,不给通用),
* 区服固定「互通」且默认选中(互通 = QQ/微信都能收);
* - 「互通」是区服通配值(互通现货匹配微信/QQ 订单,反之亦然),
* 订单文案里的「通用/全区/不限」由后端归一化成通配别名。
*/
test('后台全量选项:系统含通用,区服含互通', () => {
const source = readFileSync(
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
'utf8',
)
assert.ok(source.includes("{ label: '通用', value: '通用' }"), '系统全量选项必须含「通用」')
assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」')
})
test('苏尔南发布选项:系统只有苹果/安卓,区服唯一「互通」且为默认值', () => {
const source = readFileSync(
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
'utf8',
)
const surnanSystem = source.slice(
source.indexOf('SURNAN_PUBLISH_SYSTEM_OPTIONS'),
source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS'),
)
assert.ok(!surnanSystem.includes("value: '通用'"), '苏尔南发布不允许再选系统「通用」')
assert.ok(source.includes("export const SURNAN_DEFAULT_ZONE = '互通'"), '苏尔南区服默认「互通」')
const surnanZone = source.slice(source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS'))
assert.ok(
!surnanZone.slice(0, surnanZone.indexOf('\n\n')).includes("value: '微信区'"),
'苏尔南发布端区服只有互通一个选项',
)
})
test('打手端发布表单:用苏尔南选项集 + 区服默认值,不引后台全量', () => {
const source = readFileSync(
new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url).pathname,
'utf8',
)
assert.ok(source.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'))
assert.ok(source.includes('SURNAN_PUBLISH_ZONE_OPTIONS'))
assert.ok(source.includes('initialValue={SURNAN_DEFAULT_ZONE}'), '区服默认值要落到表单项上')
assert.ok(!source.includes('[...CONSIGNMENT_'), '发布端不直接引用后台全量选项')
})
test('客服端两个表单(提报审核 / 库存编辑)用后台全量选项,不再自由填写', () => {
for (const file of ['ConsignmentSubmissionsPanel.tsx', 'ConsignmentInventoryPanel.tsx']) {
const source = readFileSync(
new URL(`../src/pages/admin/panels/${file}`, import.meta.url).pathname,
'utf8',
)
assert.ok(source.includes('CONSIGNMENT_SYSTEM_OPTIONS'), `${file} 应引用全量系统选项`)
assert.ok(source.includes('CONSIGNMENT_ZONE_OPTIONS'), `${file} 应引用全量区服选项`)
}
})
+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` |
| 本批提交(批次 W,同一提交内含本文档,故不写自身 hash) | 批次 W:规格口径定案——后台全量(系统 苹果/安卓/通用,区服 微信/QQ/互通),打手端按物品展示(苏尔南:系统二选一、区服唯一「互通」默认选中);「互通」为区服通配值,匹配引擎通配符对称化 | `utils/consignment-spec.ts`、`WorkerConsignmentPage.tsx`、两个面板、`consignment-matching-service.ts`、`consignment-worker-service.ts` |
| 本批提交(批次 V,同一提交内含本文档,故不写自身 hash) | 批次 V:复核遗留——拼单份额验收收官补寄卖钩子(押金释放+供货货款)、手动匹配弹窗 409 刷新/关键字透传/无打手禁指派、核销反馈抽共享出口 | `work-order-settlement-repo.ts`、`ConsignmentManualMatchModal.tsx`、`utils/voucher-consume.ts` |
| 本批提交(批次 U,同一提交内含本文档,故不写自身 hash) | 批次 U:复核修复——占库 allocation_status 撞 080 CHECK(真实库必 500)、指派非供货卖家时货款双付、手动匹配入口按 fulfill 权限门控 | `consignment-repo.ts`、`consignment-fulfillment-service.ts`、两个面板、三个测试文件 |
| 本批提交(批次 S,同一提交内含本文档,故不写自身 hash) | 批次 S:手动指定现货匹配(判定/差异/强制留痕 + 双向入口 + 共享弹窗) | `consignment-matching-service.ts`、`consignment-fulfillment-service.ts`、`ConsignmentManualMatchModal.tsx` |
@@ -290,6 +291,28 @@
规格冲突 → 409 `consignment_spec_mismatch`;非 exact 且未显式传 `allowPartial` → 409 `consignment_spec_partial_requires_confirm`。
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
### 批次 W · 规格口径:互通通配 + 后台全量/发布端分物品展示(本轮)
**业务定案**:
1. **操作后台全量选项**:系统 苹果/安卓/通用;区服 微信区/QQ区/互通。提报审核与库存编辑
的系统/区服从自由文本 Input 改为受控下拉(单一来源 `utils/consignment-spec.ts`)。
2. **打手端发布按物品类型展示**:苏尔南 = 系统只有 苹果/安卓(必须区分端,不给「通用」),
区服**只展示「互通」一个选项且默认选中**(互通 = QQ/微信都能收)。后续其他品类规则不同
(有的不分端),在共享常量里按品类加条目即可。
3. **匹配引擎通配符对称化**:`compareSpec` 支持按字段传通配值集合——区服通配 =「互通」
(订单文案里的「通用/全区/不限」归一化成通配别名),系统通配 =「通用」(「双端/全端」同理)。
以前只认**库存侧**是通用件;现在订单侧写通配值(买家不限)同样放行,只增不减匹配面。
`describeConsignmentSpecDifferences` 同口径:通配对不算差异,不再吓唬客服。
4. **提报白名单校验(API 层)**:系统只收 苹果/安卓(400 `consignment_system_invalid`),
区服只收 微信区/QQ区/互通(400 `consignment_server_zone_invalid`)——旧页面缓存/直调接口绕不过。
5. **物品名拼接**:区服为「互通」时不拼进默认物品名("安卓互通暗潮…"对买家无信息量),
前端自动拼名与后端默认拼名同口径。
**验证**:后端 481 测试(互通双向匹配/通配不算差异/真冲突仍排除/白名单校验均有用例)、
前端 54 测试(选项口径/表单引用/默认值静态钉住)全绿;两侧 `typecheck` / `lint:check` /
`format:check` / `check:sql` 通过。
### 批次 V · 复核遗留:份额验收钩子 + 手动匹配弹窗三处(本轮)
1. ✅ **拼单份额全部验收(母单转已验收)补寄卖钩子**:`acceptWorkOrderShareAndSettle` 里最后一个份额把母单转 `accepted` 的分支,此前既不释放发布押金也不结算供货货款(两个钩子只在统一验收里)。现在与 `acceptWorkOrderAndSettle` 同口径:有 `material.consignment.sourceSubmissionId` 就释放发布押金;供货货款固定传 `assignedWorkerId: 0`(拼单母单没有单一接单人,份额打手已各自按 share_reward 结算,不会重复)。