feat(consignment): 寄卖规格与价格动态配置重构
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
-- 130_consignment_dynamic_specs.sql
|
||||
-- 寄卖动态规格:提报/库存增加 spec_values_json、spec_key、spec_text 与改价上限。
|
||||
-- 全部语句幂等,可安全重跑;不改动历史迁移。
|
||||
|
||||
-- ===========================================================================
|
||||
-- 1. 提报:动态规格值、匹配 key、展示文本、改价上限
|
||||
-- ===========================================================================
|
||||
ALTER TABLE consignment_submissions
|
||||
ADD COLUMN IF NOT EXISTS spec_values_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS spec_key TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS spec_text TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS price_cap_fen BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- ===========================================================================
|
||||
-- 2. 库存:同提报;入库后规格值继续随库存走
|
||||
-- ===========================================================================
|
||||
ALTER TABLE consignment_inventories
|
||||
ADD COLUMN IF NOT EXISTS spec_values_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS spec_key TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS spec_text TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS price_cap_fen BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- ===========================================================================
|
||||
-- 3. 存量回填:内置 5 规格 -> JSON / key / 文本;改价上限取当前售价
|
||||
-- ===========================================================================
|
||||
UPDATE consignment_submissions
|
||||
SET
|
||||
spec_values_json = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'system', NULLIF(BTRIM(system), ''),
|
||||
'serverZone', NULLIF(BTRIM(server_zone), ''),
|
||||
'series', NULLIF(BTRIM(series), ''),
|
||||
'diamondLevel', diamond_level,
|
||||
'brillianceCount', brilliance_count
|
||||
)
|
||||
),
|
||||
spec_key = NULLIF(
|
||||
concat_ws(
|
||||
'|',
|
||||
CASE WHEN COALESCE(NULLIF(BTRIM(system), ''), '') <> '' THEN 'system=' || BTRIM(system) END,
|
||||
CASE WHEN COALESCE(NULLIF(BTRIM(server_zone), ''), '') <> '' THEN 'serverZone=' || BTRIM(server_zone) END,
|
||||
CASE WHEN COALESCE(NULLIF(BTRIM(series), ''), '') <> '' THEN 'series=' || BTRIM(series) END,
|
||||
CASE WHEN diamond_level IS NOT NULL THEN 'diamondLevel=' || diamond_level::text END,
|
||||
CASE WHEN brilliance_count IS NOT NULL THEN 'brillianceCount=' || brilliance_count::text END
|
||||
),
|
||||
''
|
||||
),
|
||||
spec_text = COALESCE(NULLIF(BTRIM(item_name), ''), ''),
|
||||
price_cap_fen = CASE
|
||||
WHEN COALESCE(requested_sale_price_fen, 0) > 0 THEN requested_sale_price_fen
|
||||
WHEN COALESCE(asking_price_fen, 0) > 0 THEN asking_price_fen
|
||||
ELSE 0
|
||||
END
|
||||
WHERE spec_key = '';
|
||||
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
spec_values_json = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'system', NULLIF(BTRIM(system), ''),
|
||||
'serverZone', NULLIF(BTRIM(server_zone), ''),
|
||||
'series', NULLIF(BTRIM(series), ''),
|
||||
'diamondLevel', diamond_level,
|
||||
'brillianceCount', brilliance_count
|
||||
)
|
||||
),
|
||||
spec_key = NULLIF(
|
||||
concat_ws(
|
||||
'|',
|
||||
CASE WHEN COALESCE(NULLIF(BTRIM(system), ''), '') <> '' THEN 'system=' || BTRIM(system) END,
|
||||
CASE WHEN COALESCE(NULLIF(BTRIM(server_zone), ''), '') <> '' THEN 'serverZone=' || BTRIM(server_zone) END,
|
||||
CASE WHEN COALESCE(NULLIF(BTRIM(series), ''), '') <> '' THEN 'series=' || BTRIM(series) END,
|
||||
CASE WHEN diamond_level IS NOT NULL THEN 'diamondLevel=' || diamond_level::text END,
|
||||
CASE WHEN brilliance_count IS NOT NULL THEN 'brillianceCount=' || brilliance_count::text END
|
||||
),
|
||||
''
|
||||
),
|
||||
spec_text = COALESCE(NULLIF(BTRIM(item_name), ''), ''),
|
||||
price_cap_fen = COALESCE(sale_price_fen, 0)
|
||||
WHERE spec_key = '';
|
||||
|
||||
-- ===========================================================================
|
||||
-- 4. 索引:按配置 + 规格 key 快速筛选在库可匹配库存
|
||||
-- ===========================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_spec_key
|
||||
ON consignment_inventories (profile_id, spec_key)
|
||||
WHERE status = 'in_stock' AND allow_matching;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_submissions_spec_key
|
||||
ON consignment_submissions (profile_id, spec_key)
|
||||
WHERE status = 'pending_review';
|
||||
|
||||
-- ===========================================================================
|
||||
-- 5. 注释
|
||||
-- ===========================================================================
|
||||
COMMENT ON COLUMN consignment_submissions.spec_values_json IS
|
||||
'动态规格字段值(key -> 选项值/数字/文本),随配置版本快照固化';
|
||||
COMMENT ON COLUMN consignment_submissions.spec_key IS
|
||||
'参与匹配的规格字段稳定 key(按字段 key 排序的 key=value 串),用于候选筛选';
|
||||
COMMENT ON COLUMN consignment_submissions.spec_text IS
|
||||
'规格展示/搜索文本(字段名称 + 选项名称拼接)';
|
||||
COMMENT ON COLUMN consignment_submissions.price_cap_fen IS
|
||||
'打手改价上限(分):默认取接单模板价/系统建议价;0 = 不限制';
|
||||
COMMENT ON COLUMN consignment_inventories.spec_values_json IS
|
||||
'动态规格字段值(key -> 选项值/数字/文本),随配置版本快照固化';
|
||||
COMMENT ON COLUMN consignment_inventories.spec_key IS
|
||||
'参与匹配的规格字段稳定 key(按字段 key 排序的 key=value 串),用于候选筛选';
|
||||
COMMENT ON COLUMN consignment_inventories.spec_text IS
|
||||
'规格展示/搜索文本(字段名称 + 选项名称拼接)';
|
||||
COMMENT ON COLUMN consignment_inventories.price_cap_fen IS
|
||||
'打手改价上限(分):默认取接单模板价/系统建议价;0 = 不限制';
|
||||
@@ -0,0 +1,203 @@
|
||||
-- 131_consignment_surnan_default_profile.sql
|
||||
-- 苏尔南手枪默认寄卖配置:五个规格字段(与旧发布页一致),直接发布 v1。
|
||||
-- 幂等:该分类已有寄卖配置时不做任何事,避免覆盖运营已保存/已发布的配置。
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
v_category_id BIGINT;
|
||||
v_profile_id BIGINT;
|
||||
v_version_id BIGINT;
|
||||
v_config JSONB;
|
||||
BEGIN
|
||||
SELECT id INTO v_category_id
|
||||
FROM work_categories
|
||||
WHERE category_key = 'shouqiang'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_category_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM consignment_profiles WHERE category_id = v_category_id) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
v_config := $config${
|
||||
"version": 1,
|
||||
"tabTitle": "苏尔南寄卖",
|
||||
"tabDescription": "苏尔南手枪寄卖:选择规格后按接单模板价带出建议价",
|
||||
"specs": [
|
||||
{
|
||||
"key": "system",
|
||||
"label": "操作系统",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
{ "label": "苹果", "value": "苹果", "enabled": true },
|
||||
{ "label": "安卓", "value": "安卓", "enabled": true }
|
||||
],
|
||||
"placeholder": "",
|
||||
"helpText": "",
|
||||
"matchMode": "exact",
|
||||
"wildcardValues": [],
|
||||
"showInItemName": true,
|
||||
"sortOrder": 10
|
||||
},
|
||||
{
|
||||
"key": "serverZone",
|
||||
"label": "游戏区服",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
{ "label": "互通(QQ/微信)", "value": "互通", "enabled": true }
|
||||
],
|
||||
"placeholder": "",
|
||||
"helpText": "苏尔南手枪当前统一为互通区服,旧库存的微信区/QQ区仍可参与匹配",
|
||||
"matchMode": "wildcard",
|
||||
"wildcardValues": ["互通", "通用", "全区", "不限"],
|
||||
"showInItemName": false,
|
||||
"sortOrder": 20
|
||||
},
|
||||
{
|
||||
"key": "series",
|
||||
"label": "系列 / 枪系",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
{ "label": "暗潮", "value": "暗潮", "enabled": true },
|
||||
{ "label": "怒嚎", "value": "怒嚎", "enabled": true },
|
||||
{ "label": "血宴", "value": "血宴", "enabled": true }
|
||||
],
|
||||
"placeholder": "",
|
||||
"helpText": "",
|
||||
"matchMode": "exact",
|
||||
"wildcardValues": [],
|
||||
"showInItemName": true,
|
||||
"sortOrder": 30
|
||||
},
|
||||
{
|
||||
"key": "diamondLevel",
|
||||
"label": "钻石等级",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
{ "label": "1钻", "value": "1", "enabled": true },
|
||||
{ "label": "2钻", "value": "2", "enabled": true },
|
||||
{ "label": "3钻", "value": "3", "enabled": true },
|
||||
{ "label": "4钻", "value": "4", "enabled": true },
|
||||
{ "label": "5钻", "value": "5", "enabled": true },
|
||||
{ "label": "6钻", "value": "6", "enabled": true },
|
||||
{ "label": "7钻", "value": "7", "enabled": true }
|
||||
],
|
||||
"placeholder": "",
|
||||
"helpText": "",
|
||||
"matchMode": "minimum",
|
||||
"wildcardValues": [],
|
||||
"showInItemName": true,
|
||||
"sortOrder": 40
|
||||
},
|
||||
{
|
||||
"key": "brillianceCount",
|
||||
"label": "璀璨数",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
{ "label": "无", "value": "0", "enabled": true },
|
||||
{ "label": "1璀", "value": "1", "enabled": true },
|
||||
{ "label": "2璀", "value": "2", "enabled": true },
|
||||
{ "label": "3璀", "value": "3", "enabled": true },
|
||||
{ "label": "4璀", "value": "4", "enabled": true },
|
||||
{ "label": "5璀", "value": "5", "enabled": true },
|
||||
{ "label": "6璀", "value": "6", "enabled": true },
|
||||
{ "label": "7璀", "value": "7", "enabled": true }
|
||||
],
|
||||
"placeholder": "",
|
||||
"helpText": "",
|
||||
"matchMode": "minimum",
|
||||
"wildcardValues": [],
|
||||
"showInItemName": true,
|
||||
"sortOrder": 50
|
||||
}
|
||||
],
|
||||
"nameTemplate": "{system}{series}{diamondLevel}钻{brillianceCount}璀璨 苏尔南",
|
||||
"pricing": {
|
||||
"priceType": "template_discount",
|
||||
"discount": 10,
|
||||
"fixedPriceFen": 0,
|
||||
"allowWorkerPrice": true,
|
||||
"minPriceFen": 0,
|
||||
"missingPricePolicy": "manual"
|
||||
},
|
||||
"fields": {
|
||||
"system": true,
|
||||
"serverZone": true,
|
||||
"series": true,
|
||||
"diamondLevel": true,
|
||||
"brillianceCount": true,
|
||||
"quantity": true
|
||||
},
|
||||
"deposit": {
|
||||
"enabled": false,
|
||||
"mode": "fixed",
|
||||
"fixedAmountFen": 0,
|
||||
"unitAmountFen": 0
|
||||
},
|
||||
"serviceFee": {
|
||||
"enabled": false,
|
||||
"mode": "percent",
|
||||
"percent": 0,
|
||||
"amountFen": 0
|
||||
},
|
||||
"inventoryLimit": {
|
||||
"enabled": false,
|
||||
"maxActiveQuantity": 0
|
||||
},
|
||||
"matching": {
|
||||
"mode": "manual",
|
||||
"autoMatchEnabled": false,
|
||||
"exactOnly": true,
|
||||
"preferLowReward": true
|
||||
},
|
||||
"internalMatch": {
|
||||
"delayMinutes": 30,
|
||||
"fallbackToHall": true
|
||||
},
|
||||
"timeout": {
|
||||
"minutes": 30,
|
||||
"policy": "reopen"
|
||||
},
|
||||
"priceDrop": {
|
||||
"enabled": false,
|
||||
"hours": 72,
|
||||
"mode": "percent",
|
||||
"value": 10
|
||||
}
|
||||
}$config$::jsonb;
|
||||
|
||||
INSERT INTO consignment_profiles (
|
||||
category_id, name, title, description, tab_title, config_json, status, created_at, updated_at
|
||||
) VALUES (
|
||||
v_category_id,
|
||||
'苏尔南手枪寄卖',
|
||||
'苏尔南手枪寄卖',
|
||||
'苏尔南手枪默认配置:选择规格后按接单模板价带出建议价',
|
||||
'苏尔南寄卖',
|
||||
v_config,
|
||||
'published',
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING id INTO v_profile_id;
|
||||
|
||||
INSERT INTO consignment_profile_versions (
|
||||
profile_id, version_no, config_json, published_at, created_at
|
||||
) VALUES (
|
||||
v_profile_id, 1, v_config, now(), now()
|
||||
)
|
||||
RETURNING id INTO v_version_id;
|
||||
|
||||
UPDATE consignment_profiles
|
||||
SET current_version_id = v_version_id, updated_at = now()
|
||||
WHERE id = v_profile_id;
|
||||
END
|
||||
$migration$;
|
||||
@@ -54,6 +54,12 @@ export type ConsignmentInventoryRow = {
|
||||
profile_id?: string | number | null
|
||||
profile_version_id?: string | number | null
|
||||
profile_snapshot_json?: string | Record<string, unknown> | null
|
||||
/** 动态规格值 / 匹配 key / 展示文本 */
|
||||
spec_values_json?: string | Record<string, unknown> | null
|
||||
spec_key?: string
|
||||
spec_text?: string
|
||||
/** 打手改价上限(分);0 = 不限制 */
|
||||
price_cap_fen?: number
|
||||
/** 该批次总数量 / 剩余可匹配数量 */
|
||||
quantity?: number
|
||||
remaining_quantity?: number
|
||||
@@ -97,6 +103,12 @@ export type ConsignmentSubmissionRow = {
|
||||
profile_id?: string | number | null
|
||||
profile_version_id?: string | number | null
|
||||
profile_snapshot_json?: string | Record<string, unknown> | null
|
||||
/** 动态规格值 / 匹配 key / 展示文本 */
|
||||
spec_values_json?: string | Record<string, unknown> | null
|
||||
spec_key?: string
|
||||
spec_text?: string
|
||||
/** 打手改价上限(分);0 = 不限制 */
|
||||
price_cap_fen?: number
|
||||
/** 本次提报数量与期望售价 */
|
||||
quantity?: number
|
||||
requested_sale_price_fen?: number
|
||||
@@ -413,6 +425,10 @@ export async function updateConsignmentInventory(
|
||||
brillianceCount?: number | undefined
|
||||
costPriceFen?: number | undefined
|
||||
salePriceFen?: number | undefined
|
||||
specValuesJson?: Record<string, unknown> | undefined
|
||||
specKey?: string | undefined
|
||||
specText?: string | undefined
|
||||
priceCapFen?: number | undefined
|
||||
buyerName?: string | undefined
|
||||
inboundBatch?: string | undefined
|
||||
sellerContact?: string | undefined
|
||||
@@ -467,6 +483,22 @@ export async function updateConsignmentInventory(
|
||||
values.push(input.salePriceFen)
|
||||
updates.push(`sale_price_fen = $${values.length}`)
|
||||
}
|
||||
if (input.specValuesJson !== undefined) {
|
||||
values.push(toJsonString(input.specValuesJson))
|
||||
updates.push(`spec_values_json = $${values.length}::jsonb`)
|
||||
}
|
||||
if (input.specKey !== undefined) {
|
||||
values.push(input.specKey)
|
||||
updates.push(`spec_key = $${values.length}`)
|
||||
}
|
||||
if (input.specText !== undefined) {
|
||||
values.push(input.specText)
|
||||
updates.push(`spec_text = $${values.length}`)
|
||||
}
|
||||
if (input.priceCapFen !== undefined) {
|
||||
values.push(Math.max(0, Math.round(input.priceCapFen)))
|
||||
updates.push(`price_cap_fen = $${values.length}`)
|
||||
}
|
||||
if (input.buyerName !== undefined) {
|
||||
values.push(input.buyerName)
|
||||
updates.push(`buyer_name = $${values.length}`)
|
||||
@@ -1060,6 +1092,7 @@ export async function listWorkerConsignmentPage(input: {
|
||||
AND (
|
||||
cs.submission_no ILIKE ${p}
|
||||
OR cs.item_name ILIKE ${p}
|
||||
OR cs.spec_text ILIKE ${p}
|
||||
OR cs.system ILIKE ${p}
|
||||
OR cs.server_zone ILIKE ${p}
|
||||
OR cs.series ILIKE ${p}
|
||||
@@ -1069,6 +1102,7 @@ export async function listWorkerConsignmentPage(input: {
|
||||
OR cs.seller_phone ILIKE ${p}
|
||||
OR ci.inventory_no ILIKE ${p}
|
||||
OR ci.item_name ILIKE ${p}
|
||||
OR ci.spec_text ILIKE ${p}
|
||||
OR ci.seller_game_id ILIKE ${p}
|
||||
OR ci.seller_contact ILIKE ${p}
|
||||
OR ci.seller_phone ILIKE ${p}
|
||||
@@ -1338,6 +1372,10 @@ export async function createConsignmentSubmissionWithClient(
|
||||
profileId?: number | null
|
||||
profileVersionId?: number | null
|
||||
profileSnapshotJson?: Record<string, unknown> | null
|
||||
specValuesJson?: Record<string, unknown> | null
|
||||
specKey?: string
|
||||
specText?: string
|
||||
priceCapFen?: number
|
||||
quantity?: number
|
||||
requestedSalePriceFen?: number
|
||||
now: string
|
||||
@@ -1352,6 +1390,7 @@ export async function createConsignmentSubmissionWithClient(
|
||||
worker_id, worker_name, worker_phone, deposit_amount_fen,
|
||||
contact_supporter_id,
|
||||
profile_id, profile_version_id, profile_snapshot_json, quantity, requested_sale_price_fen,
|
||||
spec_values_json, spec_key, spec_text, price_cap_fen,
|
||||
status, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
@@ -1361,7 +1400,8 @@ export async function createConsignmentSubmissionWithClient(
|
||||
$17, $18, $19, $20,
|
||||
$21,
|
||||
$22, $23, $24::jsonb, $25, $26,
|
||||
'pending_review', $27, $27
|
||||
$27::jsonb, $28, $29, $30,
|
||||
'pending_review', $31, $31
|
||||
)
|
||||
RETURNING *
|
||||
`
|
||||
@@ -1392,6 +1432,10 @@ export async function createConsignmentSubmissionWithClient(
|
||||
input.profileSnapshotJson ? toJsonString(input.profileSnapshotJson) : null,
|
||||
Math.max(1, Math.round(Number(input.quantity || 1))),
|
||||
Math.max(0, Math.round(Number(input.requestedSalePriceFen || input.askingPriceFen || 0))),
|
||||
toJsonString(input.specValuesJson || {}),
|
||||
String(input.specKey || ''),
|
||||
String(input.specText || ''),
|
||||
Math.max(0, Math.round(Number(input.priceCapFen || 0))),
|
||||
input.now,
|
||||
])
|
||||
return res.rows[0]!
|
||||
@@ -1463,6 +1507,10 @@ export async function approveConsignmentSubmissionWithInbound(input: {
|
||||
profileId?: number | null | undefined
|
||||
profileVersionId?: number | null | undefined
|
||||
profileSnapshotJson?: string | Record<string, unknown> | null | undefined
|
||||
specValuesJson?: string | Record<string, unknown> | null | undefined
|
||||
specKey?: string | undefined
|
||||
specText?: string | undefined
|
||||
priceCapFen?: number | undefined
|
||||
quantity?: number | undefined
|
||||
salePriceFen?: number | undefined
|
||||
now: string
|
||||
@@ -1501,6 +1549,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
|
||||
contact_supporter_id,
|
||||
profile_id, profile_version_id, profile_snapshot_json, quantity, remaining_quantity,
|
||||
sale_price_fen, listed_at,
|
||||
spec_values_json, spec_key, spec_text, price_cap_fen,
|
||||
inbound_at, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
@@ -1512,6 +1561,7 @@ export async function approveConsignmentSubmissionWithInbound(input: {
|
||||
$25,
|
||||
$26, $27, $28::jsonb, $29, $29,
|
||||
$30, $31,
|
||||
$32::jsonb, $33, $34, $35,
|
||||
$31, $31, $31
|
||||
)
|
||||
RETURNING *
|
||||
@@ -1563,6 +1613,10 @@ export async function approveConsignmentSubmissionWithInbound(input: {
|
||||
),
|
||||
),
|
||||
input.now,
|
||||
toJsonString(input.specValuesJson ?? submission.spec_values_json ?? {}),
|
||||
input.specKey ?? submission.spec_key ?? '',
|
||||
input.specText ?? submission.spec_text ?? '',
|
||||
Math.max(0, Math.round(Number(input.priceCapFen ?? submission.price_cap_fen ?? 0))),
|
||||
]
|
||||
const invRes = await client.query<ConsignmentInventoryRow>(invSql, invValues)
|
||||
const inventory = invRes.rows[0]!
|
||||
|
||||
@@ -128,6 +128,8 @@ router.post(
|
||||
inventoryIds: Array.isArray(req.body?.inventoryIds)
|
||||
? req.body.inventoryIds.map((id: unknown) => Number(id))
|
||||
: [],
|
||||
allowNonExact: Boolean(req.body?.allowNonExact),
|
||||
reason: String(req.body?.reason || '').trim(),
|
||||
actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin',
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -80,6 +80,7 @@ router.post('/consignment/publish', requireActiveWorker, async (req, res) => {
|
||||
router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res) => {
|
||||
try {
|
||||
const result = await getWorkerConsignmentPriceSuggestion({
|
||||
profileId: req.query.profileId,
|
||||
system: req.query.system,
|
||||
series: req.query.series,
|
||||
diamondLevel: req.query.diamondLevel,
|
||||
@@ -91,6 +92,22 @@ router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/v1/worker/consignment/price-preview
|
||||
* 动态规格发布时按 profileId + specValues 算价(支持自定义字段与配置价)。
|
||||
*/
|
||||
router.post('/consignment/price-preview', requireActiveWorker, async (req, res) => {
|
||||
try {
|
||||
const result = await getWorkerConsignmentPriceSuggestion({
|
||||
profileId: req.body?.profileId,
|
||||
values: req.body?.specValues,
|
||||
})
|
||||
res.json({ ok: true, data: result })
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '获取价格预览失败', '[worker/consignment/price-preview]')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/v1/worker/consignment/my-items?group=&keyword=&page=&pageSize=
|
||||
* 打手查看自己在接单平台寄卖的手枪列表与状态(服务端搜索 + 分组 + 分页)
|
||||
|
||||
@@ -28,6 +28,14 @@ import {
|
||||
normalizeConsignmentProfileConfig,
|
||||
resolvePriceDropSuggestion,
|
||||
} from './consignment-profile-service.js'
|
||||
import {
|
||||
buildConsignmentSpecKey,
|
||||
buildConsignmentSpecText,
|
||||
normalizeConsignmentSpecValues,
|
||||
parseConsignmentSpecValues,
|
||||
resolveLegacyConsignmentColumns,
|
||||
type ConsignmentSpecField,
|
||||
} from './consignment-spec-service.js'
|
||||
import {
|
||||
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||
getConsignmentDepositErrorCode,
|
||||
@@ -36,6 +44,55 @@ import {
|
||||
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { extractConsignmentBuyerInfo } from './work-order-material-mappers.js'
|
||||
|
||||
function resolveSpecsFromSnapshot(value: unknown): ConsignmentSpecField[] {
|
||||
const config = value ? normalizeConsignmentProfileConfig(value) : null
|
||||
return config?.specs ?? []
|
||||
}
|
||||
|
||||
function hasSpecValuesPayload(value: unknown): boolean {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value as Record<string, unknown>).length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
function mergeLegacySpecValues(
|
||||
specs: ConsignmentSpecField[],
|
||||
baseValues: Record<string, string | number>,
|
||||
payload: JsonObject,
|
||||
): { values: Record<string, string | number>; hasLegacyEdit: boolean } {
|
||||
const values = { ...baseValues }
|
||||
let hasLegacyEdit = false
|
||||
if (payload.system !== undefined) {
|
||||
values.system = String(payload.system).trim()
|
||||
hasLegacyEdit = true
|
||||
}
|
||||
if (payload.serverZone !== undefined) {
|
||||
values.serverZone = String(payload.serverZone).trim()
|
||||
hasLegacyEdit = true
|
||||
}
|
||||
if (payload.series !== undefined) {
|
||||
values.series = String(payload.series).trim()
|
||||
hasLegacyEdit = true
|
||||
}
|
||||
if (payload.diamondLevel !== undefined) {
|
||||
values.diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel)))
|
||||
hasLegacyEdit = true
|
||||
}
|
||||
if (payload.brillianceCount !== undefined) {
|
||||
values.brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount)))
|
||||
hasLegacyEdit = true
|
||||
}
|
||||
if (!hasLegacyEdit) return { values, hasLegacyEdit }
|
||||
// 只保留配置里存在的内置字段,避免把已移除的字段重新写回 specKey。
|
||||
for (const key of ['system', 'serverZone', 'series', 'diamondLevel', 'brillianceCount']) {
|
||||
if (!specs.some((spec) => spec.key === key)) delete values[key]
|
||||
}
|
||||
return { values, hasLegacyEdit }
|
||||
}
|
||||
|
||||
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
|
||||
const code = getConsignmentDepositErrorCode(error)
|
||||
if (code === 'consignment_deposit_insufficient') {
|
||||
@@ -191,12 +248,16 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||
listedAt: row.listed_at || row.inbound_at,
|
||||
priceUpdatedAt: row.price_updated_at || null,
|
||||
priceDropSuggestion,
|
||||
specs: profileConfig?.specs || [],
|
||||
itemName: row.item_name,
|
||||
system: row.system,
|
||||
serverZone: row.server_zone,
|
||||
series: row.series,
|
||||
diamondLevel: Number(row.diamond_level || 1),
|
||||
brillianceCount: Number(row.brilliance_count || 0),
|
||||
specValues: parseConsignmentSpecValues(row.spec_values_json),
|
||||
specText: row.spec_text || '',
|
||||
priceCapFen: Number(row.price_cap_fen || 0),
|
||||
costPriceFen: Number(row.cost_price_fen || 0),
|
||||
buyerName: row.buyer_name || '',
|
||||
shopName: showingMatchedBuyer ? row.shop_name || '' : '',
|
||||
@@ -261,12 +322,23 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||
}
|
||||
|
||||
export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
|
||||
const profileConfig = row.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(row.profile_snapshot_json)
|
||||
: null
|
||||
return {
|
||||
id: Number(row.id),
|
||||
submissionNo: row.submission_no,
|
||||
gameName: row.game_name,
|
||||
categoryId: row.category_id ? Number(row.category_id) : null,
|
||||
categoryName: row.category_name,
|
||||
profileId: row.profile_id ? Number(row.profile_id) : null,
|
||||
profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null,
|
||||
specs: profileConfig?.specs || [],
|
||||
specValues: parseConsignmentSpecValues(row.spec_values_json),
|
||||
specText: row.spec_text || '',
|
||||
priceCapFen: Number(row.price_cap_fen || 0),
|
||||
quantity: Math.max(1, Number(row.quantity || 1)),
|
||||
requestedSalePriceFen: Number(row.requested_sale_price_fen || 0),
|
||||
itemName: row.item_name,
|
||||
system: row.system,
|
||||
serverZone: row.server_zone,
|
||||
@@ -393,6 +465,69 @@ export async function updateAdminConsignmentInventory(
|
||||
errorCode: 'consignment_allocation_status_update_forbidden',
|
||||
})
|
||||
}
|
||||
let specPatch: {
|
||||
specValuesJson?: Record<string, unknown>
|
||||
specKey?: string
|
||||
specText?: string
|
||||
system?: string
|
||||
serverZone?: string
|
||||
series?: string
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
} = {}
|
||||
if (hasSpecValuesPayload(payload.specValues)) {
|
||||
const inventory = await getConsignmentInventoryById(id)
|
||||
if (!inventory) {
|
||||
throw createHttpError('寄卖库存不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'consignment_inventory_not_found',
|
||||
})
|
||||
}
|
||||
const config = inventory.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json)
|
||||
: null
|
||||
const specs = config?.specs ?? []
|
||||
const validation = normalizeConsignmentSpecValues(specs, { specValues: payload.specValues })
|
||||
if (validation.errors.length > 0) {
|
||||
throw createHttpError(validation.errors[0]!, {
|
||||
statusCode: 400,
|
||||
errorCode: 'consignment_specs_invalid',
|
||||
})
|
||||
}
|
||||
const legacy = resolveLegacyConsignmentColumns(validation.values)
|
||||
specPatch = {
|
||||
specValuesJson: validation.values,
|
||||
specKey: buildConsignmentSpecKey(specs, validation.values),
|
||||
specText: buildConsignmentSpecText(specs, validation.values),
|
||||
...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}),
|
||||
...(specs.some((spec) => spec.key === 'serverZone') ? { serverZone: legacy.serverZone } : {}),
|
||||
...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}),
|
||||
...(specs.some((spec) => spec.key === 'diamondLevel')
|
||||
? { diamondLevel: legacy.diamondLevel }
|
||||
: {}),
|
||||
...(specs.some((spec) => spec.key === 'brillianceCount')
|
||||
? { brillianceCount: legacy.brillianceCount }
|
||||
: {}),
|
||||
}
|
||||
} else {
|
||||
// 客服只改旧版内置字段时,也要同步 spec_values/spec_key,避免 JSON 与旧列不一致。
|
||||
const inventory = await getConsignmentInventoryById(id)
|
||||
if (inventory) {
|
||||
const specs = resolveSpecsFromSnapshot(inventory.profile_snapshot_json)
|
||||
const merged = mergeLegacySpecValues(
|
||||
specs,
|
||||
parseConsignmentSpecValues(inventory.spec_values_json),
|
||||
payload,
|
||||
)
|
||||
if (merged.hasLegacyEdit) {
|
||||
specPatch = {
|
||||
specValuesJson: merged.values,
|
||||
specKey: buildConsignmentSpecKey(specs, merged.values),
|
||||
specText: buildConsignmentSpecText(specs, merged.values),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const updated = await updateConsignmentInventory(id, {
|
||||
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
|
||||
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
|
||||
@@ -446,6 +581,7 @@ export async function updateAdminConsignmentInventory(
|
||||
? Number(payload.contactSupporterId)
|
||||
: null
|
||||
: undefined,
|
||||
...specPatch,
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -665,23 +801,90 @@ export async function approveAdminConsignmentSubmission(
|
||||
? Number(payload.depositAmountFen)
|
||||
: Number(submission.deposit_amount_fen || 0)
|
||||
|
||||
let approvalSpec: {
|
||||
specValuesJson?: Record<string, unknown>
|
||||
specKey?: string
|
||||
specText?: string
|
||||
system?: string
|
||||
serverZone?: string
|
||||
series?: string
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
} = {}
|
||||
if (hasSpecValuesPayload(payload.specValues)) {
|
||||
const config = submission.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(submission.profile_snapshot_json)
|
||||
: null
|
||||
const specs = config?.specs ?? []
|
||||
const validation = normalizeConsignmentSpecValues(specs, { specValues: payload.specValues })
|
||||
if (validation.errors.length > 0) {
|
||||
throw createHttpError(validation.errors[0]!, {
|
||||
statusCode: 400,
|
||||
errorCode: 'consignment_specs_invalid',
|
||||
})
|
||||
}
|
||||
const legacy = resolveLegacyConsignmentColumns(validation.values)
|
||||
approvalSpec = {
|
||||
specValuesJson: validation.values,
|
||||
specKey: buildConsignmentSpecKey(specs, validation.values),
|
||||
specText: buildConsignmentSpecText(specs, validation.values),
|
||||
...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}),
|
||||
...(specs.some((spec) => spec.key === 'serverZone') ? { serverZone: legacy.serverZone } : {}),
|
||||
...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}),
|
||||
...(specs.some((spec) => spec.key === 'diamondLevel')
|
||||
? { diamondLevel: legacy.diamondLevel }
|
||||
: {}),
|
||||
...(specs.some((spec) => spec.key === 'brillianceCount')
|
||||
? { brillianceCount: legacy.brillianceCount }
|
||||
: {}),
|
||||
}
|
||||
} else {
|
||||
const specs = resolveSpecsFromSnapshot(submission.profile_snapshot_json)
|
||||
const merged = mergeLegacySpecValues(
|
||||
specs,
|
||||
parseConsignmentSpecValues(submission.spec_values_json),
|
||||
payload,
|
||||
)
|
||||
if (merged.hasLegacyEdit) {
|
||||
const legacy = resolveLegacyConsignmentColumns(merged.values)
|
||||
approvalSpec = {
|
||||
specValuesJson: merged.values,
|
||||
specKey: buildConsignmentSpecKey(specs, merged.values),
|
||||
specText: buildConsignmentSpecText(specs, merged.values),
|
||||
...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}),
|
||||
...(specs.some((spec) => spec.key === 'serverZone')
|
||||
? { serverZone: legacy.serverZone }
|
||||
: {}),
|
||||
...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}),
|
||||
...(specs.some((spec) => spec.key === 'diamondLevel')
|
||||
? { diamondLevel: legacy.diamondLevel }
|
||||
: {}),
|
||||
...(specs.some((spec) => spec.key === 'brillianceCount')
|
||||
? { brillianceCount: legacy.brillianceCount }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await approveConsignmentSubmissionWithInbound({
|
||||
submissionId: id,
|
||||
inventoryNo,
|
||||
buyerName: String(payload.buyerName || actorName || '').trim(),
|
||||
inboundBatch: String(payload.inboundBatch || '').trim(),
|
||||
itemName: String(payload.itemName || submission.item_name).trim(),
|
||||
system: String(payload.system || submission.system).trim(),
|
||||
serverZone: String(payload.serverZone || submission.server_zone).trim(),
|
||||
series: String(payload.series || submission.series).trim(),
|
||||
diamondLevel: Math.max(
|
||||
1,
|
||||
Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)),
|
||||
),
|
||||
brillianceCount: Math.max(
|
||||
0,
|
||||
Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)),
|
||||
),
|
||||
system: approvalSpec.system ?? String(payload.system || submission.system).trim(),
|
||||
serverZone:
|
||||
approvalSpec.serverZone ?? String(payload.serverZone || submission.server_zone).trim(),
|
||||
series: approvalSpec.series ?? String(payload.series || submission.series).trim(),
|
||||
diamondLevel:
|
||||
approvalSpec.diamondLevel ??
|
||||
Math.max(1, Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1))),
|
||||
brillianceCount:
|
||||
approvalSpec.brillianceCount ??
|
||||
Math.max(0, Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0))),
|
||||
specValuesJson: approvalSpec.specValuesJson,
|
||||
specKey: approvalSpec.specKey,
|
||||
specText: approvalSpec.specText,
|
||||
costPriceFen,
|
||||
sellerContact: String(payload.sellerContact || submission.contact_info).trim(),
|
||||
sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(),
|
||||
|
||||
@@ -20,9 +20,17 @@ import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
buildServiceFeeFen,
|
||||
normalizeConsignmentProfileConfig,
|
||||
resolveConsignmentProfileForCategory,
|
||||
type ConsignmentProfileSnapshot,
|
||||
} from './consignment-profile-service.js'
|
||||
import {
|
||||
evaluateConsignmentSpecMatch,
|
||||
isBuiltInConsignmentSpecField,
|
||||
parseConsignmentSpecValues,
|
||||
type ConsignmentSpecField,
|
||||
type ConsignmentSpecValues,
|
||||
} from './consignment-spec-service.js'
|
||||
import {
|
||||
classifyConsignmentMatch,
|
||||
extractPeacekeeperSpecs,
|
||||
@@ -62,20 +70,114 @@ function inventoryMatchesProfile(
|
||||
return Number(inventory.category_id || 0) === profile.categoryId
|
||||
}
|
||||
|
||||
function inventoryMatchesWorkOrder(
|
||||
workOrder: WorkOrderRow,
|
||||
type InventoryMatchEvaluation = {
|
||||
specs: ConsignmentSpecField[]
|
||||
values: ConsignmentSpecValues
|
||||
orderValues: ConsignmentSpecValues
|
||||
exact: boolean
|
||||
partial: boolean
|
||||
unknown: boolean
|
||||
conflicts: string[]
|
||||
unknowns: string[]
|
||||
details: ReturnType<typeof evaluateConsignmentSpecMatch>['details']
|
||||
}
|
||||
|
||||
function resolveInventorySpecs(
|
||||
inventory: ConsignmentInventoryRow,
|
||||
): boolean {
|
||||
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
|
||||
if (!inventory.allow_matching) return false
|
||||
if (inventory.status !== 'in_stock') return false
|
||||
profile: ConsignmentProfileSnapshot | null,
|
||||
): ConsignmentSpecField[] {
|
||||
const config = inventory.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json)
|
||||
: profile?.config
|
||||
return config?.specs ?? []
|
||||
}
|
||||
|
||||
function resolveInventorySpecValues(inventory: ConsignmentInventoryRow): ConsignmentSpecValues {
|
||||
const parsed = parseConsignmentSpecValues(inventory.spec_values_json)
|
||||
if (Object.keys(parsed).length > 0) return parsed
|
||||
return {
|
||||
system: inventory.system,
|
||||
serverZone: inventory.server_zone,
|
||||
series: inventory.series,
|
||||
diamondLevel: Number(inventory.diamond_level || 1),
|
||||
brillianceCount: Number(inventory.brilliance_count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOrderSpecValues(workOrder: WorkOrderRow): ConsignmentSpecValues {
|
||||
const fields = resolveCollectSubmittedFieldValues(workOrder)
|
||||
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
|
||||
const specs = extractPeacekeeperSpecs(workOrder.product_name, {
|
||||
system: fields.system,
|
||||
serverZone: fields.serverZone,
|
||||
series: fields.series,
|
||||
})
|
||||
return classifyConsignmentMatch(orderSpecs, inventory).matchType === 'exact'
|
||||
const values: ConsignmentSpecValues = {}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (typeof value === 'string' || typeof value === 'number') values[key] = value
|
||||
}
|
||||
if (specs.system) values.system = specs.system
|
||||
if (specs.serverZone) values.serverZone = specs.serverZone
|
||||
if (specs.series) values.series = specs.series
|
||||
if (Number(specs.diamondLevel || 0) > 0) values.diamondLevel = Number(specs.diamondLevel)
|
||||
values.brillianceCount = Number(specs.brillianceCount || 0)
|
||||
return values
|
||||
}
|
||||
|
||||
function evaluateInventoryMatch(
|
||||
workOrder: WorkOrderRow,
|
||||
inventory: ConsignmentInventoryRow,
|
||||
profile: ConsignmentProfileSnapshot | null,
|
||||
): InventoryMatchEvaluation {
|
||||
const specs = resolveInventorySpecs(inventory, profile)
|
||||
const orderValues = resolveOrderSpecValues(workOrder)
|
||||
const values = resolveInventorySpecValues(inventory)
|
||||
const hasCustomSpecs = specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
|
||||
|
||||
if (!hasCustomSpecs) {
|
||||
// 内置 5 规格沿用原有苏尔南匹配器(通配、低配不能充高配、品类门禁);
|
||||
// 只有出现自定义规格时才走通用匹配,避免存量行为回归。
|
||||
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
|
||||
system: String(orderValues.system || ''),
|
||||
serverZone: String(orderValues.serverZone || ''),
|
||||
series: String(orderValues.series || ''),
|
||||
})
|
||||
const strict = classifyConsignmentMatch(orderSpecs, inventory)
|
||||
return {
|
||||
specs,
|
||||
values,
|
||||
orderValues,
|
||||
exact: strict.matchType === 'exact',
|
||||
partial: strict.matchType === 'partial',
|
||||
unknown: false,
|
||||
conflicts: strict.matchType === 'conflict' ? strict.conflicts : [],
|
||||
unknowns: [],
|
||||
details: [],
|
||||
}
|
||||
}
|
||||
|
||||
const evaluation = evaluateConsignmentSpecMatch({ specs, orderValues, inventoryValues: values })
|
||||
return {
|
||||
specs,
|
||||
values,
|
||||
orderValues,
|
||||
exact: evaluation.exact,
|
||||
partial: evaluation.partial,
|
||||
unknown: evaluation.unknown,
|
||||
conflicts: evaluation.conflicts,
|
||||
unknowns: evaluation.unknowns,
|
||||
details: evaluation.details,
|
||||
}
|
||||
}
|
||||
|
||||
function inventoryMatchesWorkOrder(
|
||||
workOrder: WorkOrderRow,
|
||||
inventory: ConsignmentInventoryRow,
|
||||
profile: ConsignmentProfileSnapshot | null = null,
|
||||
): boolean {
|
||||
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
|
||||
if (!inventory.allow_matching) return false
|
||||
if (inventory.status !== 'in_stock') return false
|
||||
return evaluateInventoryMatch(workOrder, inventory, profile).exact
|
||||
}
|
||||
|
||||
async function resolveProfileForWorkOrder(
|
||||
@@ -196,7 +298,7 @@ function pickCandidateInventories(
|
||||
.filter(
|
||||
(inventory) =>
|
||||
inventoryMatchesProfile(inventory, profile) &&
|
||||
inventoryMatchesWorkOrder(workOrder, inventory),
|
||||
inventoryMatchesWorkOrder(workOrder, inventory, profile),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const priceDiff = inventorySalePriceFen(a) - inventorySalePriceFen(b)
|
||||
@@ -364,7 +466,7 @@ async function applySharingMatch(
|
||||
if (remaining <= 0) break
|
||||
if (!inventory.worker_id) continue
|
||||
if (!inventoryMatchesProfile(inventory, profile)) continue
|
||||
if (!inventoryMatchesWorkOrder(current, inventory)) continue
|
||||
if (!inventoryMatchesWorkOrder(current, inventory, profile)) continue
|
||||
|
||||
const allocation = await reserveConsignmentInventoryWithClient(client, {
|
||||
inventoryId: Number(inventory.id),
|
||||
@@ -548,17 +650,36 @@ export async function expireConsignmentInternalMatching(options: { now?: string
|
||||
return { expired: expired.rows.length }
|
||||
}
|
||||
|
||||
/** 后台手动匹配弹窗:列出该工单可精确匹配的库存。 */
|
||||
/** 后台手动匹配弹窗:列出同配置在库库存,并标注精确/差异/冲突。 */
|
||||
export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: number) {
|
||||
const workOrder = await getWorkOrderById(workOrderId)
|
||||
if (!workOrder) {
|
||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||
}
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) =>
|
||||
inventoryMatchesProfile(inventory, profile),
|
||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
||||
(inventory) =>
|
||||
inventoryMatchesProfile(inventory, profile) &&
|
||||
Number(inventory.remaining_quantity ?? 1) > 0 &&
|
||||
inventory.status === 'in_stock' &&
|
||||
inventory.allow_matching,
|
||||
)
|
||||
const options = pickCandidateInventories(workOrder, profile, inventories).map((inventory) => ({
|
||||
const evaluated = inventories
|
||||
.map((inventory) => ({
|
||||
inventory,
|
||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.evaluation.exact !== b.evaluation.exact) return a.evaluation.exact ? -1 : 1
|
||||
const priceDiff = inventorySalePriceFen(a.inventory) - inventorySalePriceFen(b.inventory)
|
||||
if (priceDiff !== 0) return priceDiff
|
||||
const listedA = Date.parse(String(a.inventory.listed_at || a.inventory.inbound_at || '')) || 0
|
||||
const listedB = Date.parse(String(b.inventory.listed_at || b.inventory.inbound_at || '')) || 0
|
||||
if (listedA !== listedB) return listedA - listedB
|
||||
return Number(a.inventory.id) - Number(b.inventory.id)
|
||||
})
|
||||
|
||||
const options = evaluated.map(({ inventory, evaluation }) => ({
|
||||
inventoryId: Number(inventory.id),
|
||||
inventoryNo: inventory.inventory_no,
|
||||
itemName: inventory.item_name,
|
||||
@@ -567,6 +688,19 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
||||
series: inventory.series,
|
||||
diamondLevel: Number(inventory.diamond_level || 1),
|
||||
brillianceCount: Number(inventory.brilliance_count || 0),
|
||||
specValues: evaluation.values,
|
||||
specText: inventory.spec_text || '',
|
||||
matchType: evaluation.exact
|
||||
? 'exact'
|
||||
: evaluation.conflicts.length > 0
|
||||
? 'conflict'
|
||||
: evaluation.unknown
|
||||
? 'unknown'
|
||||
: evaluation.partial
|
||||
? 'partial'
|
||||
: 'unknown',
|
||||
matchReasons: [...evaluation.conflicts, ...evaluation.unknowns],
|
||||
matchDetails: evaluation.details,
|
||||
quantity: Number(inventory.quantity || 1),
|
||||
remainingQuantity: Number(inventory.remaining_quantity ?? 1),
|
||||
salePriceFen: inventorySalePriceFen(inventory),
|
||||
@@ -593,6 +727,9 @@ export async function matchConsignmentWorkOrderManually(input: {
|
||||
workOrderId: number
|
||||
inventoryIds: number[]
|
||||
actorName?: string
|
||||
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
|
||||
allowNonExact?: boolean
|
||||
reason?: string
|
||||
}): Promise<{ matched: boolean; matchedCount: number }> {
|
||||
const workOrder = await getWorkOrderById(input.workOrderId)
|
||||
if (!workOrder) {
|
||||
@@ -611,8 +748,9 @@ export async function matchConsignmentWorkOrderManually(input: {
|
||||
})
|
||||
}
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) =>
|
||||
inventoryMatchesProfile(inventory, profile),
|
||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
||||
(inventory) =>
|
||||
inventoryMatchesProfile(inventory, profile) && Number(inventory.remaining_quantity ?? 1) > 0,
|
||||
)
|
||||
const selected = input.inventoryIds
|
||||
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
|
||||
@@ -625,6 +763,33 @@ export async function matchConsignmentWorkOrderManually(input: {
|
||||
})
|
||||
}
|
||||
|
||||
const nonExact = selected
|
||||
.map((inventory) => ({
|
||||
inventory,
|
||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
|
||||
}))
|
||||
.filter((item) => !item.evaluation.exact)
|
||||
if (nonExact.length > 0) {
|
||||
if (!input.allowNonExact) {
|
||||
const details = nonExact
|
||||
.map((item) => {
|
||||
const reasons = [...item.evaluation.conflicts, ...item.evaluation.unknowns]
|
||||
return `${item.inventory.inventory_no}(${reasons.join('、') || '规格不一致'})`
|
||||
})
|
||||
.join(';')
|
||||
throw createHttpError(`所选库存与订单规格不一致:${details}。请确认后再匹配`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_spec_mismatch',
|
||||
})
|
||||
}
|
||||
if (!String(input.reason || '').trim()) {
|
||||
throw createHttpError('非精确匹配必须填写原因', {
|
||||
statusCode: 400,
|
||||
errorCode: 'consignment_match_reason_required',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING }
|
||||
if (!workOrder.sharing_enabled) {
|
||||
await query(
|
||||
|
||||
@@ -3,12 +3,16 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_PRICING,
|
||||
DEFAULT_CONSIGNMENT_SPECS,
|
||||
normalizeConsignmentPricing,
|
||||
normalizeConsignmentSpecs,
|
||||
type ConsignmentPricingConfig,
|
||||
type ConsignmentSpecField,
|
||||
} from './consignment-spec-service.js'
|
||||
|
||||
export type ConsignmentPricingConfig = {
|
||||
useTemplatePrice: boolean
|
||||
allowWorkerPrice: boolean
|
||||
minPriceFen: number
|
||||
}
|
||||
export type { ConsignmentPricingConfig, ConsignmentSpecField } from './consignment-spec-service.js'
|
||||
|
||||
export type ConsignmentDepositConfig = {
|
||||
enabled: boolean
|
||||
@@ -69,7 +73,12 @@ export type ConsignmentProfileConfig = {
|
||||
version: 1
|
||||
tabTitle: string
|
||||
tabDescription: string
|
||||
/** 旧版发布开关:由 specs 派生,quantity 仍独立控制 */
|
||||
fields: ConsignmentPublishFields
|
||||
/** 动态规格字段(完全由运营配置;默认空) */
|
||||
specs: ConsignmentSpecField[]
|
||||
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
|
||||
nameTemplate: string
|
||||
pricing: ConsignmentPricingConfig
|
||||
deposit: ConsignmentDepositConfig
|
||||
serviceFee: ConsignmentServiceFeeConfig
|
||||
@@ -101,6 +110,7 @@ export type ConsignmentProfileRow = {
|
||||
export type ConsignmentProfileSnapshot = {
|
||||
profileId: number
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
versionId: number
|
||||
versionNo: number
|
||||
config: ConsignmentProfileConfig
|
||||
@@ -111,18 +121,16 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
|
||||
tabTitle: '',
|
||||
tabDescription: '',
|
||||
fields: {
|
||||
system: true,
|
||||
serverZone: true,
|
||||
series: true,
|
||||
diamondLevel: true,
|
||||
brillianceCount: true,
|
||||
system: false,
|
||||
serverZone: false,
|
||||
series: false,
|
||||
diamondLevel: false,
|
||||
brillianceCount: false,
|
||||
quantity: true,
|
||||
},
|
||||
pricing: {
|
||||
useTemplatePrice: true,
|
||||
allowWorkerPrice: false,
|
||||
minPriceFen: 0,
|
||||
},
|
||||
specs: DEFAULT_CONSIGNMENT_SPECS,
|
||||
nameTemplate: '',
|
||||
pricing: DEFAULT_CONSIGNMENT_PRICING,
|
||||
deposit: {
|
||||
enabled: false,
|
||||
mode: 'fixed',
|
||||
@@ -197,9 +205,14 @@ function integer(value: unknown, fallback: number, min: number, max: number): nu
|
||||
}
|
||||
|
||||
export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentProfileConfig {
|
||||
const source = record(value)
|
||||
const outer = record(value)
|
||||
// 兼容直接存 config 的场景,以及库存/提报里 { config: {...}, profileId... } 的快照包装。
|
||||
const source =
|
||||
outer.config && typeof outer.config === 'object' && !Array.isArray(outer.config)
|
||||
? record(outer.config)
|
||||
: outer
|
||||
const fields = record(source.fields)
|
||||
const pricing = record(source.pricing)
|
||||
const specs = normalizeConsignmentSpecs(source.specs ?? source.specs_json, fields)
|
||||
const deposit = record(source.deposit)
|
||||
const serviceFee = record(source.serviceFee)
|
||||
const inventoryLimit = record(source.inventoryLimit)
|
||||
@@ -216,36 +229,26 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
|
||||
timeout.policy === 'cancel_release' || timeout.policy === 'cancel_deduct'
|
||||
? timeout.policy
|
||||
: 'reopen'
|
||||
const specKeys = new Set(specs.map((spec) => spec.key))
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
tabTitle: text(source.tabTitle, 30),
|
||||
tabDescription: text(source.tabDescription, 200),
|
||||
// 旧字段保留给旧代码/旧页面;有 specs 时以内置规格是否存在为准。
|
||||
fields: {
|
||||
system: bool(fields.system, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.system),
|
||||
serverZone: bool(fields.serverZone, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.serverZone),
|
||||
series: bool(fields.series, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.series),
|
||||
diamondLevel: bool(
|
||||
fields.diamondLevel,
|
||||
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.diamondLevel,
|
||||
),
|
||||
brillianceCount: bool(
|
||||
fields.brillianceCount,
|
||||
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.brillianceCount,
|
||||
),
|
||||
system: specKeys.has('system'),
|
||||
serverZone: specKeys.has('serverZone'),
|
||||
series: specKeys.has('series'),
|
||||
diamondLevel: specKeys.has('diamondLevel'),
|
||||
brillianceCount: specKeys.has('brillianceCount'),
|
||||
quantity: bool(fields.quantity, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.quantity),
|
||||
},
|
||||
pricing: {
|
||||
useTemplatePrice: bool(
|
||||
pricing.useTemplatePrice,
|
||||
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.useTemplatePrice,
|
||||
),
|
||||
allowWorkerPrice: bool(
|
||||
pricing.allowWorkerPrice,
|
||||
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.allowWorkerPrice,
|
||||
),
|
||||
minPriceFen: integer(pricing.minPriceFen, 0, 0, 100_000_000),
|
||||
},
|
||||
specs,
|
||||
nameTemplate:
|
||||
text(source.nameTemplate ?? source.name_template, 300) ||
|
||||
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.nameTemplate,
|
||||
pricing: normalizeConsignmentPricing(source.pricing),
|
||||
deposit: {
|
||||
enabled: bool(deposit.enabled, false),
|
||||
mode: depositMode,
|
||||
@@ -525,6 +528,7 @@ export async function resolveConsignmentProfileForCategory(
|
||||
return {
|
||||
profileId: Number(row.id),
|
||||
categoryId: Number(row.category_id),
|
||||
categoryName: row.category_name || '',
|
||||
versionId: Number(row.current_version_id),
|
||||
versionNo: Number(row.current_version_no || 1),
|
||||
config: normalizeConsignmentProfileConfig(row.config_json),
|
||||
@@ -547,6 +551,7 @@ export async function getPublishedConsignmentProfileById(
|
||||
return {
|
||||
profileId: Number(row.id),
|
||||
categoryId: Number(row.category_id),
|
||||
categoryName: row.category_name || '',
|
||||
versionId: Number(row.current_version_id),
|
||||
versionNo: Number(row.current_version_no || 1),
|
||||
config: normalizeConsignmentProfileConfig(row.config_json),
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import { normalizeConsignmentProfileConfig } from './consignment-profile-service.js'
|
||||
import {
|
||||
buildConsignmentItemName,
|
||||
buildConsignmentSpecKey,
|
||||
buildConsignmentSpecText,
|
||||
computeConsignmentPrice,
|
||||
evaluateConsignmentSpecMatch,
|
||||
normalizeConsignmentSpecValues,
|
||||
normalizeConsignmentSpecs,
|
||||
parseConsignmentSpecValues,
|
||||
resolveLegacyConsignmentColumns,
|
||||
resolveWorkerConsignmentPrice,
|
||||
type ConsignmentPricingConfig,
|
||||
type ConsignmentSpecField,
|
||||
} from './consignment-spec-service.js'
|
||||
|
||||
function buildPricing(overrides: Partial<ConsignmentPricingConfig> = {}): ConsignmentPricingConfig {
|
||||
return {
|
||||
priceType: 'template_discount',
|
||||
discount: 10,
|
||||
fixedPriceFen: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceFen: 0,
|
||||
missingPricePolicy: 'manual',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function option(label: string, value: string) {
|
||||
return { label, value, enabled: true }
|
||||
}
|
||||
|
||||
/** 苏尔南手枪种子配置的五个字段(与迁移 131 一致)。 */
|
||||
function surnanSpecs(): ConsignmentSpecField[] {
|
||||
return [
|
||||
{
|
||||
key: 'system',
|
||||
label: '操作系统',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [option('苹果', '苹果'), option('安卓', '安卓')],
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'exact',
|
||||
wildcardValues: [],
|
||||
showInItemName: true,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
key: 'serverZone',
|
||||
label: '游戏区服',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [option('互通(QQ/微信)', '互通')],
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'wildcard',
|
||||
wildcardValues: ['互通', '通用', '全区', '不限'],
|
||||
showInItemName: false,
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
label: '系列 / 枪系',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [option('暗潮', '暗潮'), option('怒嚎', '怒嚎'), option('血宴', '血宴')],
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'exact',
|
||||
wildcardValues: [],
|
||||
showInItemName: true,
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
key: 'diamondLevel',
|
||||
label: '钻石等级',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: Array.from({ length: 7 }, (_, index) => option(`${index + 1}钻`, String(index + 1))),
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'minimum',
|
||||
wildcardValues: [],
|
||||
showInItemName: true,
|
||||
sortOrder: 40,
|
||||
},
|
||||
{
|
||||
key: 'brillianceCount',
|
||||
label: '璀璨数',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: Array.from({ length: 8 }, (_, index) =>
|
||||
index === 0 ? option('无', '0') : option(`${index}璀`, String(index)),
|
||||
),
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'minimum',
|
||||
wildcardValues: [],
|
||||
showInItemName: true,
|
||||
sortOrder: 50,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const surnanValues = {
|
||||
system: '苹果',
|
||||
serverZone: '互通',
|
||||
series: '暗潮',
|
||||
diamondLevel: '3',
|
||||
brillianceCount: '1',
|
||||
}
|
||||
|
||||
test('动态规格:默认配置没有任何字段,价格默认十折', () => {
|
||||
const config = normalizeConsignmentProfileConfig({})
|
||||
assert.equal(config.specs.length, 0)
|
||||
assert.equal(config.pricing.priceType, 'template_discount')
|
||||
assert.equal(config.pricing.discount, 10)
|
||||
assert.equal(config.pricing.allowWorkerPrice, true)
|
||||
assert.equal(config.pricing.missingPricePolicy, 'manual')
|
||||
assert.equal(config.fields.quantity, true)
|
||||
assert.equal(config.fields.system, false)
|
||||
})
|
||||
|
||||
test('动态规格:支持 6 种字段类型,并保留选项字段语义', () => {
|
||||
const config = normalizeConsignmentProfileConfig({
|
||||
specs: [
|
||||
...surnanSpecs(),
|
||||
{
|
||||
key: 'note',
|
||||
label: '备注',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'joinedAt',
|
||||
label: '入坑日期',
|
||||
type: 'date',
|
||||
required: false,
|
||||
options: [],
|
||||
},
|
||||
{ key: 'bad key', label: '非法', type: 'text' },
|
||||
],
|
||||
})
|
||||
assert.equal(config.specs.length, 7)
|
||||
assert.equal(config.specs.find((spec) => spec.key === 'note')?.type, 'textarea')
|
||||
assert.equal(config.specs.find((spec) => spec.key === 'joinedAt')?.type, 'date')
|
||||
assert.equal(config.specs.find((spec) => spec.key === 'system')?.type, 'radio')
|
||||
assert.equal(config.fields.system, true)
|
||||
assert.equal(config.fields.series, true)
|
||||
})
|
||||
|
||||
test('动态规格:旧版 fields 开关不再生成任何默认字段', () => {
|
||||
const specs = normalizeConsignmentSpecs(undefined, {
|
||||
system: true,
|
||||
serverZone: false,
|
||||
series: true,
|
||||
diamondLevel: false,
|
||||
brillianceCount: true,
|
||||
})
|
||||
assert.deepEqual(specs, [])
|
||||
})
|
||||
|
||||
test('动态规格:必填、选项枚举、璀璨上限与日期格式校验', () => {
|
||||
const specs = [
|
||||
...surnanSpecs(),
|
||||
{
|
||||
key: 'note',
|
||||
label: '备注',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'exact' as const,
|
||||
wildcardValues: [],
|
||||
showInItemName: false,
|
||||
sortOrder: 60,
|
||||
},
|
||||
{
|
||||
key: 'joinedAt',
|
||||
label: '入坑日期',
|
||||
type: 'date',
|
||||
required: false,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'exact' as const,
|
||||
wildcardValues: [],
|
||||
showInItemName: false,
|
||||
sortOrder: 70,
|
||||
},
|
||||
]
|
||||
const missing = normalizeConsignmentSpecValues(specs, { system: '苹果' })
|
||||
assert.ok(missing.errors.some((error) => error.includes('系列')))
|
||||
|
||||
const invalid = normalizeConsignmentSpecValues(specs, {
|
||||
...surnanValues,
|
||||
system: '鸿蒙',
|
||||
})
|
||||
assert.ok(invalid.errors.some((error) => error.includes('选项无效')))
|
||||
|
||||
const tooMany = normalizeConsignmentSpecValues(specs, {
|
||||
...surnanValues,
|
||||
diamondLevel: 2,
|
||||
brillianceCount: 3,
|
||||
})
|
||||
assert.ok(tooMany.errors.some((error) => error.includes('璀璨数不能超过钻石等级')))
|
||||
|
||||
const badDate = normalizeConsignmentSpecValues(specs, {
|
||||
...surnanValues,
|
||||
joinedAt: '2025-02-30',
|
||||
})
|
||||
assert.ok(badDate.errors.some((error) => error.includes('有效日期')))
|
||||
|
||||
const longNote = 'x'.repeat(2200)
|
||||
const normalized = normalizeConsignmentSpecValues(specs, {
|
||||
...surnanValues,
|
||||
joinedAt: '2025-02-28',
|
||||
note: longNote,
|
||||
})
|
||||
assert.deepEqual(normalized.errors, [])
|
||||
assert.equal(String(normalized.values.note).length, 2000)
|
||||
assert.equal(normalized.values.joinedAt, '2025-02-28')
|
||||
})
|
||||
|
||||
test('动态规格:spec_key 按字段排序,只包含参与匹配的字段', () => {
|
||||
const specs: ConsignmentSpecField[] = [
|
||||
{ ...surnanSpecs()[0]!, key: 'zField', label: 'Z', sortOrder: 1 },
|
||||
{ ...surnanSpecs()[0]!, key: 'aField', label: 'A', sortOrder: 2 },
|
||||
{ ...surnanSpecs()[0]!, key: 'ignored', label: '忽略', matchMode: 'ignore' },
|
||||
]
|
||||
const key = buildConsignmentSpecKey(specs, { zField: '2', aField: '1', ignored: 'x' })
|
||||
assert.equal(key, 'aField=1|zField=2')
|
||||
})
|
||||
|
||||
test('动态规格:物品名模板与规格展示文本', () => {
|
||||
const specs = surnanSpecs()
|
||||
const name = buildConsignmentItemName({
|
||||
template: '{system}{serverZone}{series}{diamondLevel}钻{brillianceCount}璀璨 {category}',
|
||||
specs,
|
||||
values: surnanValues,
|
||||
categoryName: '苏尔南手枪',
|
||||
})
|
||||
assert.equal(name, '苹果暗潮3钻1璀璨 苏尔南手枪')
|
||||
const text = buildConsignmentSpecText(specs, surnanValues)
|
||||
assert.ok(text.includes('操作系统 苹果'))
|
||||
assert.ok(text.includes('游戏区服 互通'))
|
||||
|
||||
const fallback = buildConsignmentItemName({
|
||||
template: '',
|
||||
specs: [],
|
||||
values: {},
|
||||
categoryName: '苏尔南手枪',
|
||||
})
|
||||
assert.equal(fallback, '苏尔南手枪')
|
||||
})
|
||||
|
||||
test('动态价格:模板折扣价与改价上限等于建议价', () => {
|
||||
const result = computeConsignmentPrice({
|
||||
pricing: buildPricing({ discount: 9.5 }),
|
||||
templatePriceFen: 13000,
|
||||
})
|
||||
assert.equal(result.priceFen, 12350)
|
||||
assert.equal(result.capFen, 12350)
|
||||
assert.equal(result.templatePriceFen, 13000)
|
||||
assert.equal(result.source, 'template')
|
||||
})
|
||||
|
||||
test('动态价格:固定价格可用,且不能高于接单模板价', () => {
|
||||
const normal = computeConsignmentPrice({
|
||||
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 10000 }),
|
||||
templatePriceFen: 13000,
|
||||
})
|
||||
assert.equal(normal.priceFen, 10000)
|
||||
assert.equal(normal.capFen, 10000)
|
||||
assert.equal(normal.source, 'fixed')
|
||||
|
||||
const above = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 15000 }),
|
||||
templatePriceFen: 13000,
|
||||
})
|
||||
assert.ok(above.errors.some((error) => error.includes('固定价格不能高于接单模板价')))
|
||||
|
||||
const noTemplate = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 8000 }),
|
||||
templatePriceFen: 0,
|
||||
})
|
||||
assert.deepEqual(noTemplate.errors, [])
|
||||
assert.equal(noTemplate.priceFen, 8000)
|
||||
})
|
||||
|
||||
test('动态价格:打手改价只能低于系统建议价,且受最低价约束', () => {
|
||||
const pricing = buildPricing({ discount: 9.5 })
|
||||
const tooHigh = resolveWorkerConsignmentPrice({
|
||||
pricing,
|
||||
templatePriceFen: 13000,
|
||||
submittedPriceFen: 13000,
|
||||
})
|
||||
assert.ok(tooHigh.errors.some((error) => error.includes('不能高于系统建议价')))
|
||||
|
||||
const lower = resolveWorkerConsignmentPrice({
|
||||
pricing,
|
||||
templatePriceFen: 13000,
|
||||
submittedPriceFen: 12000,
|
||||
})
|
||||
assert.deepEqual(lower.errors, [])
|
||||
assert.equal(lower.priceFen, 12000)
|
||||
assert.equal(lower.capFen, 12350)
|
||||
|
||||
const belowMin = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ discount: 9.5, minPriceFen: 10000 }),
|
||||
templatePriceFen: 13000,
|
||||
submittedPriceFen: 9000,
|
||||
})
|
||||
assert.ok(belowMin.errors.some((error) => error.includes('不能少于')))
|
||||
})
|
||||
|
||||
test('动态价格:没有模板价时允许手填,block 策略则拦截', () => {
|
||||
const manual = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ missingPricePolicy: 'manual' }),
|
||||
templatePriceFen: 0,
|
||||
submittedPriceFen: 8000,
|
||||
})
|
||||
assert.deepEqual(manual.errors, [])
|
||||
assert.equal(manual.priceFen, 8000)
|
||||
|
||||
const blocked = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ missingPricePolicy: 'block' }),
|
||||
templatePriceFen: 0,
|
||||
submittedPriceFen: 8000,
|
||||
})
|
||||
assert.ok(blocked.errors.length > 0)
|
||||
})
|
||||
|
||||
test('通用匹配:精确、部分、冲突、未知与通配', () => {
|
||||
const specs = surnanSpecs()
|
||||
const inventory = { ...surnanValues }
|
||||
|
||||
const exact = evaluateConsignmentSpecMatch({
|
||||
specs,
|
||||
orderValues: { ...inventory },
|
||||
inventoryValues: inventory,
|
||||
})
|
||||
assert.equal(exact.exact, true)
|
||||
|
||||
const partial = evaluateConsignmentSpecMatch({
|
||||
specs,
|
||||
orderValues: { ...inventory, diamondLevel: '2' },
|
||||
inventoryValues: inventory,
|
||||
})
|
||||
assert.equal(partial.exact, false)
|
||||
assert.equal(partial.partial, true)
|
||||
|
||||
const conflict = evaluateConsignmentSpecMatch({
|
||||
specs,
|
||||
orderValues: { ...inventory, series: '血宴' },
|
||||
inventoryValues: inventory,
|
||||
})
|
||||
assert.equal(conflict.exact, false)
|
||||
assert.ok(conflict.conflicts.includes('系列 / 枪系'))
|
||||
|
||||
const unknown = evaluateConsignmentSpecMatch({
|
||||
specs,
|
||||
orderValues: { system: '苹果' },
|
||||
inventoryValues: inventory,
|
||||
})
|
||||
assert.equal(unknown.exact, false)
|
||||
assert.ok(unknown.unknowns.length > 0)
|
||||
})
|
||||
|
||||
test('通用匹配:自定义字段必须双方同 key 且值一致才算精确', () => {
|
||||
const config = normalizeConsignmentProfileConfig({
|
||||
specs: [
|
||||
...surnanSpecs(),
|
||||
{
|
||||
key: 'map',
|
||||
label: '地图',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '海岛', value: '海岛' },
|
||||
{ label: '沙漠', value: '沙漠' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const inventory = { ...surnanValues, map: '海岛' }
|
||||
const exact = evaluateConsignmentSpecMatch({
|
||||
specs: config.specs,
|
||||
orderValues: { ...inventory },
|
||||
inventoryValues: inventory,
|
||||
})
|
||||
assert.equal(exact.exact, true)
|
||||
|
||||
const missingOrderField = evaluateConsignmentSpecMatch({
|
||||
specs: config.specs,
|
||||
orderValues: { ...inventory, map: '' },
|
||||
inventoryValues: inventory,
|
||||
})
|
||||
assert.equal(missingOrderField.exact, false)
|
||||
assert.ok(missingOrderField.unknowns.includes('地图'))
|
||||
})
|
||||
|
||||
test('动态规格:旧列仍由兼容 key 同步', () => {
|
||||
const legacy = resolveLegacyConsignmentColumns({
|
||||
...surnanValues,
|
||||
diamondLevel: '5',
|
||||
brillianceCount: '2',
|
||||
map: '海岛',
|
||||
})
|
||||
assert.deepEqual(legacy, {
|
||||
system: '苹果',
|
||||
serverZone: '互通',
|
||||
series: '暗潮',
|
||||
diamondLevel: 5,
|
||||
brillianceCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('迁移 130 必须包含 JSON、匹配 key、文本与改价上限', async () => {
|
||||
const sql = await readFile(
|
||||
new URL('../../db/migrations/130_consignment_dynamic_specs.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
for (const fragment of [
|
||||
'spec_values_json',
|
||||
'spec_key',
|
||||
'spec_text',
|
||||
'price_cap_fen',
|
||||
'idx_consignment_inventories_spec_key',
|
||||
]) {
|
||||
assert.ok(sql.includes(fragment), `迁移 130 缺少 ${fragment}`)
|
||||
}
|
||||
assert.ok(parseConsignmentSpecValues({ system: '苹果', diamondLevel: 3 }).system === '苹果')
|
||||
})
|
||||
|
||||
test('迁移 131 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => {
|
||||
const sql = await readFile(
|
||||
new URL('../../db/migrations/131_consignment_surnan_default_profile.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
for (const fragment of [
|
||||
"category_key = 'shouqiang'",
|
||||
'"type": "radio"',
|
||||
'"priceType": "template_discount"',
|
||||
"'published'",
|
||||
'"key": "brillianceCount"',
|
||||
]) {
|
||||
assert.ok(sql.includes(fragment), `迁移 131 缺少 ${fragment}`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* 寄卖动态规格与价格(单一事实来源)。
|
||||
*
|
||||
* 设计约定:
|
||||
* - 没有任何「内置字段」:配置初始 specs 为空,字段全部由运营在寄卖配置里维护;
|
||||
* 苏尔南手枪的五个字段由迁移 131 作为种子配置写入;
|
||||
* - 字段类型支持:文本 / 长文本 / 下拉 / 单选 / 数字 / 日期;
|
||||
* - 价格只有两种:接单模板价打折、固定价格;打手只能按系统建议价往下调;
|
||||
* - 旧列兼容:字段 key 仍为 system/serverZone/series/diamondLevel/brillianceCount 时,
|
||||
* 继续同步旧列并沿用旧匹配器,保证存量库存与工单不回归。
|
||||
*/
|
||||
|
||||
export const BUILT_IN_CONSIGNMENT_SPEC_KEYS = [
|
||||
'system',
|
||||
'serverZone',
|
||||
'series',
|
||||
'diamondLevel',
|
||||
'brillianceCount',
|
||||
] as const
|
||||
|
||||
export type BuiltInConsignmentSpecKey = (typeof BUILT_IN_CONSIGNMENT_SPEC_KEYS)[number]
|
||||
|
||||
export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
|
||||
export type ConsignmentSpecMatchMode = 'exact' | 'minimum' | 'wildcard' | 'ignore'
|
||||
|
||||
export type ConsignmentSpecOption = {
|
||||
label: string
|
||||
value: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ConsignmentSpecField = {
|
||||
key: string
|
||||
label: string
|
||||
type: ConsignmentSpecFieldType
|
||||
required: boolean
|
||||
options: ConsignmentSpecOption[]
|
||||
placeholder: string
|
||||
helpText: string
|
||||
matchMode: ConsignmentSpecMatchMode
|
||||
wildcardValues: string[]
|
||||
showInItemName: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type ConsignmentPricingConfig = {
|
||||
/** template_discount = 接单模板价的几折;fixed = 固定价格 */
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
/** 几折,范围 0.1 - 10,例如 9.5 表示 95% */
|
||||
discount: number
|
||||
fixedPriceFen: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceFen: number
|
||||
/** 没有模板价时:允许打手手填 / 阻止发布 */
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
}
|
||||
|
||||
export type ConsignmentSpecValues = Record<string, string | number>
|
||||
|
||||
export type ConsignmentSpecValidationResult = {
|
||||
values: ConsignmentSpecValues
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type ConsignmentPriceResult = {
|
||||
priceFen: number
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
source: 'template' | 'fixed' | 'none'
|
||||
breakdown: { label: string; amountFen: number }[]
|
||||
}
|
||||
|
||||
export type WorkerConsignmentPriceResult = {
|
||||
priceFen: number
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
source: 'template' | 'fixed' | 'none'
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type ConsignmentSpecMatchDetail = {
|
||||
key: string
|
||||
label: string
|
||||
orderValue: string
|
||||
inventoryValue: string
|
||||
status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored'
|
||||
}
|
||||
|
||||
export type ConsignmentSpecMatchEvaluation = {
|
||||
exact: boolean
|
||||
partial: boolean
|
||||
unknown: boolean
|
||||
conflicts: string[]
|
||||
unknowns: string[]
|
||||
details: ConsignmentSpecMatchDetail[]
|
||||
}
|
||||
|
||||
const BUILT_IN_KEY_SET = new Set<string>(BUILT_IN_CONSIGNMENT_SPEC_KEYS)
|
||||
|
||||
const ZONE_WILDCARD_VALUES = ['互通', '通用', '全区', '不限']
|
||||
const SYSTEM_WILDCARD_VALUES = ['通用', '双端', '全端']
|
||||
|
||||
const SYSTEM_ALIASES: Record<string, string> = {
|
||||
苹果: '苹果',
|
||||
安卓: '安卓',
|
||||
通用: '通用',
|
||||
双端: '通用',
|
||||
全端: '通用',
|
||||
}
|
||||
|
||||
const ZONE_ALIASES: Record<string, string> = {
|
||||
微信区: '微信区',
|
||||
QQ区: 'QQ区',
|
||||
qq区: 'QQ区',
|
||||
互通: '互通',
|
||||
通用: '互通',
|
||||
全区: '互通',
|
||||
不限: '互通',
|
||||
}
|
||||
|
||||
const SPEC_TYPES: ConsignmentSpecFieldType[] = [
|
||||
'text',
|
||||
'textarea',
|
||||
'select',
|
||||
'radio',
|
||||
'number',
|
||||
'date',
|
||||
]
|
||||
|
||||
/** 兼容旧导入名;新配置不再默认带任何字段。 */
|
||||
export const DEFAULT_CONSIGNMENT_SPECS: ConsignmentSpecField[] = []
|
||||
|
||||
export const DEFAULT_CONSIGNMENT_PRICING: ConsignmentPricingConfig = {
|
||||
priceType: 'template_discount',
|
||||
discount: 10,
|
||||
fixedPriceFen: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceFen: 0,
|
||||
missingPricePolicy: 'manual',
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function text(value: unknown, maxLength: number): string {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
|
||||
function bool(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true' || value === 1 || value === '1') return true
|
||||
if (value === 'false' || value === 0 || value === '0') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function integer(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, Math.round(parsed)))
|
||||
}
|
||||
|
||||
function decimal(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, maxItems = 30): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const item of value) {
|
||||
const normalized = text(item, 60)
|
||||
if (!normalized || seen.has(normalized)) continue
|
||||
seen.add(normalized)
|
||||
result.push(normalized)
|
||||
if (result.length >= maxItems) break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isBuiltInSpecKey(key: string): key is BuiltInConsignmentSpecKey {
|
||||
return BUILT_IN_KEY_SET.has(key)
|
||||
}
|
||||
|
||||
function resolveSpecType(value: unknown): ConsignmentSpecFieldType {
|
||||
const raw = text(value, 20).toLowerCase()
|
||||
if (SPEC_TYPES.includes(raw as ConsignmentSpecFieldType)) {
|
||||
return raw as ConsignmentSpecFieldType
|
||||
}
|
||||
// 兼容旧配置与常见别名
|
||||
if (raw === 'longtext' || raw === 'long_text' || raw === 'textarea') return 'textarea'
|
||||
if (raw === 'dropdown' || raw === 'dropdown_select') return 'select'
|
||||
if (raw === 'single' || raw === 'single_select' || raw === 'radiogroup') return 'radio'
|
||||
if (raw === 'int' || raw === 'decimal') return 'number'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function normalizeOption(value: unknown): ConsignmentSpecOption | null {
|
||||
const source = record(value)
|
||||
const optionValue = text(source.value ?? source.key ?? source.label, 100)
|
||||
if (!optionValue) return null
|
||||
return {
|
||||
label: text(source.label, 80) || optionValue,
|
||||
value: optionValue,
|
||||
enabled: bool(source.enabled, true),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(value: unknown, key: string): ConsignmentSpecOption[] {
|
||||
const list = Array.isArray(value) ? value : []
|
||||
const normalized: ConsignmentSpecOption[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of list) {
|
||||
const option = normalizeOption(item)
|
||||
if (!option || seen.has(option.value)) continue
|
||||
seen.add(option.value)
|
||||
normalized.push(option)
|
||||
if (normalized.length >= 100) break
|
||||
}
|
||||
if (normalized.length > 0) return normalized
|
||||
return [{ label: key, value: key, enabled: true }]
|
||||
}
|
||||
|
||||
function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField | null {
|
||||
const source = record(value)
|
||||
const key = text(source.key, 40)
|
||||
if (!key || !/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(key)) return null
|
||||
|
||||
const type = resolveSpecType(source.type)
|
||||
const options = type === 'select' || type === 'radio' ? normalizeOptions(source.options, key) : []
|
||||
const rawMatchMode = text(source.matchMode ?? source.match_mode, 20)
|
||||
const matchMode: ConsignmentSpecMatchMode =
|
||||
rawMatchMode === 'minimum'
|
||||
? 'minimum'
|
||||
: rawMatchMode === 'wildcard'
|
||||
? 'wildcard'
|
||||
: rawMatchMode === 'ignore'
|
||||
? 'ignore'
|
||||
: 'exact'
|
||||
const wildcardValues = stringArray(source.wildcardValues ?? source.wildcard_values)
|
||||
const fallbackWildcards =
|
||||
wildcardValues.length > 0
|
||||
? wildcardValues
|
||||
: key === 'serverZone'
|
||||
? [...ZONE_WILDCARD_VALUES]
|
||||
: key === 'system'
|
||||
? [...SYSTEM_WILDCARD_VALUES]
|
||||
: []
|
||||
|
||||
return {
|
||||
key,
|
||||
label: text(source.label, 80) || key,
|
||||
type,
|
||||
required: bool(source.required, true),
|
||||
options,
|
||||
placeholder: text(source.placeholder, 120),
|
||||
helpText: text(source.helpText ?? source.help_text, 300),
|
||||
matchMode,
|
||||
wildcardValues: fallbackWildcards,
|
||||
showInItemName: bool(source.showInItemName ?? source.show_in_item_name, true),
|
||||
sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化规格定义:有 specs 数组就按数组处理;没有就是空配置。
|
||||
* 不再自动补任何「内置字段」。
|
||||
*/
|
||||
export function normalizeConsignmentSpecs(
|
||||
value: unknown,
|
||||
_legacyFields?: Record<string, unknown> | null,
|
||||
): ConsignmentSpecField[] {
|
||||
if (!Array.isArray(value) || value.length === 0) return []
|
||||
const seen = new Set<string>()
|
||||
const result: ConsignmentSpecField[] = []
|
||||
value.forEach((item, index) => {
|
||||
const field = normalizeSpecField(item, index)
|
||||
if (!field || seen.has(field.key)) return
|
||||
seen.add(field.key)
|
||||
result.push(field)
|
||||
})
|
||||
return result.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
export function normalizeConsignmentPricing(value: unknown): ConsignmentPricingConfig {
|
||||
const source = record(value)
|
||||
const rawPriceType = text(source.priceType ?? source.price_type, 30)
|
||||
// 兼容上一版:source=template 视为 10 折;source=profile 尽量映射成固定价。
|
||||
const legacySource = text(source.source, 20)
|
||||
const priceType: ConsignmentPricingConfig['priceType'] =
|
||||
rawPriceType === 'fixed' || legacySource === 'profile' ? 'fixed' : 'template_discount'
|
||||
const legacyFixedPriceFen = integer(
|
||||
source.fixedPriceFen ?? source.fixed_price_fen ?? source.basePriceFen ?? source.base_price_fen,
|
||||
0,
|
||||
0,
|
||||
100_000_000,
|
||||
)
|
||||
const missingPricePolicyRaw = text(source.missingPricePolicy ?? source.missing_price_policy, 30)
|
||||
return {
|
||||
priceType,
|
||||
discount: decimal(source.discount, 10, 0.1, 10),
|
||||
fixedPriceFen: legacyFixedPriceFen,
|
||||
allowWorkerPrice: bool(
|
||||
source.allowWorkerPrice ?? source.allow_worker_price,
|
||||
DEFAULT_CONSIGNMENT_PRICING.allowWorkerPrice,
|
||||
),
|
||||
minPriceFen: integer(source.minPriceFen ?? source.min_price_fen, 0, 0, 100_000_000),
|
||||
missingPricePolicy: missingPricePolicyRaw === 'block' ? 'block' : 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
function builtInAliases(key: string): string[] {
|
||||
if (key === 'system') return ['system', 'os']
|
||||
if (key === 'serverZone') return ['serverZone', 'server_zone', 'zone']
|
||||
if (key === 'series') return ['series', 'gunSeries']
|
||||
if (key === 'diamondLevel') return ['diamondLevel', 'diamond_level', 'diamond']
|
||||
if (key === 'brillianceCount') return ['brillianceCount', 'brilliance_count', 'brilliance']
|
||||
return [key]
|
||||
}
|
||||
|
||||
function readRawSpecValue(input: Record<string, unknown>, key: string): unknown {
|
||||
for (const alias of builtInAliases(key)) {
|
||||
if (input[alias] !== undefined && input[alias] !== null && String(input[alias]).trim() !== '') {
|
||||
return input[alias]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function optionMatches(option: ConsignmentSpecOption, raw: string): boolean {
|
||||
return option.value === raw || option.value.toLowerCase() === raw.toLowerCase()
|
||||
}
|
||||
|
||||
function findOption(field: ConsignmentSpecField, raw: string): ConsignmentSpecOption | null {
|
||||
return field.options.find((option) => optionMatches(option, raw)) || null
|
||||
}
|
||||
|
||||
function isValidDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||
const parsed = Date.parse(`${value}T00:00:00Z`)
|
||||
if (!Number.isFinite(parsed)) return false
|
||||
return new Date(parsed).toISOString().slice(0, 10) === value
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并归一化一组规格值。错误信息直接给前端展示。
|
||||
*/
|
||||
export function normalizeConsignmentSpecValues(
|
||||
specs: ConsignmentSpecField[],
|
||||
input: unknown,
|
||||
): ConsignmentSpecValidationResult {
|
||||
const source = record(input)
|
||||
const nested = record(source.specValues ?? source.spec_values)
|
||||
const values: ConsignmentSpecValues = {}
|
||||
const errors: string[] = []
|
||||
|
||||
for (const field of specs) {
|
||||
const rawValue =
|
||||
nested[field.key] !== undefined &&
|
||||
nested[field.key] !== null &&
|
||||
String(nested[field.key]).trim() !== ''
|
||||
? nested[field.key]
|
||||
: readRawSpecValue(source, field.key)
|
||||
|
||||
const raw = rawValue === undefined || rawValue === null ? '' : String(rawValue).trim()
|
||||
if (!raw) {
|
||||
if (field.required) errors.push(`请选择或填写「${field.label}」`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.type === 'select' || field.type === 'radio') {
|
||||
const option = findOption(field, raw)
|
||||
if (!option || option.enabled === false) {
|
||||
errors.push(`「${field.label}」的选项无效:${raw}`)
|
||||
continue
|
||||
}
|
||||
values[field.key] = option.value
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
errors.push(`「${field.label}」必须是数字`)
|
||||
continue
|
||||
}
|
||||
values[field.key] = Math.round(parsed)
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
if (!isValidDate(raw)) {
|
||||
errors.push(`「${field.label}」必须是有效日期(YYYY-MM-DD)`)
|
||||
continue
|
||||
}
|
||||
values[field.key] = raw
|
||||
continue
|
||||
}
|
||||
|
||||
const maxLength = field.type === 'textarea' ? 2000 : 200
|
||||
values[field.key] = raw.slice(0, maxLength)
|
||||
}
|
||||
|
||||
const diamond = Number(values.diamondLevel)
|
||||
const brilliance = Number(values.brillianceCount)
|
||||
if (
|
||||
Number.isFinite(diamond) &&
|
||||
Number.isFinite(brilliance) &&
|
||||
values.diamondLevel !== undefined &&
|
||||
values.brillianceCount !== undefined &&
|
||||
brilliance > diamond
|
||||
) {
|
||||
errors.push('璀璨数不能超过钻石等级(如1钻最多1璀、2钻最多2璀)')
|
||||
}
|
||||
|
||||
return { values, errors }
|
||||
}
|
||||
|
||||
function normalizeComparable(field: ConsignmentSpecField, value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
if (field.key === 'system') return SYSTEM_ALIASES[raw] || raw
|
||||
if (field.key === 'serverZone') return ZONE_ALIASES[raw] || ZONE_ALIASES[raw.toLowerCase()] || raw
|
||||
if (field.type === 'number' || field.key === 'diamondLevel' || field.key === 'brillianceCount') {
|
||||
const numeric = Number(raw)
|
||||
if (Number.isFinite(numeric)) return String(Math.round(numeric))
|
||||
}
|
||||
return raw.toLowerCase()
|
||||
}
|
||||
|
||||
function isNumericSpecField(field: ConsignmentSpecField): boolean {
|
||||
return field.type === 'number' || field.key === 'diamondLevel' || field.key === 'brillianceCount'
|
||||
}
|
||||
|
||||
/** 只取参与匹配的字段生成稳定 key(按字段 key 排序,避免字段顺序影响)。 */
|
||||
export function buildConsignmentSpecKey(
|
||||
specs: ConsignmentSpecField[],
|
||||
values: ConsignmentSpecValues,
|
||||
): string {
|
||||
return specs
|
||||
.filter((field) => field.matchMode !== 'ignore')
|
||||
.map((field) => [field.key, normalizeComparable(field, values[field.key])] as const)
|
||||
.filter(([, value]) => value !== '')
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('|')
|
||||
}
|
||||
|
||||
export function formatConsignmentSpecValue(
|
||||
field: ConsignmentSpecField,
|
||||
value: unknown,
|
||||
options: { forItemName?: boolean } = {},
|
||||
): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
// 物品名模板自己会拼「钻 / 璀璨」后缀,这里返回纯数字,避免「3钻钻」「1璀璀璨」。
|
||||
if (options.forItemName && (field.key === 'diamondLevel' || field.key === 'brillianceCount')) {
|
||||
const numeric = Number(raw)
|
||||
if (!Number.isFinite(numeric)) return raw
|
||||
if (field.key === 'brillianceCount' && numeric <= 0) return ''
|
||||
return String(Math.round(numeric))
|
||||
}
|
||||
if (field.type === 'select' || field.type === 'radio') {
|
||||
const option = findOption(field, raw)
|
||||
const label = option?.label || raw
|
||||
if (
|
||||
options.forItemName &&
|
||||
field.key === 'serverZone' &&
|
||||
field.wildcardValues.some(
|
||||
(item) => normalizeComparable(field, item) === normalizeComparable(field, raw),
|
||||
)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return label
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export function buildConsignmentSpecText(
|
||||
specs: ConsignmentSpecField[],
|
||||
values: ConsignmentSpecValues,
|
||||
): string {
|
||||
return specs
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
|
||||
.map((field) => {
|
||||
const display = formatConsignmentSpecValue(field, values[field.key])
|
||||
return display ? `${field.label} ${display}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
export function buildConsignmentItemName(input: {
|
||||
template: string
|
||||
specs: ConsignmentSpecField[]
|
||||
values: ConsignmentSpecValues
|
||||
categoryName?: string
|
||||
fallbackSuffix?: string
|
||||
}): string {
|
||||
const { template, specs, values, categoryName = '', fallbackSuffix = '' } = input
|
||||
const tokenValues = new Map<string, string>()
|
||||
for (const field of specs) {
|
||||
tokenValues.set(
|
||||
field.key,
|
||||
formatConsignmentSpecValue(field, values[field.key], { forItemName: true }),
|
||||
)
|
||||
}
|
||||
tokenValues.set('category', categoryName)
|
||||
tokenValues.set('suffix', fallbackSuffix)
|
||||
|
||||
let result = String(template || '').trim()
|
||||
if (result) {
|
||||
result = result.replace(
|
||||
/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g,
|
||||
(_, key: string) => tokenValues.get(key) || '',
|
||||
)
|
||||
} else {
|
||||
result = specs
|
||||
.filter((field) => field.showInItemName)
|
||||
.map((field) => formatConsignmentSpecValue(field, values[field.key], { forItemName: true }))
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
if (fallbackSuffix) result = `${result} ${fallbackSuffix}`
|
||||
}
|
||||
const normalized = result.replace(/\s+/g, ' ').trim()
|
||||
if (normalized) return normalized
|
||||
// 配置没有字段、模板也为空时,至少用分类名兜底,避免物品名为空。
|
||||
return categoryName || fallbackSuffix || '寄卖物品'
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算系统建议价与改价上限。
|
||||
* - template_discount:接单模板价 × 折扣;
|
||||
* - fixed:固定价格;
|
||||
* - 改价上限 = 配置后的建议价(打手只能往下调)。
|
||||
*/
|
||||
export function computeConsignmentPrice(input: {
|
||||
pricing: ConsignmentPricingConfig
|
||||
templatePriceFen?: number
|
||||
}): ConsignmentPriceResult {
|
||||
const { pricing } = input
|
||||
const templatePriceFen = Math.max(0, Math.round(Number(input.templatePriceFen || 0)))
|
||||
const breakdown: { label: string; amountFen: number }[] = []
|
||||
let priceFen = 0
|
||||
let source: 'template' | 'fixed' | 'none' = 'none'
|
||||
|
||||
if (pricing.priceType === 'fixed') {
|
||||
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
|
||||
if (priceFen > 0) {
|
||||
source = 'fixed'
|
||||
breakdown.push({ label: '固定价格', amountFen: priceFen })
|
||||
}
|
||||
} else if (templatePriceFen > 0) {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((templatePriceFen * discount) / 10)
|
||||
source = 'template'
|
||||
breakdown.push({ label: '接单模板价', amountFen: templatePriceFen })
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
|
||||
}
|
||||
}
|
||||
|
||||
priceFen = Math.max(0, Math.round(priceFen))
|
||||
return {
|
||||
priceFen,
|
||||
capFen: priceFen,
|
||||
templatePriceFen,
|
||||
source,
|
||||
breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打手提交价格时决定最终售价:
|
||||
* - 默认用系统建议价(模板折扣价 / 固定价);
|
||||
* - 允许改价时只能下调,不能高于建议价;
|
||||
* - 没有建议价时按 missingPricePolicy 处理(manual=手填,仍受最低价约束)。
|
||||
*/
|
||||
export function resolveWorkerConsignmentPrice(input: {
|
||||
pricing: ConsignmentPricingConfig
|
||||
templatePriceFen?: number
|
||||
submittedPriceFen?: number
|
||||
}): WorkerConsignmentPriceResult {
|
||||
const computed = computeConsignmentPrice(input)
|
||||
const submittedPriceFen = Math.max(0, Math.round(Number(input.submittedPriceFen || 0)))
|
||||
const minPriceFen = Math.max(0, Number(input.pricing.minPriceFen || 0))
|
||||
const errors: string[] = []
|
||||
let priceFen = computed.priceFen
|
||||
|
||||
if (
|
||||
input.pricing.priceType === 'fixed' &&
|
||||
computed.templatePriceFen > 0 &&
|
||||
computed.priceFen > computed.templatePriceFen
|
||||
) {
|
||||
errors.push(`固定价格不能高于接单模板价 ¥${(computed.templatePriceFen / 100).toFixed(2)}`)
|
||||
}
|
||||
|
||||
if (priceFen > 0) {
|
||||
if (submittedPriceFen > 0) {
|
||||
if (!input.pricing.allowWorkerPrice) {
|
||||
errors.push('当前寄卖配置不允许修改售价')
|
||||
} else if (submittedPriceFen > computed.capFen) {
|
||||
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
|
||||
} else {
|
||||
priceFen = submittedPriceFen
|
||||
}
|
||||
}
|
||||
} else if (input.pricing.missingPricePolicy === 'block') {
|
||||
errors.push('该规格暂无价格,请联系客服补充价格后再提报')
|
||||
} else if (submittedPriceFen <= 0) {
|
||||
errors.push('该规格暂无模板价,请填写寄售价格')
|
||||
} else {
|
||||
priceFen = submittedPriceFen
|
||||
}
|
||||
|
||||
if (priceFen > 0 && priceFen < minPriceFen) {
|
||||
errors.push(`寄售价格不能少于 ¥${(minPriceFen / 100).toFixed(2)}`)
|
||||
}
|
||||
if (priceFen > 0) {
|
||||
priceFen = Math.max(100, priceFen)
|
||||
}
|
||||
return {
|
||||
priceFen,
|
||||
capFen: computed.capFen,
|
||||
templatePriceFen: computed.templatePriceFen,
|
||||
source: computed.source,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用规格匹配:精确 / 最低值 / 通配 / 忽略。
|
||||
* 订单侧缺字段 -> unknown(不能算精确);库存侧缺字段 -> conflict。
|
||||
*/
|
||||
export function evaluateConsignmentSpecMatch(input: {
|
||||
specs: ConsignmentSpecField[]
|
||||
orderValues: ConsignmentSpecValues
|
||||
inventoryValues: ConsignmentSpecValues
|
||||
}): ConsignmentSpecMatchEvaluation {
|
||||
const details: ConsignmentSpecMatchDetail[] = []
|
||||
const conflicts: string[] = []
|
||||
const unknowns: string[] = []
|
||||
let partial = false
|
||||
|
||||
for (const field of input.specs) {
|
||||
if (field.matchMode === 'ignore') {
|
||||
details.push({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
orderValue: String(input.orderValues[field.key] ?? ''),
|
||||
inventoryValue: String(input.inventoryValues[field.key] ?? ''),
|
||||
status: 'ignored',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const orderRaw = String(input.orderValues[field.key] ?? '').trim()
|
||||
const inventoryRaw = String(input.inventoryValues[field.key] ?? '').trim()
|
||||
const detailBase = {
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
orderValue: orderRaw,
|
||||
inventoryValue: inventoryRaw,
|
||||
}
|
||||
|
||||
if (!orderRaw) {
|
||||
unknowns.push(field.label)
|
||||
details.push({ ...detailBase, status: 'unknown' })
|
||||
continue
|
||||
}
|
||||
if (!inventoryRaw) {
|
||||
conflicts.push(`${field.label}(库存未填写)`)
|
||||
details.push({ ...detailBase, status: 'conflict' })
|
||||
continue
|
||||
}
|
||||
|
||||
const wildcardHit =
|
||||
(field.matchMode === 'wildcard' || field.wildcardValues.length > 0) &&
|
||||
(field.wildcardValues.some(
|
||||
(item) => normalizeComparable(field, item) === normalizeComparable(field, orderRaw),
|
||||
) ||
|
||||
field.wildcardValues.some(
|
||||
(item) => normalizeComparable(field, item) === normalizeComparable(field, inventoryRaw),
|
||||
))
|
||||
if (wildcardHit) {
|
||||
details.push({ ...detailBase, status: 'match' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (isNumericSpecField(field)) {
|
||||
const orderNumber = Number(orderRaw)
|
||||
const inventoryNumber = Number(inventoryRaw)
|
||||
if (!Number.isFinite(orderNumber) || !Number.isFinite(inventoryNumber)) {
|
||||
if (normalizeComparable(field, orderRaw) === normalizeComparable(field, inventoryRaw)) {
|
||||
details.push({ ...detailBase, status: 'match' })
|
||||
} else {
|
||||
conflicts.push(field.label)
|
||||
details.push({ ...detailBase, status: 'conflict' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (field.matchMode === 'minimum') {
|
||||
if (inventoryNumber < orderNumber) {
|
||||
conflicts.push(field.label)
|
||||
details.push({ ...detailBase, status: 'conflict' })
|
||||
} else if (inventoryNumber > orderNumber) {
|
||||
partial = true
|
||||
details.push({ ...detailBase, status: 'partial' })
|
||||
} else {
|
||||
details.push({ ...detailBase, status: 'match' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (inventoryNumber !== orderNumber) {
|
||||
conflicts.push(field.label)
|
||||
details.push({ ...detailBase, status: 'conflict' })
|
||||
} else {
|
||||
details.push({ ...detailBase, status: 'match' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (normalizeComparable(field, orderRaw) !== normalizeComparable(field, inventoryRaw)) {
|
||||
conflicts.push(field.label)
|
||||
details.push({ ...detailBase, status: 'conflict' })
|
||||
} else {
|
||||
details.push({ ...detailBase, status: 'match' })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exact: conflicts.length === 0 && unknowns.length === 0 && !partial,
|
||||
partial: conflicts.length === 0 && unknowns.length === 0 && partial,
|
||||
unknown: unknowns.length > 0,
|
||||
conflicts,
|
||||
unknowns,
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧列同步:key 为 system/serverZone/series/diamondLevel/brillianceCount 时取对应值。 */
|
||||
export function resolveLegacyConsignmentColumns(values: ConsignmentSpecValues): {
|
||||
system: string
|
||||
serverZone: string
|
||||
series: string
|
||||
diamondLevel: number
|
||||
brillianceCount: number
|
||||
} {
|
||||
return {
|
||||
system: String(values.system ?? '').trim(),
|
||||
serverZone: String(values.serverZone ?? '').trim(),
|
||||
series: String(values.series ?? '').trim(),
|
||||
diamondLevel: Math.max(1, Math.round(Number(values.diamondLevel || 1))),
|
||||
brillianceCount: Math.max(0, Math.round(Number(values.brillianceCount || 0))),
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅用于旧匹配器兼容判断:字段 key 是否属于历史五字段。 */
|
||||
export function isBuiltInConsignmentSpecField(field: ConsignmentSpecField): boolean {
|
||||
return isBuiltInSpecKey(field.key)
|
||||
}
|
||||
|
||||
/** 把数据库里的 spec_values_json 解析成可用的规格值对象。 */
|
||||
export function parseConsignmentSpecValues(value: unknown): ConsignmentSpecValues {
|
||||
const source = record(value)
|
||||
const result: ConsignmentSpecValues = {}
|
||||
for (const [key, raw] of Object.entries(source)) {
|
||||
if (typeof raw === 'string' || typeof raw === 'number') result[key] = raw
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -26,6 +26,18 @@ import {
|
||||
resolvePriceDropSuggestion,
|
||||
} from './consignment-profile-service.js'
|
||||
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_PRICING,
|
||||
buildConsignmentItemName,
|
||||
buildConsignmentSpecKey,
|
||||
buildConsignmentSpecText,
|
||||
computeConsignmentPrice,
|
||||
normalizeConsignmentSpecValues,
|
||||
resolveLegacyConsignmentColumns,
|
||||
resolveWorkerConsignmentPrice,
|
||||
type ConsignmentSpecField,
|
||||
type ConsignmentSpecValues,
|
||||
} from './consignment-spec-service.js'
|
||||
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -64,6 +76,16 @@ function mapConsignmentContactSupporter(source: {
|
||||
}
|
||||
}
|
||||
|
||||
function parseSpecValues(value: unknown): ConsignmentSpecValues {
|
||||
const source = toRecord(value)
|
||||
if (!source) return {}
|
||||
const result: ConsignmentSpecValues = {}
|
||||
for (const [key, raw] of Object.entries(source)) {
|
||||
if (typeof raw === 'string' || typeof raw === 'number') result[key] = raw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) {
|
||||
if (!boundWo) {
|
||||
return {
|
||||
@@ -198,7 +220,30 @@ export async function updateWorkerConsignmentSalePrice(
|
||||
errorCode: 'consignment_inventory_unavailable',
|
||||
})
|
||||
}
|
||||
const profileConfig = inventory.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json)
|
||||
: null
|
||||
if (profileConfig && !profileConfig.pricing.allowWorkerPrice) {
|
||||
throw createHttpError('当前寄卖配置不允许打手修改售价,请联系客服', {
|
||||
statusCode: 403,
|
||||
errorCode: 'consignment_price_edit_forbidden',
|
||||
})
|
||||
}
|
||||
const priceFen = Math.max(100, Math.round(Number(priceFenInput || 0)))
|
||||
const capFen = Math.max(0, Math.round(Number(inventory.price_cap_fen || 0)))
|
||||
if (capFen > 0 && priceFen > capFen) {
|
||||
throw createHttpError(`寄售价格不能高于系统建议价 ¥${(capFen / 100).toFixed(2)}`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_price_above_cap',
|
||||
})
|
||||
}
|
||||
const minPriceFen = Math.max(0, Number(profileConfig?.pricing.minPriceFen || 0))
|
||||
if (priceFen < minPriceFen) {
|
||||
throw createHttpError(`寄售价格不能少于 ¥${(minPriceFen / 100).toFixed(2)}`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_price_below_min',
|
||||
})
|
||||
}
|
||||
const updated = await updateConsignmentInventory(inventory.id, {
|
||||
salePriceFen: priceFen,
|
||||
now: nowIso(),
|
||||
@@ -254,58 +299,33 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
}
|
||||
}
|
||||
|
||||
const system = String(payload.system || '').trim()
|
||||
const serverZone = String(payload.serverZone || '').trim()
|
||||
const series = String(payload.series || '').trim()
|
||||
if (!system || !serverZone || !series) {
|
||||
throw createHttpError('请完整填写操作系统、区服与枪系', {
|
||||
const specs = profile?.config.specs ?? []
|
||||
const specValidation = normalizeConsignmentSpecValues(specs, payload)
|
||||
if (specValidation.errors.length > 0) {
|
||||
throw createHttpError(specValidation.errors[0]!, {
|
||||
statusCode: 400,
|
||||
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 ||
|
||||
payload.diamondLevel === null ||
|
||||
payload.diamondLevel === ''
|
||||
) {
|
||||
throw createHttpError('请选择钻石等级', {
|
||||
statusCode: 400,
|
||||
errorCode: 'diamond_level_required',
|
||||
})
|
||||
}
|
||||
const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel)))
|
||||
if (
|
||||
payload.brillianceCount === undefined ||
|
||||
payload.brillianceCount === null ||
|
||||
payload.brillianceCount === ''
|
||||
) {
|
||||
throw createHttpError('请选择璀璨数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'brilliance_required',
|
||||
})
|
||||
}
|
||||
const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount)))
|
||||
if (brillianceCount > diamondLevel) {
|
||||
throw createHttpError('璀璨数不能超过钻石等级(如1钻最多1璀、2钻最多2璀)', {
|
||||
statusCode: 400,
|
||||
errorCode: 'brilliance_exceeds_diamond',
|
||||
errorCode: 'consignment_specs_invalid',
|
||||
})
|
||||
}
|
||||
// 配置没有字段时仍兼容旧版平铺参数(老客户端不带 profileId/specValues)。
|
||||
const specValues: ConsignmentSpecValues =
|
||||
specs.length > 0
|
||||
? specValidation.values
|
||||
: {
|
||||
...(String(payload.system || '').trim() ? { system: String(payload.system).trim() } : {}),
|
||||
...(String(payload.serverZone || '').trim()
|
||||
? { serverZone: String(payload.serverZone).trim() }
|
||||
: {}),
|
||||
...(String(payload.series || '').trim() ? { series: String(payload.series).trim() } : {}),
|
||||
...(payload.diamondLevel !== undefined && String(payload.diamondLevel).trim() !== ''
|
||||
? { diamondLevel: Math.round(Number(payload.diamondLevel)) }
|
||||
: {}),
|
||||
...(payload.brillianceCount !== undefined && String(payload.brillianceCount).trim() !== ''
|
||||
? { brillianceCount: Math.max(0, Math.round(Number(payload.brillianceCount))) }
|
||||
: {}),
|
||||
}
|
||||
const { system, serverZone, series, diamondLevel, brillianceCount } =
|
||||
resolveLegacyConsignmentColumns(specValues)
|
||||
|
||||
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
|
||||
if (!contactInfo) {
|
||||
@@ -338,40 +358,46 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
})
|
||||
}
|
||||
|
||||
// 寄售价:配置开启「模板价」时以接单模板为准;允许打手改价时以打手填写价为准
|
||||
// (不低于配置的最低价)。未命中配置的存量调用保持旧行为。
|
||||
// 寄售价:按配置算出系统建议价(模板折扣价 / 固定价),打手只能往下调。
|
||||
// 没有建议价时按 missingPricePolicy 处理(manual = 允许打手手填,仍受最低价约束)。
|
||||
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
|
||||
system,
|
||||
series,
|
||||
diamondLevel,
|
||||
brillianceCount,
|
||||
profileId: profile?.profileId,
|
||||
specs,
|
||||
values: specValues,
|
||||
})
|
||||
const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0)))
|
||||
const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING
|
||||
const submittedPriceFen = Math.max(
|
||||
0,
|
||||
Math.round(Number(payload.askingPriceFen ?? payload.salePriceFen ?? 0)),
|
||||
)
|
||||
let askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen
|
||||
if (profile) {
|
||||
const minPriceFen = Math.max(0, profile.config.pricing.minPriceFen)
|
||||
if (profile.config.pricing.allowWorkerPrice && submittedPriceFen > 0) {
|
||||
askingPriceFen = submittedPriceFen
|
||||
} else if (!profile.config.pricing.useTemplatePrice && submittedPriceFen > 0) {
|
||||
askingPriceFen = submittedPriceFen
|
||||
}
|
||||
askingPriceFen = Math.max(minPriceFen, askingPriceFen)
|
||||
const priceResolution = resolveWorkerConsignmentPrice({
|
||||
pricing,
|
||||
templatePriceFen: priceSuggestion.templatePriceFen,
|
||||
submittedPriceFen,
|
||||
})
|
||||
if (priceResolution.errors.length > 0) {
|
||||
throw createHttpError(priceResolution.errors[0]!, {
|
||||
statusCode: 400,
|
||||
errorCode: 'consignment_price_invalid',
|
||||
})
|
||||
}
|
||||
askingPriceFen = Math.max(100, askingPriceFen)
|
||||
const askingPriceFen = priceResolution.priceFen
|
||||
const priceCapFen = priceResolution.capFen
|
||||
|
||||
const depositAmountFen = profile?.config.deposit.enabled
|
||||
? buildDepositAmountFen(profile.config, quantity)
|
||||
: getConsignmentPublishDepositFen()
|
||||
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
|
||||
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
|
||||
// 区服是「互通」(通配)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
|
||||
const zoneLabel = serverZone === '互通' ? '' : serverZone
|
||||
const defaultItemName = `${system}${zoneLabel}${series}${diamondLevel}钻${briStr} 苏尔南`
|
||||
const defaultItemName = buildConsignmentItemName({
|
||||
template: profile?.config.nameTemplate || '',
|
||||
specs,
|
||||
values: specValues,
|
||||
categoryName: profile?.categoryName || '',
|
||||
fallbackSuffix: '苏尔南',
|
||||
})
|
||||
const itemName = String(payload.itemName || defaultItemName).trim()
|
||||
const specKey = buildConsignmentSpecKey(specs, specValues)
|
||||
const specText = buildConsignmentSpecText(specs, specValues)
|
||||
const submissionNo = generateConsignmentSubmissionNo()
|
||||
const now = nowIso()
|
||||
|
||||
@@ -401,6 +427,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
profileVersionId: profile?.versionId ?? null,
|
||||
quantity,
|
||||
salePriceFen: askingPriceFen,
|
||||
priceCapFen,
|
||||
specValues,
|
||||
specText,
|
||||
},
|
||||
screenshotsJson: screenshots,
|
||||
askingPriceFen,
|
||||
@@ -412,11 +441,16 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
? {
|
||||
profileId: profile.profileId,
|
||||
categoryId: profile.categoryId,
|
||||
categoryName: profile.categoryName,
|
||||
versionId: profile.versionId,
|
||||
versionNo: profile.versionNo,
|
||||
config: profile.config,
|
||||
}
|
||||
: null,
|
||||
specValuesJson: specValues,
|
||||
specKey,
|
||||
specText,
|
||||
priceCapFen,
|
||||
workerId: Number(worker.id),
|
||||
workerName: worker.display_name || worker.username,
|
||||
workerPhone: sellerPhone || worker.phone || '',
|
||||
@@ -450,6 +484,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
brillianceCount: submission.brilliance_count,
|
||||
askingPriceFen: submission.asking_price_fen,
|
||||
salePriceFen: Number(submission.requested_sale_price_fen || submission.asking_price_fen || 0),
|
||||
priceCapFen: Number(submission.price_cap_fen || 0),
|
||||
specValues,
|
||||
specText,
|
||||
quantity: Number(submission.quantity || 1),
|
||||
profileId: submission.profile_id ? Number(submission.profile_id) : null,
|
||||
profileVersionId: submission.profile_version_id
|
||||
@@ -574,6 +611,9 @@ export async function listWorkerConsignments(
|
||||
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
|
||||
// 现货上的对接客服优先级更高:客服可能在审核入库后改派对接人,卖家端应看到最新归属。
|
||||
const contactSupporter = inv?.contact_supporter_id ? inv : sub
|
||||
const profileConfig = inv?.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(inv.profile_snapshot_json)
|
||||
: null
|
||||
return {
|
||||
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
|
||||
consignmentNo: sub.submission_no,
|
||||
@@ -586,18 +626,21 @@ export async function listWorkerConsignments(
|
||||
series: sub.series,
|
||||
diamondLevel: sub.diamond_level,
|
||||
brillianceCount: sub.brilliance_count,
|
||||
specValues: parseSpecValues(inv?.spec_values_json ?? sub.spec_values_json),
|
||||
specText: inv?.spec_text || sub.spec_text || '',
|
||||
askingPriceFen: sub.asking_price_fen,
|
||||
salePriceFen: Number(
|
||||
inv?.sale_price_fen ?? sub.requested_sale_price_fen ?? sub.asking_price_fen ?? 0,
|
||||
),
|
||||
priceCapFen: Number(inv?.price_cap_fen ?? sub.price_cap_fen ?? 0),
|
||||
quantity: Math.max(1, Number(sub.quantity || inv?.quantity || 1)),
|
||||
remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0,
|
||||
listedAt: inv?.listed_at || null,
|
||||
canEditPrice: inv?.status === 'in_stock',
|
||||
priceDropSuggestion: inv?.profile_snapshot_json
|
||||
? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), {
|
||||
listedAt: inv.listed_at || inv.inbound_at,
|
||||
salePriceFen: Number(inv.sale_price_fen || 0),
|
||||
canEditPrice: inv?.status === 'in_stock' && profileConfig?.pricing.allowWorkerPrice !== false,
|
||||
priceDropSuggestion: profileConfig
|
||||
? resolvePriceDropSuggestion(profileConfig, {
|
||||
listedAt: inv?.listed_at || inv?.inbound_at || '',
|
||||
salePriceFen: Number(inv?.sale_price_fen || 0),
|
||||
now: nowIso(),
|
||||
})
|
||||
: null,
|
||||
@@ -631,6 +674,9 @@ export async function listWorkerConsignments(
|
||||
const evidence = showingMatchedBuyer
|
||||
? extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
|
||||
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
|
||||
const profileConfig = inv.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(inv.profile_snapshot_json)
|
||||
: null
|
||||
return {
|
||||
consignmentNo: '客服手工入库',
|
||||
...(stage.canConfirmFriend || stage.canSubmitEvidence
|
||||
@@ -645,14 +691,17 @@ export async function listWorkerConsignments(
|
||||
series: inv.series,
|
||||
diamondLevel: inv.diamond_level,
|
||||
brillianceCount: inv.brilliance_count,
|
||||
specValues: parseSpecValues(inv.spec_values_json),
|
||||
specText: inv.spec_text || '',
|
||||
askingPriceFen: inv.cost_price_fen,
|
||||
salePriceFen: Number(inv.sale_price_fen || inv.cost_price_fen || 0),
|
||||
priceCapFen: Number(inv.price_cap_fen || 0),
|
||||
quantity: Math.max(1, Number(inv.quantity || 1)),
|
||||
remainingQuantity: Math.max(0, Number(inv.remaining_quantity ?? 0)),
|
||||
listedAt: inv.listed_at || inv.inbound_at || null,
|
||||
canEditPrice: inv.status === 'in_stock',
|
||||
priceDropSuggestion: inv.profile_snapshot_json
|
||||
? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), {
|
||||
canEditPrice: inv.status === 'in_stock' && profileConfig?.pricing.allowWorkerPrice !== false,
|
||||
priceDropSuggestion: profileConfig
|
||||
? resolvePriceDropSuggestion(profileConfig, {
|
||||
listedAt: inv.listed_at || inv.inbound_at,
|
||||
salePriceFen: Number(inv.sale_price_fen || 0),
|
||||
now: nowIso(),
|
||||
@@ -774,45 +823,76 @@ export async function submitWorkerConsignmentGiftEvidence(
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布寄卖的建议价:按 规格(系统/系列/钻级)从「接单模板」里带出价格,
|
||||
* 例如模板里有「安卓暗潮1钻 ¥120」,卖家选到 安卓+暗潮+1钻 就自动带出 120。
|
||||
* 找不到匹配的模板(本地没配、或规格没配价)就返回 null,前端保持手填。
|
||||
* 发布寄卖的建议价:
|
||||
* - 模板折扣:接单模板价 × 折扣;
|
||||
* - 固定价格:直接使用固定价,有模板价时校验不得高于模板价;
|
||||
* - 同时返回改价上限(等于建议价,打手只能往下调)。
|
||||
*/
|
||||
export async function getWorkerConsignmentPriceSuggestion(payload: {
|
||||
profileId?: unknown
|
||||
/** 保留旧参数,价格只依赖系统/系列/钻级/璀璨 */
|
||||
specs?: ConsignmentSpecField[]
|
||||
values?: ConsignmentSpecValues
|
||||
/** 旧调用兼容:平铺参数 */
|
||||
system?: unknown
|
||||
series?: unknown
|
||||
diamondLevel?: unknown
|
||||
brillianceCount?: unknown
|
||||
}): Promise<{
|
||||
suggestedPriceFen: number | null
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
source: 'template' | 'fixed' | 'none'
|
||||
matchedCount: number
|
||||
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
|
||||
}> {
|
||||
const system = String(payload.system || '').trim()
|
||||
const series = String(payload.series || '').trim()
|
||||
const diamondLevel = Math.round(Number(payload.diamondLevel || 0))
|
||||
const profileIdInput = Number(payload.profileId ?? 0)
|
||||
const profile =
|
||||
Number.isInteger(profileIdInput) && profileIdInput > 0
|
||||
? await getPublishedConsignmentProfileById(profileIdInput)
|
||||
: null
|
||||
const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING
|
||||
const values: ConsignmentSpecValues = { ...(payload.values || {}) }
|
||||
if (values.system === undefined && payload.system !== undefined)
|
||||
values.system = String(payload.system || '').trim()
|
||||
if (values.series === undefined && payload.series !== undefined)
|
||||
values.series = String(payload.series || '').trim()
|
||||
if (values.diamondLevel === undefined && payload.diamondLevel !== undefined) {
|
||||
values.diamondLevel = Math.round(Number(payload.diamondLevel || 0))
|
||||
}
|
||||
if (values.brillianceCount === undefined && payload.brillianceCount !== undefined) {
|
||||
values.brillianceCount = Math.max(0, Math.round(Number(payload.brillianceCount || 0)))
|
||||
}
|
||||
|
||||
const system = String(values.system || '').trim()
|
||||
const series = String(values.series || '').trim()
|
||||
const diamondLevel = Math.round(Number(values.diamondLevel || 0))
|
||||
const brillianceCount =
|
||||
payload.brillianceCount !== undefined &&
|
||||
payload.brillianceCount !== null &&
|
||||
String(payload.brillianceCount).trim() !== ''
|
||||
? Math.max(0, Math.round(Number(payload.brillianceCount || 0)))
|
||||
values.brillianceCount !== undefined && String(values.brillianceCount).trim() !== ''
|
||||
? Math.max(0, Math.round(Number(values.brillianceCount || 0)))
|
||||
: undefined
|
||||
if (!system || !series || diamondLevel <= 0) {
|
||||
return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] }
|
||||
}
|
||||
const rules = await listEnabledWorkProductRulesForSuggestion()
|
||||
const suggestion = suggestConsignmentPriceFromRules(rules, {
|
||||
system,
|
||||
series,
|
||||
diamondLevel,
|
||||
brillianceCount,
|
||||
})
|
||||
if (!suggestion) {
|
||||
return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] }
|
||||
|
||||
let templatePriceFen = 0
|
||||
let matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] = []
|
||||
if (system && series && diamondLevel > 0) {
|
||||
const rules = await listEnabledWorkProductRulesForSuggestion()
|
||||
const suggestion = suggestConsignmentPriceFromRules(rules, {
|
||||
system,
|
||||
series,
|
||||
diamondLevel,
|
||||
brillianceCount,
|
||||
})
|
||||
templatePriceFen = Math.max(0, Math.round(Number(suggestion?.suggestedPriceFen || 0)))
|
||||
matchedRules = suggestion?.matchedRules || []
|
||||
}
|
||||
|
||||
const computed = computeConsignmentPrice({ pricing, templatePriceFen })
|
||||
return {
|
||||
suggestedPriceFen: suggestion.suggestedPriceFen,
|
||||
matchedCount: suggestion.matchedRules.length,
|
||||
matchedRules: suggestion.matchedRules,
|
||||
suggestedPriceFen: computed.priceFen > 0 ? computed.priceFen : null,
|
||||
capFen: computed.capFen,
|
||||
templatePriceFen: computed.templatePriceFen,
|
||||
source: computed.source,
|
||||
matchedCount: matchedRules.length,
|
||||
matchedRules,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App, Alert, Button, Card, Empty, Modal, Space, Tag, Typography } from 'antd'
|
||||
import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
const { message } = App.useApp()
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [nonExactReason, setNonExactReason] = useState('')
|
||||
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ['admin-consignment-match-candidates', workOrderId],
|
||||
@@ -40,13 +41,24 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedIds([])
|
||||
setNonExactReason('')
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
// 默认按缺口选中价格最低的几条,客服可自行调整。
|
||||
setSelectedIds(options.slice(0, needed).map((item) => item.inventoryId))
|
||||
// 默认只选精确匹配;非精确库存必须人工勾选并填写原因。
|
||||
const exactOptions = options.filter((item) => (item.matchType || 'exact') === 'exact')
|
||||
setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId))
|
||||
}, [open, options, needed])
|
||||
|
||||
const nonExactSelected = useMemo(
|
||||
() =>
|
||||
selectedIds.some((id) => {
|
||||
const item = options.find((option) => option.inventoryId === id)
|
||||
return Boolean(item?.matchType && item.matchType !== 'exact')
|
||||
}),
|
||||
[options, selectedIds],
|
||||
)
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() =>
|
||||
selectedIds.reduce((sum, id) => {
|
||||
@@ -69,7 +81,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={selectedIds.length === 0}
|
||||
disabled={selectedIds.length === 0 || (nonExactSelected && !nonExactReason.trim())}
|
||||
onClick={async () => {
|
||||
if (!workOrderId) return
|
||||
setSubmitting(true)
|
||||
@@ -77,6 +89,8 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
const result = await matchAdminConsignmentWorkOrder({
|
||||
workOrderId,
|
||||
inventoryIds: selectedIds,
|
||||
allowNonExact: nonExactSelected,
|
||||
reason: nonExactSelected ? nonExactReason.trim() : undefined,
|
||||
})
|
||||
message.success(
|
||||
result.data?.matched
|
||||
@@ -99,7 +113,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
>
|
||||
{candidatesQuery.isLoading ? <Card loading /> : null}
|
||||
{!candidatesQuery.isLoading && options.length === 0 ? (
|
||||
<Empty description="没有精确匹配的在库寄卖库存" />
|
||||
<Empty description="没有同配置的在库寄卖库存" />
|
||||
) : null}
|
||||
{options.length > 0 ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
@@ -107,7 +121,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
type="info"
|
||||
showIcon
|
||||
message={`该工单${data?.sharingEnabled ? `拼单 ${data.totalQuantity} 份,已匹配 ${data.activeQuantity} 份` : '为整单'},本次还需 ${needed} 条库存。`}
|
||||
description="自动匹配只做精确规格;手动匹配可选多条,拼单工单每选一条生成一个份额。"
|
||||
description="自动匹配只做精确规格;手动匹配可选多条,拼单工单每选一条生成一个份额。选择非精确库存必须填写原因。"
|
||||
/>
|
||||
<PercentTable<ConsignmentMatchCandidate>
|
||||
rowKey="inventoryId"
|
||||
@@ -133,8 +147,30 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
{
|
||||
title: '规格',
|
||||
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: '库存',
|
||||
width: 90,
|
||||
@@ -151,6 +187,21 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{nonExactSelected ? (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="已选择非精确库存:请确认规格差异,并填写人工匹配原因。"
|
||||
/>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={nonExactReason}
|
||||
onChange={(event) => setNonExactReason(event.target.value)}
|
||||
placeholder="例如:订单规格未写全,客服已与买家确认可用该库存"
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
<Typography.Text type="secondary">
|
||||
已选 {selectedIds.length} 条,合计成本 {money(totalPrice)}
|
||||
</Typography.Text>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
@@ -270,6 +271,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
values.salePrice !== undefined
|
||||
? Math.max(0, Math.round(Number(values.salePrice) * 100))
|
||||
: undefined,
|
||||
specValues:
|
||||
values.specValues && Object.keys(values.specValues).length
|
||||
? values.specValues
|
||||
: undefined,
|
||||
buyerName: values.buyerName,
|
||||
shopName: values.shopName,
|
||||
inboundBatch: values.inboundBatch,
|
||||
@@ -349,6 +354,11 @@ export default function ConsignmentInventoryPanel() {
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{record.itemName}</div>
|
||||
{record.specText ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.specText}
|
||||
</Text>
|
||||
) : null}
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
寄卖单号
|
||||
@@ -666,6 +676,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
setEditingItem(record)
|
||||
editForm.setFieldsValue({
|
||||
...record,
|
||||
specValues:
|
||||
record.specValues && Object.keys(record.specValues).length
|
||||
? record.specValues
|
||||
: undefined,
|
||||
costPrice: record.costPriceFen / 100,
|
||||
salePrice: (record.salePriceFen ?? record.costPriceFen) / 100,
|
||||
workerId: record.workerId || undefined,
|
||||
@@ -961,6 +975,46 @@ export default function ConsignmentInventoryPanel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{editingItem?.specs?.length ? (
|
||||
<>
|
||||
<Divider plain>动态规格</Divider>
|
||||
<Row gutter={16}>
|
||||
{editingItem.specs.map((field) => (
|
||||
<Col span={8} key={field.key}>
|
||||
<Form.Item name={['specValues', field.key]} label={field.label}>
|
||||
{field.type === 'select' || field.type === 'radio' ? (
|
||||
field.type === 'radio' ? (
|
||||
<Radio.Group
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
)
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
) : field.type === 'date' ? (
|
||||
<Input type="date" />
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
placeholder={field.placeholder || undefined}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={field.placeholder || undefined} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="buyerName" label="收购人">
|
||||
|
||||
@@ -166,6 +166,10 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
series: values.series,
|
||||
diamondLevel: Number(values.diamondLevel || 1),
|
||||
brillianceCount: Number(values.brillianceCount || 0),
|
||||
specValues:
|
||||
values.specValues && Object.keys(values.specValues).length
|
||||
? values.specValues
|
||||
: undefined,
|
||||
sellerContact: values.sellerContact,
|
||||
sellerGameId: values.sellerGameId,
|
||||
sellerPaymentInfo: values.sellerPaymentInfo,
|
||||
@@ -375,6 +379,10 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
setApproveModalItem(record)
|
||||
approveForm.setFieldsValue({
|
||||
...record,
|
||||
specValues:
|
||||
record.specValues && Object.keys(record.specValues).length
|
||||
? record.specValues
|
||||
: undefined,
|
||||
costPrice: record.askingPriceFen / 100,
|
||||
sellerContact: record.contactInfo,
|
||||
buyerName: '客服',
|
||||
@@ -667,6 +675,47 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{approveModalItem?.specs?.length ? (
|
||||
<Row gutter={12}>
|
||||
{approveModalItem.specs.map((field) => (
|
||||
<Col span={8} key={field.key}>
|
||||
<Form.Item
|
||||
name={['specValues', field.key]}
|
||||
label={field.label}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{field.type === 'select' || field.type === 'radio' ? (
|
||||
field.type === 'radio' ? (
|
||||
<Radio.Group
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
)
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
) : field.type === 'date' ? (
|
||||
<Input type="date" />
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
placeholder={field.placeholder || undefined}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={field.placeholder || undefined} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{/* 分组二:履约与打手 */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
InboxOutlined,
|
||||
SearchOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Empty,
|
||||
Form,
|
||||
@@ -38,14 +38,14 @@ import {
|
||||
Typography,
|
||||
Spin,
|
||||
} from 'antd'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
|
||||
import {
|
||||
confirmWorkerConsignmentFriendAdded,
|
||||
fetchWorkerConsignmentContacts,
|
||||
fetchWorkerConsignmentPriceSuggestion,
|
||||
fetchWorkerConsignmentPricePreview,
|
||||
fetchWorkerConsignmentProfiles,
|
||||
fetchWorkerConsignments,
|
||||
fetchWorkerProfile,
|
||||
@@ -54,35 +54,19 @@ import {
|
||||
updateWorkerConsignmentPrice,
|
||||
} from '@/services/worker'
|
||||
import type { WorkerConsignmentRecord } from '@/services/worker'
|
||||
import type { WorkerConsignmentProfile } from '@/types/consignment'
|
||||
import type {
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecValues,
|
||||
WorkerConsignmentProfile,
|
||||
} from '@/types/consignment'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import {
|
||||
SURNAN_DEFAULT_ZONE,
|
||||
SURNAN_PUBLISH_SYSTEM_OPTIONS,
|
||||
SURNAN_PUBLISH_ZONE_OPTIONS,
|
||||
} from '@/utils/consignment-spec'
|
||||
import { buildSpecItemName } from '@/utils/consignment-spec'
|
||||
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
|
||||
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
|
||||
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
|
||||
const SERIES_OPTIONS = [
|
||||
{ label: '暗潮', value: '暗潮' },
|
||||
{ label: '怒嚎', value: '怒嚎' },
|
||||
{ label: '血宴', value: '血宴' },
|
||||
]
|
||||
|
||||
const DIAMOND_OPTIONS = [1, 2, 3, 4, 5, 6, 7].map((v) => ({
|
||||
label: `${v} 钻`,
|
||||
value: v,
|
||||
}))
|
||||
|
||||
const BRILLIANCE_OPTIONS = [
|
||||
{ label: '无璀璨 (0)', value: 0 },
|
||||
...[1, 2, 3, 4, 5, 6, 7].map((v) => ({ label: `${v} 璀璨`, value: v })),
|
||||
]
|
||||
|
||||
type RecordFilter = 'all' | 'reviewing' | 'active' | 'finished'
|
||||
|
||||
const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen'
|
||||
@@ -176,11 +160,11 @@ export default function WorkerConsignmentPage() {
|
||||
const [cooldownNow, setCooldownNow] = useState(() => Date.now())
|
||||
const [form] = Form.useForm()
|
||||
const currentDiamondLevel = Form.useWatch('diamondLevel', form)
|
||||
// 寄售价:完全由后台「接单模板」决定(规格选齐后自动带出),卖家只读不可改。
|
||||
// 寄售价:按后台配置(模板折扣价 / 固定价)自动带出,打手只能往下调。
|
||||
const [priceState, setPriceState] = useState<
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'matched'; fen: number }
|
||||
| { status: 'matched'; fen: number; capFen: number }
|
||||
| { status: 'empty' }
|
||||
>({ status: 'idle' })
|
||||
const priceFetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -218,6 +202,7 @@ export default function WorkerConsignmentPage() {
|
||||
consignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
|
||||
consignmentProfiles[0] ||
|
||||
null
|
||||
const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? []
|
||||
useEffect(() => {
|
||||
if (selectedProfileId === null && consignmentProfiles.length > 0) {
|
||||
setSelectedProfileId(consignmentProfiles[0]!.profileId)
|
||||
@@ -262,9 +247,7 @@ export default function WorkerConsignmentPage() {
|
||||
2000,
|
||||
)
|
||||
const publishDepositYuan = publishDepositFen / 100
|
||||
const manualPriceAllowed =
|
||||
selectedProfile?.config.pricing.allowWorkerPrice === true ||
|
||||
(Boolean(selectedProfile) && !selectedProfile!.config.pricing.useTemplatePrice)
|
||||
const manualPriceAllowed = selectedProfile?.config.pricing.allowWorkerPrice !== false
|
||||
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
||||
const canPublish = availableAmount >= publishDepositFen
|
||||
const canConsign = canWorkerPublishConsignment({
|
||||
@@ -338,6 +321,24 @@ export default function WorkerConsignmentPage() {
|
||||
})
|
||||
}, [profileQuery.isLoading, modal, publishDepositYuan])
|
||||
|
||||
function collectSpecValues(): ConsignmentSpecValues {
|
||||
const values: ConsignmentSpecValues = {}
|
||||
for (const field of selectedSpecs) {
|
||||
const raw = form.getFieldValue(field.key)
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') continue
|
||||
if (
|
||||
field.type === 'date' &&
|
||||
typeof raw === 'object' &&
|
||||
typeof (raw as { format?: unknown }).format === 'function'
|
||||
) {
|
||||
values[field.key] = (raw as { format: (pattern: string) => string }).format('YYYY-MM-DD')
|
||||
continue
|
||||
}
|
||||
values[field.key] = typeof raw === 'number' ? raw : String(raw)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function handleFormValuesChange(changed: Record<string, unknown>) {
|
||||
// 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀)
|
||||
if (changed && changed.diamondLevel !== undefined) {
|
||||
@@ -347,28 +348,22 @@ export default function WorkerConsignmentPage() {
|
||||
form.setFieldsValue({ brillianceCount: newDia })
|
||||
}
|
||||
}
|
||||
const sys = form.getFieldValue('system')
|
||||
const zone = form.getFieldValue('serverZone')
|
||||
const ser = form.getFieldValue('series')
|
||||
const dia = form.getFieldValue('diamondLevel')
|
||||
const bri = form.getFieldValue('brillianceCount')
|
||||
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}${zoneLabel}${ser}${dia}钻${briStr} 苏尔南`,
|
||||
})
|
||||
}
|
||||
|
||||
// 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出寄售价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。
|
||||
// 价格只读、卖家不用填;只有规格真的变了才重新匹配(改联系方式等字段不会触发)。
|
||||
const specKey =
|
||||
sys && ser && dia != null && Number(dia) > 0 && bri != null
|
||||
? `${sys}|${ser}|${dia}|${bri}`
|
||||
: ''
|
||||
if (!specKey) {
|
||||
const specValues = collectSpecValues()
|
||||
const itemName = buildSpecItemName(
|
||||
selectedProfile?.config.nameTemplate || '',
|
||||
selectedSpecs,
|
||||
specValues,
|
||||
selectedProfile?.categoryName || '',
|
||||
)
|
||||
if (itemName) form.setFieldsValue({ itemName })
|
||||
|
||||
const missing = selectedSpecs.some(
|
||||
(field) =>
|
||||
field.required &&
|
||||
(specValues[field.key] === undefined || String(specValues[field.key]).trim() === ''),
|
||||
)
|
||||
if (missing) {
|
||||
if (lastPriceQueryKeyRef.current !== '') {
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setPriceState({ status: 'idle' })
|
||||
@@ -376,6 +371,8 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const specKey = JSON.stringify(specValues)
|
||||
if (specKey === lastPriceQueryKeyRef.current) return
|
||||
lastPriceQueryKeyRef.current = specKey
|
||||
setPriceState({ status: 'loading' })
|
||||
@@ -385,17 +382,18 @@ export default function WorkerConsignmentPage() {
|
||||
priceFetchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetchWorkerConsignmentPriceSuggestion({
|
||||
system: String(sys),
|
||||
series: String(ser),
|
||||
diamondLevel: Number(dia),
|
||||
brillianceCount: Number(bri),
|
||||
const res = await fetchWorkerConsignmentPricePreview({
|
||||
profileId: selectedProfile?.profileId,
|
||||
specValues,
|
||||
})
|
||||
if (lastPriceQueryKeyRef.current !== specKey) return
|
||||
const fen = Number(res.data?.suggestedPriceFen || 0)
|
||||
const capFen = Number(res.data?.capFen || 0)
|
||||
if (fen > 0) {
|
||||
setPriceState({ status: 'matched', fen })
|
||||
form.setFieldsValue({ askingPrice: fen / 100 })
|
||||
setPriceState({ status: 'matched', fen, capFen: capFen > 0 ? capFen : fen })
|
||||
if (manualPriceAllowed) {
|
||||
form.setFieldsValue({ askingPrice: fen / 100 })
|
||||
}
|
||||
} else {
|
||||
setPriceState({ status: 'empty' })
|
||||
}
|
||||
@@ -434,22 +432,34 @@ export default function WorkerConsignmentPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// 寄售价:配置允许改价/不使用模板价时按打手填写;否则必须匹配到接单模板价。
|
||||
// 寄售价:按后台配置带出系统建议价;允许改价时可下调,但不能高于建议价。
|
||||
const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0)
|
||||
let askingPriceFen = priceState.status === 'matched' ? priceState.fen : 0
|
||||
const suggestedPriceFen = priceState.status === 'matched' ? priceState.fen : 0
|
||||
const capPriceFen = priceState.status === 'matched' ? priceState.capFen : 0
|
||||
let askingPriceFen = suggestedPriceFen
|
||||
if (manualPriceAllowed) {
|
||||
askingPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
|
||||
if (askingPriceFen < Math.max(100, minPriceFen)) {
|
||||
message.error(`寄售价格不能少于 ¥${(Math.max(100, minPriceFen) / 100).toFixed(2)}`)
|
||||
const submittedPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
|
||||
if (submittedPriceFen > 0) {
|
||||
if (capPriceFen > 0 && submittedPriceFen > capPriceFen) {
|
||||
message.error(`寄售价格不能高于系统建议价 ¥${(capPriceFen / 100).toFixed(2)}`)
|
||||
return
|
||||
}
|
||||
if (submittedPriceFen < Math.max(100, minPriceFen)) {
|
||||
message.error(`寄售价格不能少于 ¥${(Math.max(100, minPriceFen) / 100).toFixed(2)}`)
|
||||
return
|
||||
}
|
||||
askingPriceFen = submittedPriceFen
|
||||
} else if (suggestedPriceFen <= 0) {
|
||||
message.error('该规格暂无价格,请填写寄售价格')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (priceState.status === 'loading') {
|
||||
message.warning('正在匹配接单模板价,请稍候')
|
||||
message.warning('正在计算系统建议价,请稍候')
|
||||
return
|
||||
}
|
||||
if (priceState.status !== 'matched') {
|
||||
message.error('该规格暂无接单模板价,请联系客服补充模板后再提报')
|
||||
message.error('该规格暂无价格,请联系客服补充配置后再提报')
|
||||
return
|
||||
}
|
||||
if (askingPriceFen < 100) {
|
||||
@@ -458,11 +468,15 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const briNum = Number(values.brillianceCount || 0)
|
||||
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
|
||||
const specValues = collectSpecValues()
|
||||
const itemName =
|
||||
String(values.itemName || '').trim() ||
|
||||
`${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 苏尔南`
|
||||
buildSpecItemName(
|
||||
selectedProfile?.config.nameTemplate || '',
|
||||
selectedSpecs,
|
||||
specValues,
|
||||
selectedProfile?.categoryName || '',
|
||||
)
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
@@ -470,11 +484,12 @@ export default function WorkerConsignmentPage() {
|
||||
profileId: selectedProfile?.profileId,
|
||||
quantity: Math.max(1, Number(values.quantity || 1)),
|
||||
itemName,
|
||||
system: values.system,
|
||||
serverZone: values.serverZone,
|
||||
series: values.series,
|
||||
diamondLevel: Number(values.diamondLevel || 1),
|
||||
brillianceCount: Number(values.brillianceCount || 0),
|
||||
specValues,
|
||||
system: String(specValues.system || ''),
|
||||
serverZone: String(specValues.serverZone || ''),
|
||||
series: String(specValues.series || ''),
|
||||
diamondLevel: Number(specValues.diamondLevel || 1),
|
||||
brillianceCount: Number(specValues.brillianceCount || 0),
|
||||
askingPriceFen,
|
||||
sellerContact: values.sellerContact,
|
||||
sellerPhone: values.sellerPhone || worker?.phone || '',
|
||||
@@ -653,94 +668,105 @@ export default function WorkerConsignmentPage() {
|
||||
|
||||
<div className="worker-consignment-specs">
|
||||
<div className="worker-consignment-spec-grid">
|
||||
<Form.Item
|
||||
name="system"
|
||||
label="操作系统"
|
||||
rules={[{ required: true, message: '请选择系统' }]}
|
||||
className="worker-consignment-spec-card"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
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={[...SURNAN_PUBLISH_ZONE_OPTIONS]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="series"
|
||||
label="系列 / 枪系"
|
||||
rules={[{ required: true, message: '请选择枪系' }]}
|
||||
className="worker-consignment-spec-card"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={SERIES_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="diamondLevel"
|
||||
label="钻石等级"
|
||||
rules={[{ required: true, message: '请选择钻级' }]}
|
||||
className="worker-consignment-spec-card is-count"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips is-count"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={DIAMOND_OPTIONS.map((o) => ({
|
||||
label: `${o.value}钻`,
|
||||
value: o.value,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="brillianceCount"
|
||||
label="璀璨数"
|
||||
rules={[
|
||||
{ required: true, message: '请选择璀璨数' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const dia = getFieldValue('diamondLevel')
|
||||
if (value != null && dia != null && Number(value) > Number(dia)) {
|
||||
return Promise.reject(
|
||||
new Error(`璀璨数不能超过钻石等级(当前${dia}钻最多选${dia}璀)`),
|
||||
)
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
]}
|
||||
initialValue={0}
|
||||
className="worker-consignment-spec-card is-count"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips is-count"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={BRILLIANCE_OPTIONS.map((o) => ({
|
||||
label: o.value === 0 ? '无' : `${o.value}璀`,
|
||||
value: o.value,
|
||||
disabled: currentDiamondLevel != null && o.value > Number(currentDiamondLevel),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedSpecs.map((field) => {
|
||||
const isCountField = field.key === 'diamondLevel' || field.key === 'brillianceCount'
|
||||
const options = field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
disabled:
|
||||
field.key === 'brillianceCount' &&
|
||||
currentDiamondLevel != null &&
|
||||
Number(option.value) > Number(currentDiamondLevel),
|
||||
}))
|
||||
const defaultValue =
|
||||
field.key === 'serverZone'
|
||||
? options.some((option) => option.value === '互通')
|
||||
? '互通'
|
||||
: options.length === 1
|
||||
? options[0]?.value
|
||||
: undefined
|
||||
: field.key === 'brillianceCount'
|
||||
? 0
|
||||
: undefined
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
initialValue={defaultValue}
|
||||
extra={field.helpText || undefined}
|
||||
className={`worker-consignment-spec-card${isCountField ? ' is-count' : ''}`}
|
||||
rules={[
|
||||
...(field.required
|
||||
? [{ required: true, message: `请选择或填写${field.label}` }]
|
||||
: []),
|
||||
...(field.key === 'brillianceCount'
|
||||
? [
|
||||
({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({
|
||||
validator(_: unknown, value: unknown) {
|
||||
const diamond = getFieldValue('diamondLevel')
|
||||
if (
|
||||
value != null &&
|
||||
diamond != null &&
|
||||
Number(value) > Number(diamond)
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`璀璨数不能超过钻石等级(当前${diamond}钻最多选${diamond}璀)`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
{field.type === 'select' ? (
|
||||
<Select
|
||||
className="worker-consignment-select"
|
||||
options={options}
|
||||
placeholder={field.placeholder || `请选择${field.label}`}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
) : field.type === 'radio' ? (
|
||||
<Radio.Group
|
||||
className={`worker-consignment-chips${isCountField ? ' is-count' : ''}`}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={options}
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
placeholder={field.placeholder || undefined}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
placeholder={field.placeholder || undefined}
|
||||
maxLength={2000}
|
||||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||
allowClear
|
||||
/>
|
||||
) : field.type === 'date' ? (
|
||||
<DatePicker
|
||||
format="YYYY-MM-DD"
|
||||
placeholder={field.placeholder || '请选择日期'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={field.placeholder || undefined} maxLength={200} allowClear />
|
||||
)}
|
||||
</Form.Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="worker-consignment-price-grid">
|
||||
@@ -748,8 +774,8 @@ export default function WorkerConsignmentPage() {
|
||||
label="寄售价格"
|
||||
tooltip={
|
||||
manualPriceAllowed
|
||||
? '当前寄卖配置允许自定义售价,价格不得低于配置的最低价。'
|
||||
: '由后台「接单模板」按系统/系列/钻级/璀璨自动匹配,卖家无需填写;需要调整请联系客服。'
|
||||
? `默认按后台配置带出(模板折扣价 / 固定价),可下调,不能高于系统建议价${selectedProfile?.config.pricing.minPriceFen ? `;最低 ¥${(selectedProfile.config.pricing.minPriceFen / 100).toFixed(2)}` : ''}。`
|
||||
: '由后台寄卖配置自动带出,卖家不可修改;需要调整请联系客服。'
|
||||
}
|
||||
className="worker-consignment-spec-card is-price"
|
||||
>
|
||||
@@ -757,8 +783,14 @@ export default function WorkerConsignmentPage() {
|
||||
<Form.Item name="askingPrice" noStyle>
|
||||
<InputNumber
|
||||
min={Math.max(1, Number(selectedProfile?.config.pricing.minPriceFen || 0) / 100)}
|
||||
max={
|
||||
priceState.status === 'matched' && priceState.capFen > 0
|
||||
? priceState.capFen / 100
|
||||
: undefined
|
||||
}
|
||||
precision={2}
|
||||
addonBefore="¥"
|
||||
placeholder="选完规格后自动带出"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -766,12 +798,12 @@ export default function WorkerConsignmentPage() {
|
||||
<div className="worker-consignment-price-readonly">
|
||||
{priceState.status === 'loading' ? (
|
||||
<span className="is-pending">
|
||||
<Spin size="small" /> 匹配模板价…
|
||||
<Spin size="small" /> 计算建议价…
|
||||
</span>
|
||||
) : priceState.status === 'matched' ? (
|
||||
<span className="is-amount">¥{(priceState.fen / 100).toFixed(0)}</span>
|
||||
) : priceState.status === 'empty' ? (
|
||||
<span className="is-empty">该规格暂无模板价,请联系客服</span>
|
||||
<span className="is-empty">该规格暂无价格,请联系客服</span>
|
||||
) : (
|
||||
<span className="is-empty">选完规格后自动带出</span>
|
||||
)}
|
||||
@@ -1032,6 +1064,17 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
const profileId = Number(String(key).replace('publish-', ''))
|
||||
if (Number.isInteger(profileId) && profileId > 0) {
|
||||
if (profileId !== selectedProfileId) {
|
||||
const nextProfile = consignmentProfiles.find((item) => item.profileId === profileId)
|
||||
const nextSpecs = nextProfile?.config.specs ?? []
|
||||
const cleared: Record<string, undefined> = {}
|
||||
for (const field of nextSpecs) cleared[field.key] = undefined
|
||||
cleared.askingPrice = undefined
|
||||
cleared.itemName = undefined
|
||||
form.setFieldsValue(cleared)
|
||||
setPriceState({ status: 'idle' })
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
}
|
||||
setSelectedProfileId(profileId)
|
||||
}
|
||||
setActiveTab('publish')
|
||||
@@ -1474,13 +1517,19 @@ function ConsignmentRecordCard({
|
||||
</Typography.Text>
|
||||
<Tag color={record.color}>{record.label}</Tag>
|
||||
</div>
|
||||
<SpecTags
|
||||
system={record.system}
|
||||
serverZone={record.serverZone}
|
||||
series={record.series}
|
||||
diamondLevel={record.diamondLevel}
|
||||
brillianceCount={record.brillianceCount}
|
||||
/>
|
||||
{record.specText ? (
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-spec-text">
|
||||
{record.specText}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<SpecTags
|
||||
system={record.system}
|
||||
serverZone={record.serverZone}
|
||||
series={record.series}
|
||||
diamondLevel={record.diamondLevel}
|
||||
brillianceCount={record.brillianceCount}
|
||||
/>
|
||||
)}
|
||||
<div className="worker-consignment-record-meta">
|
||||
<span>
|
||||
寄售价{' '}
|
||||
@@ -1695,12 +1744,18 @@ function ConsignmentRecordCard({
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={record.priceCapFen && record.priceCapFen > 0 ? record.priceCapFen / 100 : undefined}
|
||||
precision={2}
|
||||
addonBefore="¥"
|
||||
value={priceYuan}
|
||||
onChange={(value) => setPriceYuan(Number(value || 0))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{record.priceCapFen && record.priceCapFen > 0 ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
|
||||
最高不能超过系统建议价 ¥{(record.priceCapFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Modal>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -54,6 +54,8 @@ export function fetchAdminConsignmentMatchCandidates(workOrderId: number | strin
|
||||
export function matchAdminConsignmentWorkOrder(payload: {
|
||||
workOrderId: number
|
||||
inventoryIds: number[]
|
||||
allowNonExact?: boolean
|
||||
reason?: string
|
||||
}) {
|
||||
return apiPost<{ matched: boolean; matchedCount: number }>(
|
||||
'/api/v1/admin/worker-platform/consignment/match',
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
ConsignmentBuyerInfo,
|
||||
ConsignmentContactSupporter,
|
||||
ConsignmentSellerInfo,
|
||||
ConsignmentSpecValues,
|
||||
WorkerConsignmentProfile,
|
||||
} from '@/types/consignment'
|
||||
|
||||
@@ -378,11 +379,13 @@ export type WorkerConsignmentPublishPayload = {
|
||||
profileId?: number
|
||||
quantity?: number
|
||||
itemName?: string
|
||||
system: string
|
||||
serverZone: string
|
||||
series: string
|
||||
diamondLevel: number
|
||||
brillianceCount: number
|
||||
/** 动态规格值(新链路);内置 5 字段仍可平铺传,旧调用兼容 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
system?: string
|
||||
serverZone?: string
|
||||
series?: string
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
askingPriceFen: number
|
||||
sellerContact: string
|
||||
sellerPhone?: string
|
||||
@@ -453,6 +456,11 @@ export type WorkerConsignmentRecord = {
|
||||
brillianceCount: number
|
||||
askingPriceFen: number
|
||||
salePriceFen?: number
|
||||
/** 打手改价上限(分);0 = 不限制 */
|
||||
priceCapFen?: number
|
||||
/** 动态规格值与展示文本 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
quantity?: number
|
||||
remainingQuantity?: number
|
||||
listedAt?: string | null
|
||||
@@ -574,6 +582,7 @@ export function submitWorkerConsignmentGiftEvidence(
|
||||
|
||||
/** 发布寄卖:按规格(系统/系列/钻级/璀璨)从「接单模板」带出建议价,如 安卓+暗潮+1钻+0璀 → ¥120 */
|
||||
export function fetchWorkerConsignmentPriceSuggestion(params: {
|
||||
profileId?: number
|
||||
system: string
|
||||
series: string
|
||||
diamondLevel: number
|
||||
@@ -581,11 +590,29 @@ export function fetchWorkerConsignmentPriceSuggestion(params: {
|
||||
}) {
|
||||
return apiGet<{
|
||||
suggestedPriceFen: number | null
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
source: 'template' | 'fixed' | 'none'
|
||||
matchedCount: number
|
||||
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
|
||||
}>('/api/v1/worker/consignment/price-suggestion', params)
|
||||
}
|
||||
|
||||
/** 动态规格发布:按 profileId + specValues 算价(模板折扣价 / 固定价)。 */
|
||||
export function fetchWorkerConsignmentPricePreview(payload: {
|
||||
profileId?: number
|
||||
specValues: ConsignmentSpecValues
|
||||
}) {
|
||||
return apiPost<{
|
||||
suggestedPriceFen: number | null
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
source: 'template' | 'fixed' | 'none'
|
||||
matchedCount: number
|
||||
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
|
||||
}>('/api/v1/worker/consignment/price-preview', payload)
|
||||
}
|
||||
|
||||
export type OrderServiceField = {
|
||||
key: string
|
||||
label: string
|
||||
|
||||
@@ -28,6 +28,31 @@ export type ConsignmentContactFields = {
|
||||
contactSupporterName?: string
|
||||
}
|
||||
|
||||
/** 动态规格值:key -> 选项值/数字/文本 */
|
||||
export type ConsignmentSpecValues = Record<string, string | number>
|
||||
|
||||
export type ConsignmentSpecOption = {
|
||||
label: string
|
||||
value: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
|
||||
|
||||
export type ConsignmentSpecField = {
|
||||
key: string
|
||||
label: string
|
||||
type: ConsignmentSpecFieldType
|
||||
required: boolean
|
||||
options: ConsignmentSpecOption[]
|
||||
placeholder: string
|
||||
helpText: string
|
||||
matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore'
|
||||
wildcardValues: string[]
|
||||
showInItemName: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。
|
||||
*
|
||||
@@ -98,6 +123,13 @@ export type ConsignmentInventory = ConsignmentEvidenceFields &
|
||||
salePriceFen?: number
|
||||
listedAt?: string | null
|
||||
priceUpdatedAt?: string | null
|
||||
/** 动态规格值与展示文本 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
/** 该库存快照对应的规格定义(旧库存为空,前端回退固定 5 项) */
|
||||
specs?: ConsignmentSpecField[]
|
||||
/** 打手改价上限(分);0 = 不限制 */
|
||||
priceCapFen?: number
|
||||
canEditPrice?: boolean
|
||||
priceDropSuggestion?: {
|
||||
suggestedPriceFen: number
|
||||
@@ -180,6 +212,11 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields &
|
||||
/** 分类级寄卖配置与数量/售价 */
|
||||
profileId?: number | null
|
||||
profileVersionId?: number | null
|
||||
/** 动态规格值与展示文本 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
specs?: ConsignmentSpecField[]
|
||||
priceCapFen?: number
|
||||
quantity?: number
|
||||
requestedSalePriceFen?: number
|
||||
workerId?: number | null
|
||||
@@ -307,7 +344,20 @@ export type ConsignmentProfileConfig = {
|
||||
brillianceCount: boolean
|
||||
quantity: boolean
|
||||
}
|
||||
pricing: { useTemplatePrice: boolean; allowWorkerPrice: boolean; minPriceFen: number }
|
||||
/** 动态规格字段(完全由运营配置,默认空) */
|
||||
specs: ConsignmentSpecField[]
|
||||
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
|
||||
nameTemplate: string
|
||||
pricing: {
|
||||
/** 接单模板价打折 / 固定价格 */
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
/** 几折,0.1 - 10,例如 9.5 */
|
||||
discount: number
|
||||
fixedPriceFen: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceFen: number
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
}
|
||||
deposit: {
|
||||
enabled: boolean
|
||||
mode: 'fixed' | 'per_quantity'
|
||||
@@ -332,6 +382,8 @@ export type ConsignmentProfileConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsignmentPricingConfig = ConsignmentProfileConfig['pricing']
|
||||
|
||||
export type ConsignmentProfile = {
|
||||
profileId: number
|
||||
categoryId: number
|
||||
@@ -381,6 +433,17 @@ export type ConsignmentMatchCandidate = {
|
||||
series: string
|
||||
diamondLevel: number
|
||||
brillianceCount: number
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
matchType?: 'exact' | 'partial' | 'conflict' | 'unknown'
|
||||
matchReasons?: string[]
|
||||
matchDetails?: Array<{
|
||||
key: string
|
||||
label: string
|
||||
orderValue: string
|
||||
inventoryValue: string
|
||||
status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored'
|
||||
}>
|
||||
quantity: number
|
||||
remainingQuantity: number
|
||||
salePriceFen: number
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type {
|
||||
ConsignmentPricingConfig,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecFieldType,
|
||||
ConsignmentSpecValues,
|
||||
} from '@/types/consignment'
|
||||
|
||||
/**
|
||||
* 寄卖规格选项(单一来源)。
|
||||
*
|
||||
* 两套口径:
|
||||
* 1. 后台全量(CONSIGNMENT_SYSTEM_OPTIONS / CONSIGNMENT_ZONE_OPTIONS):给客服端
|
||||
* 提报审核、库存编辑用 —— 系统 苹果/安卓/通用,区服 微信区/QQ区/互通。
|
||||
* 2. 发布端按物品类型展示(SURNAN_*):打手端「我要寄卖」按物品类型给选项 ——
|
||||
* 苏尔南必须区分 苹果/安卓(不给通用),区服固定「互通」(QQ/微信互通,默认选中)。
|
||||
*
|
||||
* 「互通」在匹配引擎里是区服通配值:互通现货 = 微信/QQ 订单都匹配,反之亦然;
|
||||
* 订单文案里的「通用/全区/不限」会归一化成通配(见后端 ZONE_WILDCARDS)。
|
||||
* 系统侧的「通用」同理是系统通配(订单文案里的「双端/全端」也归一化过来)。
|
||||
* 后续其他品类规则不同(有的不分端),在这里按品类加条目即可。
|
||||
* 旧版客服手工入库表单的选项常量。
|
||||
* 新链路以「每个寄卖配置的 specs」为准,这里只给没有配置快照的旧数据兜底。
|
||||
*/
|
||||
export const CONSIGNMENT_SYSTEM_OPTIONS = [
|
||||
{ label: '苹果', value: '苹果' },
|
||||
@@ -24,13 +21,124 @@ export const CONSIGNMENT_ZONE_OPTIONS = [
|
||||
{ label: '互通(QQ/微信)', value: '互通' },
|
||||
] as const
|
||||
|
||||
/** 打手端发布 · 苏尔南:系统必须二选一,区服固定「互通」 */
|
||||
export const SURNAN_PUBLISH_SYSTEM_OPTIONS = [
|
||||
{ label: '苹果', value: '苹果' },
|
||||
{ label: '安卓', value: '安卓' },
|
||||
] as const
|
||||
export const SPEC_FIELD_TYPE_OPTIONS: Array<{ label: string; value: ConsignmentSpecFieldType }> = [
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: '长文本', value: 'textarea' },
|
||||
{ label: '下拉', value: 'select' },
|
||||
{ label: '单选', value: 'radio' },
|
||||
{ label: '数字', value: 'number' },
|
||||
{ label: '日期', value: 'date' },
|
||||
]
|
||||
|
||||
export const SURNAN_PUBLISH_ZONE_OPTIONS = [{ label: '互通(QQ/微信)', value: '互通' }] as const
|
||||
export function specFieldTypeLabel(type: ConsignmentSpecFieldType): string {
|
||||
return SPEC_FIELD_TYPE_OPTIONS.find((item) => item.value === type)?.label || '文本'
|
||||
}
|
||||
|
||||
/** 苏尔南发布时区服的默认值(唯一选项,自动选中) */
|
||||
export const SURNAN_DEFAULT_ZONE = '互通'
|
||||
export function isOptionSpecField(field: Pick<ConsignmentSpecField, 'type'>): boolean {
|
||||
return field.type === 'select' || field.type === 'radio'
|
||||
}
|
||||
|
||||
export function formatSpecValue(
|
||||
field: ConsignmentSpecField,
|
||||
value: unknown,
|
||||
options: { forItemName?: boolean } = {},
|
||||
): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
if (options.forItemName && (field.key === 'diamondLevel' || field.key === 'brillianceCount')) {
|
||||
const numeric = Number(raw)
|
||||
if (!Number.isFinite(numeric)) return raw
|
||||
if (field.key === 'brillianceCount' && numeric <= 0) return ''
|
||||
return String(Math.round(numeric))
|
||||
}
|
||||
if (isOptionSpecField(field)) {
|
||||
const option = field.options.find((item) => item.value === raw)
|
||||
if (options.forItemName && field.key === 'serverZone' && field.wildcardValues.includes(raw)) {
|
||||
return ''
|
||||
}
|
||||
return option?.label || raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export function buildSpecText(
|
||||
specs: ConsignmentSpecField[],
|
||||
values: ConsignmentSpecValues | undefined,
|
||||
): string {
|
||||
if (!values) return ''
|
||||
return specs
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
|
||||
.map((field) => {
|
||||
const display = formatSpecValue(field, values[field.key])
|
||||
return display ? `${field.label} ${display}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
export function buildSpecItemName(
|
||||
template: string,
|
||||
specs: ConsignmentSpecField[],
|
||||
values: ConsignmentSpecValues | undefined,
|
||||
categoryName = '',
|
||||
): string {
|
||||
if (!values) return categoryName
|
||||
const tokens = new Map<string, string>()
|
||||
for (const field of specs) {
|
||||
tokens.set(field.key, formatSpecValue(field, values[field.key], { forItemName: true }))
|
||||
}
|
||||
tokens.set('category', categoryName)
|
||||
let result = String(template || '').trim()
|
||||
if (result) {
|
||||
result = result.replace(
|
||||
/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g,
|
||||
(_, key: string) => tokens.get(key) || '',
|
||||
)
|
||||
} else {
|
||||
result = specs
|
||||
.filter((field) => field.showInItemName)
|
||||
.map((field) => formatSpecValue(field, values[field.key], { forItemName: true }))
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
}
|
||||
const normalized = result.replace(/\s+/g, ' ').trim()
|
||||
return normalized || categoryName || '寄卖物品'
|
||||
}
|
||||
|
||||
export type SpecPriceResult = {
|
||||
priceFen: number
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
breakdown: Array<{ label: string; amountFen: number }>
|
||||
}
|
||||
|
||||
/** 与后端 computeConsignmentPrice 保持同口径,仅用于配置面板预览。 */
|
||||
export function computeSpecPrice(
|
||||
pricing: ConsignmentPricingConfig,
|
||||
templatePriceFen = 0,
|
||||
): SpecPriceResult {
|
||||
const template = Math.max(0, Math.round(Number(templatePriceFen || 0)))
|
||||
const breakdown: Array<{ label: string; amountFen: number }> = []
|
||||
let priceFen = 0
|
||||
|
||||
if (pricing.priceType === 'fixed') {
|
||||
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
|
||||
if (priceFen > 0) breakdown.push({ label: '固定价格', amountFen: priceFen })
|
||||
} else if (template > 0) {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((template * discount) / 10)
|
||||
breakdown.push({ label: '接单模板价', amountFen: template })
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
|
||||
}
|
||||
}
|
||||
|
||||
priceFen = Math.max(0, Math.round(priceFen))
|
||||
return {
|
||||
priceFen,
|
||||
capFen: priceFen,
|
||||
templatePriceFen: template,
|
||||
breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,37 @@ test('工单页:必须挂载内部库存匹配弹窗,并支持状态 interna
|
||||
assert.ok(shared.includes("internal_matching: '内部匹配中'"), '状态文案必须包含内部匹配中')
|
||||
assert.ok(shared.includes("internal_matching: 'magenta'"), '状态配色必须包含内部匹配中')
|
||||
})
|
||||
|
||||
test('寄卖配置面板:独立功能卡片、空字段初始状态与价格配置', () => {
|
||||
const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
|
||||
for (const label of [
|
||||
'规格字段',
|
||||
'添加字段',
|
||||
'基础信息',
|
||||
'价格配置',
|
||||
'履约保证金',
|
||||
'服务费',
|
||||
'库存数量限制',
|
||||
'匹配设置',
|
||||
'超时与降价提示',
|
||||
'接单模板价打折',
|
||||
'固定价格',
|
||||
'模板折扣',
|
||||
'物品名模板',
|
||||
]) {
|
||||
assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`)
|
||||
}
|
||||
assert.ok(!panel.includes('DEFAULT_SPEC_FIELDS'), '面板不能预置任何内置字段')
|
||||
assert.ok(panel.includes('useState<ConsignmentSpecField[]>([])'), '规格字段初始必须为空')
|
||||
assert.ok(panel.includes('specDrafts.map'), '规格字段必须可动态增删/排序')
|
||||
assert.ok(panel.includes('SPEC_FIELD_TYPE_OPTIONS'), '字段类型必须支持 6 种选项')
|
||||
})
|
||||
|
||||
test('打手端:动态规格 + 系统建议价 + 改价不得高于建议价', () => {
|
||||
const page = read('../src/pages/worker/WorkerConsignmentPage.tsx')
|
||||
assert.ok(page.includes('selectedSpecs.map'), '规格必须按配置动态渲染')
|
||||
assert.ok(page.includes('fetchWorkerConsignmentPricePreview'), '必须走动态价格预览')
|
||||
assert.ok(page.includes('不能高于系统建议价'), '前端必须提示改价上限')
|
||||
assert.ok(page.includes('specValues'), '发布必须提交动态规格值')
|
||||
assert.ok(page.includes('config.pricing.allowWorkerPrice'), '改价能力必须按配置')
|
||||
})
|
||||
|
||||
@@ -20,32 +20,26 @@ test('后台全量选项:系统含通用,区服含互通', () => {
|
||||
assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」')
|
||||
})
|
||||
|
||||
test('苏尔南发布选项:系统只有苹果/安卓,区服唯一「互通」且为默认值', () => {
|
||||
const source = readFileSync(
|
||||
test('苏尔南默认配置:取消代码内置字段,由迁移 131 提供五个单选字段', () => {
|
||||
const utils = 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: '微信区'"),
|
||||
'苏尔南发布端区服只有互通一个选项',
|
||||
)
|
||||
assert.ok(!utils.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'), '前端不再写死苏尔南发布选项')
|
||||
assert.ok(!utils.includes('DEFAULT_SPEC_FIELDS'), '前端不再内置默认 5 个字段')
|
||||
assert.ok(utils.includes('SPEC_FIELD_TYPE_OPTIONS'), '字段类型选项必须集中维护')
|
||||
})
|
||||
|
||||
test('打手端发布表单:用苏尔南选项集 + 区服默认值,不引后台全量', () => {
|
||||
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('selectedSpecs.map'), '规格字段必须来自配置动态渲染')
|
||||
assert.ok(source.includes('config.specs'), '必须读取寄卖配置的 specs')
|
||||
assert.ok(!source.includes('DEFAULT_SPEC_FIELDS'), '空配置不能回退内置 5 项')
|
||||
assert.ok(source.includes('fetchWorkerConsignmentPricePreview'), '价格必须走动态预览接口')
|
||||
assert.ok(source.includes('buildSpecItemName'), '物品名必须按名称模板生成')
|
||||
assert.ok(!source.includes('[...CONSIGNMENT_'), '发布端不直接引用后台全量选项')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user