外部测试内部绑定ok
This commit is contained in:
@@ -35,7 +35,6 @@ module.exports = {
|
|||||||
orders: {
|
orders: {
|
||||||
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
|
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
|
||||||
tokenTtlHours: 24,
|
tokenTtlHours: 24,
|
||||||
fulfillmentBindings: [],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
admin: {
|
admin: {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
{
|
{
|
||||||
"693760716": {
|
"693760716": {
|
||||||
|
"enabled": true,
|
||||||
"shopName": "大锤商行",
|
"shopName": "大锤商行",
|
||||||
"accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4"
|
"accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4"
|
||||||
},
|
},
|
||||||
"2037495620": {
|
"2037495620": {
|
||||||
|
"enabled": true,
|
||||||
"shopName": "羊小胖。",
|
"shopName": "羊小胖。",
|
||||||
"accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu"
|
"accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu"
|
||||||
|
},
|
||||||
|
"2209880145223": {
|
||||||
|
"enabled": true,
|
||||||
|
"shopName": "大锤号商",
|
||||||
|
"accessToken": "AldsIdleu5rzb68g8gh32sk9e8rbunes6gwyg59aph2nn5em6fp4r"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"provider": "agiso",
|
||||||
|
"platform": "xianyu",
|
||||||
|
"shopId": "693760716",
|
||||||
|
"skuCode": "sjz_hdl_test01",
|
||||||
|
"skuName": "三角洲行动-自动领取",
|
||||||
|
"profileKey": "tencent_claim_redeem",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 100,
|
||||||
|
"config": {
|
||||||
|
"note": "三角洲行动腾讯兑换码自动领取"
|
||||||
|
},
|
||||||
|
"match": {
|
||||||
|
"externalSkuCode": "6228372634012",
|
||||||
|
"externalItemId": "1040695550204",
|
||||||
|
"externalSkuName": "周边",
|
||||||
|
"config": {
|
||||||
|
"resolvedSkuName": "三角洲行动-自动领取"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -24,6 +24,7 @@ const RESET_TABLES = [
|
|||||||
'order_items',
|
'order_items',
|
||||||
'orders',
|
'orders',
|
||||||
'webhook_events',
|
'webhook_events',
|
||||||
|
'product_match_rules',
|
||||||
'inventory_items',
|
'inventory_items',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -161,11 +161,6 @@ function applyEnvOverrides(baseConfig) {
|
|||||||
nextConfig.orders.tokenTtlHours = tokenTtlHours
|
nextConfig.orders.tokenTtlHours = tokenTtlHours
|
||||||
}
|
}
|
||||||
|
|
||||||
const fulfillmentBindings = parseJsonArray(process.env.ORDER_FULFILLMENT_BINDINGS_JSON)
|
|
||||||
if (fulfillmentBindings) {
|
|
||||||
nextConfig.orders.fulfillmentBindings = fulfillmentBindings
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminSessionSecret = String(process.env.ADMIN_SESSION_SECRET || '').trim()
|
const adminSessionSecret = String(process.env.ADMIN_SESSION_SECRET || '').trim()
|
||||||
if (adminSessionSecret) {
|
if (adminSessionSecret) {
|
||||||
nextConfig.admin.sessionSecret = adminSessionSecret
|
nextConfig.admin.sessionSecret = adminSessionSecret
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS product_match_rules (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL DEFAULT '',
|
||||||
|
platform TEXT NOT NULL DEFAULT '',
|
||||||
|
shop_id TEXT NOT NULL DEFAULT '',
|
||||||
|
external_item_id TEXT NOT NULL DEFAULT '',
|
||||||
|
external_sku_code TEXT NOT NULL DEFAULT '',
|
||||||
|
external_sku_name TEXT NOT NULL DEFAULT '',
|
||||||
|
external_sku_name_normalized TEXT NOT NULL DEFAULT '',
|
||||||
|
resolved_sku_code TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_match_rules_unique
|
||||||
|
ON product_match_rules(
|
||||||
|
provider,
|
||||||
|
platform,
|
||||||
|
shop_id,
|
||||||
|
external_item_id,
|
||||||
|
external_sku_code,
|
||||||
|
external_sku_name_normalized,
|
||||||
|
resolved_sku_code
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_match_rules_lookup
|
||||||
|
ON product_match_rules(provider, platform, shop_id, enabled, priority, id);
|
||||||
@@ -115,6 +115,7 @@ export async function listInventoryItems({
|
|||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
skuCode = '',
|
skuCode = '',
|
||||||
|
credentialType = '',
|
||||||
status = '',
|
status = '',
|
||||||
batchNo = '',
|
batchNo = '',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -132,6 +133,11 @@ export async function listInventoryItems({
|
|||||||
filters.push(`ii.status = $${params.length}`)
|
filters.push(`ii.status = $${params.length}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (credentialType) {
|
||||||
|
params.push(credentialType)
|
||||||
|
filters.push(`ii.credential_type = $${params.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (batchNo) {
|
if (batchNo) {
|
||||||
params.push(`%${batchNo}%`)
|
params.push(`%${batchNo}%`)
|
||||||
filters.push(`ii.batch_no ILIKE $${params.length}`)
|
filters.push(`ii.batch_no ILIKE $${params.length}`)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { query } from '../db/client.js'
|
||||||
|
|
||||||
|
export async function upsertProductMatchRule(input) {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
INSERT INTO product_match_rules (
|
||||||
|
provider,
|
||||||
|
platform,
|
||||||
|
shop_id,
|
||||||
|
external_item_id,
|
||||||
|
external_sku_code,
|
||||||
|
external_sku_name,
|
||||||
|
external_sku_name_normalized,
|
||||||
|
resolved_sku_code,
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
config_json,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13
|
||||||
|
)
|
||||||
|
ON CONFLICT (
|
||||||
|
provider,
|
||||||
|
platform,
|
||||||
|
shop_id,
|
||||||
|
external_item_id,
|
||||||
|
external_sku_code,
|
||||||
|
external_sku_name_normalized,
|
||||||
|
resolved_sku_code
|
||||||
|
) DO UPDATE
|
||||||
|
SET
|
||||||
|
external_sku_name = EXCLUDED.external_sku_name,
|
||||||
|
enabled = EXCLUDED.enabled,
|
||||||
|
priority = EXCLUDED.priority,
|
||||||
|
config_json = EXCLUDED.config_json,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.provider || '',
|
||||||
|
input.platform || '',
|
||||||
|
input.shopId || '',
|
||||||
|
input.externalItemId || '',
|
||||||
|
input.externalSkuCode || '',
|
||||||
|
input.externalSkuName || '',
|
||||||
|
input.externalSkuNameNormalized || '',
|
||||||
|
input.resolvedSkuCode,
|
||||||
|
input.enabled !== false,
|
||||||
|
Number(input.priority || 100),
|
||||||
|
input.configJson || '{}',
|
||||||
|
input.createdAt,
|
||||||
|
input.updatedAt,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveProductMatchRule({
|
||||||
|
provider = '',
|
||||||
|
platform = '',
|
||||||
|
shopId = '',
|
||||||
|
externalItemId = '',
|
||||||
|
externalSkuCode = '',
|
||||||
|
externalSkuNameNormalized = '',
|
||||||
|
}) {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
pmr.*,
|
||||||
|
CASE
|
||||||
|
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 'external_sku_code'
|
||||||
|
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 'external_item_id'
|
||||||
|
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 'external_sku_name_exact'
|
||||||
|
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
|
||||||
|
position(pmr.external_sku_name_normalized in $6) > 0
|
||||||
|
OR position($6 in pmr.external_sku_name_normalized) > 0
|
||||||
|
) THEN 'external_sku_name_contains'
|
||||||
|
ELSE ''
|
||||||
|
END AS matched_by,
|
||||||
|
CASE
|
||||||
|
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 400
|
||||||
|
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 300
|
||||||
|
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 200
|
||||||
|
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
|
||||||
|
position(pmr.external_sku_name_normalized in $6) > 0
|
||||||
|
OR position($6 in pmr.external_sku_name_normalized) > 0
|
||||||
|
) THEN 100
|
||||||
|
ELSE 0
|
||||||
|
END AS match_score
|
||||||
|
FROM product_match_rules pmr
|
||||||
|
WHERE pmr.enabled = TRUE
|
||||||
|
AND (pmr.provider = '' OR pmr.provider = $1)
|
||||||
|
AND (pmr.platform = '' OR pmr.platform = $2)
|
||||||
|
AND (pmr.shop_id = '' OR pmr.shop_id = $3)
|
||||||
|
AND (
|
||||||
|
($4 != '' AND pmr.external_sku_code = $4)
|
||||||
|
OR ($5 != '' AND pmr.external_item_id = $5)
|
||||||
|
OR (
|
||||||
|
$6 != ''
|
||||||
|
AND pmr.external_sku_name_normalized != ''
|
||||||
|
AND (
|
||||||
|
pmr.external_sku_name_normalized = $6
|
||||||
|
OR position(pmr.external_sku_name_normalized in $6) > 0
|
||||||
|
OR position($6 in pmr.external_sku_name_normalized) > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
match_score DESC,
|
||||||
|
CASE WHEN pmr.shop_id = '' THEN 1 ELSE 0 END,
|
||||||
|
CASE WHEN pmr.platform = '' THEN 1 ELSE 0 END,
|
||||||
|
CASE WHEN pmr.provider = '' THEN 1 ELSE 0 END,
|
||||||
|
pmr.priority ASC,
|
||||||
|
pmr.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[provider, platform, shopId, externalSkuCode, externalItemId, externalSkuNameNormalized],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
import { getAdminAgisoShopConfigs, updateAdminAgisoShopConfigs } from '../../services/admin/admin-service.js'
|
import {
|
||||||
|
getAdminAgisoShopConfigs,
|
||||||
|
getAdminFulfillmentBindingConfigs,
|
||||||
|
updateAdminAgisoShopConfigs,
|
||||||
|
updateAdminFulfillmentBindingConfigs,
|
||||||
|
} from '../../services/admin/admin-service.js'
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -34,4 +39,31 @@ router.post('/platform-config/agiso-shops', createJsonHandler(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
|
router.get('/platform-config/fulfillment-bindings', createJsonHandler(
|
||||||
|
() => getAdminFulfillmentBindingConfigs(),
|
||||||
|
{
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取履约配置失败',
|
||||||
|
scope: '[admin/platform-config/fulfillment-bindings]',
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.post('/platform-config/fulfillment-bindings', createJsonHandler(
|
||||||
|
(req) => updateAdminFulfillmentBindingConfigs(req.body),
|
||||||
|
{
|
||||||
|
successMessage: '履约配置已保存',
|
||||||
|
errorMessage: '保存履约配置失败',
|
||||||
|
scope: '[admin/platform-config/fulfillment-bindings]',
|
||||||
|
audit: (_req, data) => ({
|
||||||
|
action: 'platform_fulfillment_config_updated',
|
||||||
|
targetType: 'platform_config',
|
||||||
|
targetId: 'fulfillment_bindings',
|
||||||
|
data: {
|
||||||
|
bindingCount: data.bindings.length,
|
||||||
|
filePath: data.filePath,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
getAgisoShopsFilePath,
|
getAgisoShopsFilePath,
|
||||||
saveAgisoShopConfigMap,
|
saveAgisoShopConfigMap,
|
||||||
} from '../platforms/agiso/shop-config-service.js'
|
} from '../platforms/agiso/shop-config-service.js'
|
||||||
|
import {
|
||||||
|
getOrderFulfillmentBindingConfigs,
|
||||||
|
getOrderFulfillmentBindingsFilePath,
|
||||||
|
saveOrderFulfillmentBindingConfigs,
|
||||||
|
} from '../order/fulfillment-binding-config-service.js'
|
||||||
|
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
||||||
import { getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
import { getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||||
import {
|
import {
|
||||||
createInventoryItems,
|
createInventoryItems,
|
||||||
@@ -31,6 +37,8 @@ import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/m
|
|||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||||
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
|
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
|
||||||
|
import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js'
|
||||||
|
import { normalizeProductName } from '../order/product-match-service.js'
|
||||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||||
|
|
||||||
export async function getAdminDashboardSummary() {
|
export async function getAdminDashboardSummary() {
|
||||||
@@ -294,6 +302,7 @@ export async function getAdminInventoryItems(query = {}) {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
skuCode: String(query.skuCode || '').trim(),
|
skuCode: String(query.skuCode || '').trim(),
|
||||||
|
credentialType: String(query.credentialType || '').trim(),
|
||||||
status: String(query.status || '').trim(),
|
status: String(query.status || '').trim(),
|
||||||
batchNo: String(query.batchNo || '').trim(),
|
batchNo: String(query.batchNo || '').trim(),
|
||||||
})
|
})
|
||||||
@@ -581,6 +590,145 @@ export function updateAdminAgisoShopConfigs(payload = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAdminFulfillmentBindingConfigs() {
|
||||||
|
const bindings = getOrderFulfillmentBindingConfigs()
|
||||||
|
const rowsResult = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
o.provider,
|
||||||
|
o.platform,
|
||||||
|
o.shop_id,
|
||||||
|
MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name,
|
||||||
|
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id,
|
||||||
|
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code,
|
||||||
|
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name,
|
||||||
|
MAX(oi.created_at) AS latest_seen_at,
|
||||||
|
COUNT(*)::int AS order_item_count
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN orders o ON o.id = oi.order_id
|
||||||
|
WHERE
|
||||||
|
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != ''
|
||||||
|
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != ''
|
||||||
|
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != ''
|
||||||
|
GROUP BY
|
||||||
|
o.provider,
|
||||||
|
o.platform,
|
||||||
|
o.shop_id,
|
||||||
|
external_item_id,
|
||||||
|
external_sku_code,
|
||||||
|
external_sku_name
|
||||||
|
ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC
|
||||||
|
LIMIT 200
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||||
|
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
|
||||||
|
observedProducts: rowsResult.rows.map((row) => {
|
||||||
|
const item = {
|
||||||
|
provider: String(row.provider || '').trim(),
|
||||||
|
platform: String(row.platform || '').trim(),
|
||||||
|
shopId: String(row.shop_id || '').trim(),
|
||||||
|
shopName: String(row.shop_name || '').trim(),
|
||||||
|
externalItemId: String(row.external_item_id || '').trim(),
|
||||||
|
externalSkuCode: String(row.external_sku_code || '').trim(),
|
||||||
|
externalSkuName: String(row.external_sku_name || '').trim(),
|
||||||
|
latestSeenAt: row.latest_seen_at || null,
|
||||||
|
orderItemCount: Number(row.order_item_count || 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
configured: bindings.some((binding) => matchesObservedProduct(binding, item)),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminFulfillmentBindingConfigs(payload = {}) {
|
||||||
|
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
|
||||||
|
await validateAdminFulfillmentBindingConfigs(bindingsInput)
|
||||||
|
const saved = saveOrderFulfillmentBindingConfigs(bindingsInput)
|
||||||
|
await syncConfiguredFulfillmentBindings()
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||||
|
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||||
|
if (!Array.isArray(bindings)) {
|
||||||
|
throw createHttpError('履约配置格式不正确', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'admin_fulfillment_bindings_invalid_payload',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenKeys = new Set()
|
||||||
|
|
||||||
|
for (const [index, rawBinding] of bindings.entries()) {
|
||||||
|
if (!isPlainObject(rawBinding)) {
|
||||||
|
throw createHttpError(`第 ${index + 1} 条规则格式不正确`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'admin_fulfillment_bindings_invalid_item',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
|
||||||
|
const platform = String(rawBinding.platform || '').trim()
|
||||||
|
const shopId = String(rawBinding.shopId || '').trim()
|
||||||
|
const skuCode = String(rawBinding.skuCode || '').trim()
|
||||||
|
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
|
||||||
|
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
|
||||||
|
const externalItemId = String(match.externalItemId || '').trim()
|
||||||
|
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||||
|
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||||
|
|
||||||
|
if (!skuCode) {
|
||||||
|
throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!externalItemId && !externalSkuCode && !externalSkuName) {
|
||||||
|
throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await getFulfillmentProfileByKey(profileKey)
|
||||||
|
if (!profile) {
|
||||||
|
throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${profileKey}`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueKey = [
|
||||||
|
provider,
|
||||||
|
platform,
|
||||||
|
shopId,
|
||||||
|
externalItemId,
|
||||||
|
externalSkuCode,
|
||||||
|
normalizeProductName(externalSkuName),
|
||||||
|
skuCode,
|
||||||
|
].join('::')
|
||||||
|
|
||||||
|
if (seenKeys.has(uniqueKey)) {
|
||||||
|
throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
seenKeys.add(uniqueKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function retryAdminTask(taskId) {
|
export async function retryAdminTask(taskId) {
|
||||||
const task = await getRequiredTask(taskId)
|
const task = await getRequiredTask(taskId)
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
@@ -934,6 +1082,55 @@ function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapAdminFulfillmentBindingConfigItem(item) {
|
||||||
|
const match = item?.match || {}
|
||||||
|
return {
|
||||||
|
provider: String(item?.provider || '').trim(),
|
||||||
|
platform: String(item?.platform || '').trim(),
|
||||||
|
shopId: String(item?.shopId || '').trim(),
|
||||||
|
skuCode: String(item?.skuCode || '').trim(),
|
||||||
|
skuName: String(item?.skuName || '').trim(),
|
||||||
|
profileKey: String(item?.profileKey || '').trim(),
|
||||||
|
enabled: item?.enabled !== false,
|
||||||
|
priority: Number(item?.priority || 100),
|
||||||
|
config: item?.config || {},
|
||||||
|
match: {
|
||||||
|
externalSkuCode: String(match.externalSkuCode || '').trim(),
|
||||||
|
externalItemId: String(match.externalItemId || '').trim(),
|
||||||
|
externalSkuName: String(match.externalSkuName || '').trim(),
|
||||||
|
config: match.config || {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesObservedProduct(binding, observed) {
|
||||||
|
const provider = String(binding?.provider || '').trim()
|
||||||
|
const platform = String(binding?.platform || '').trim()
|
||||||
|
const shopId = String(binding?.shopId || '').trim()
|
||||||
|
const match = binding?.match || {}
|
||||||
|
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||||
|
const externalItemId = String(match.externalItemId || '').trim()
|
||||||
|
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||||
|
|
||||||
|
if (provider && provider !== String(observed?.provider || '').trim()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform && platform !== String(observed?.platform || '').trim()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shopId && shopId !== String(observed?.shopId || '').trim()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim())
|
||||||
|
|| (externalItemId && externalItemId === String(observed?.externalItemId || '').trim())
|
||||||
|
|| (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) {
|
async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) {
|
||||||
const headers = normalizeRecord(safeParseJson(item.headers_json))
|
const headers = normalizeRecord(safeParseJson(item.headers_json))
|
||||||
const query = normalizeRecord(safeParseJson(item.query_json))
|
const query = normalizeRecord(safeParseJson(item.query_json))
|
||||||
@@ -1666,6 +1863,10 @@ function normalizeRecord(value) {
|
|||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
function extractWebhookPayload(body) {
|
function extractWebhookPayload(body) {
|
||||||
const normalizedBody = normalizeRecord(body)
|
const normalizedBody = normalizeRecord(body)
|
||||||
const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim()
|
const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { runtimeConfig } from '../../config/runtime.js'
|
|
||||||
import {
|
import {
|
||||||
getFulfillmentProfileByKey,
|
getFulfillmentProfileByKey,
|
||||||
listFulfillmentProfileRequirements,
|
listFulfillmentProfileRequirements,
|
||||||
@@ -6,6 +5,10 @@ import {
|
|||||||
upsertFulfillmentProfile,
|
upsertFulfillmentProfile,
|
||||||
upsertSkuFulfillmentBinding,
|
upsertSkuFulfillmentBinding,
|
||||||
} from '../../repositories/fulfillment-profile-repo.js'
|
} from '../../repositories/fulfillment-profile-repo.js'
|
||||||
|
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
||||||
|
import { normalizeProductName } from '../order/product-match-service.js'
|
||||||
|
import { query } from '../../db/client.js'
|
||||||
|
import { getOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
|
||||||
const CORE_PROFILES = [
|
const CORE_PROFILES = [
|
||||||
@@ -37,6 +40,11 @@ const CORE_PROFILES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||||
|
const profileMap = await ensureCoreProfiles()
|
||||||
|
await syncConfiguredFulfillmentBindings(profileMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCoreProfiles() {
|
||||||
const timestamp = nowIso()
|
const timestamp = nowIso()
|
||||||
const profileMap = {}
|
const profileMap = {}
|
||||||
|
|
||||||
@@ -64,7 +72,15 @@ export async function ensureFulfillmentCatalogBootstrapped() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindingsToApply = normalizeConfiguredBindings(runtimeConfig.orders?.fulfillmentBindings)
|
return profileMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncConfiguredFulfillmentBindings(profileMap = {}) {
|
||||||
|
const timestamp = nowIso()
|
||||||
|
const bindingsToApply = getOrderFulfillmentBindingConfigs()
|
||||||
|
|
||||||
|
await query('DELETE FROM product_match_rules')
|
||||||
|
await query('DELETE FROM sku_fulfillment_bindings')
|
||||||
|
|
||||||
for (const binding of bindingsToApply) {
|
for (const binding of bindingsToApply) {
|
||||||
const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey)
|
const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey)
|
||||||
@@ -84,24 +100,30 @@ export async function ensureFulfillmentCatalogBootstrapped() {
|
|||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const match = binding.match || {}
|
||||||
|
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||||
|
const externalItemId = String(match.externalItemId || '').trim()
|
||||||
|
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||||
|
|
||||||
|
if (!externalSkuName && !externalItemId && !externalSkuCode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertProductMatchRule({
|
||||||
|
provider: binding.provider,
|
||||||
|
platform: binding.platform,
|
||||||
|
shopId: binding.shopId,
|
||||||
|
externalItemId,
|
||||||
|
externalSkuCode,
|
||||||
|
externalSkuName,
|
||||||
|
externalSkuNameNormalized: normalizeProductName(externalSkuName),
|
||||||
|
resolvedSkuCode: binding.skuCode,
|
||||||
|
enabled: binding.enabled,
|
||||||
|
priority: binding.priority,
|
||||||
|
configJson: JSON.stringify(match.config || {}),
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfiguredBindings(bindings) {
|
|
||||||
if (!Array.isArray(bindings)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return bindings
|
|
||||||
.map((binding) => ({
|
|
||||||
skuCode: String(binding?.skuCode || '').trim(),
|
|
||||||
provider: String(binding?.provider || '').trim(),
|
|
||||||
platform: String(binding?.platform || '').trim(),
|
|
||||||
shopId: String(binding?.shopId || '').trim(),
|
|
||||||
profileKey: String(binding?.profileKey || '').trim() || 'manual_review',
|
|
||||||
enabled: binding?.enabled !== false,
|
|
||||||
priority: Number(binding?.priority || 100),
|
|
||||||
config: binding?.config || {},
|
|
||||||
}))
|
|
||||||
.filter((binding) => binding.skuCode)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
|
|||||||
profileName: String(profile.profile_name || profile.name || ''),
|
profileName: String(profile.profile_name || profile.name || ''),
|
||||||
skuCode: item.sku_code,
|
skuCode: item.sku_code,
|
||||||
skuName: item.sku_name,
|
skuName: item.sku_name,
|
||||||
|
inventorySkuCode: item.sku_code,
|
||||||
primaryRequirement: primaryRequirement
|
primaryRequirement: primaryRequirement
|
||||||
? {
|
? {
|
||||||
roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'),
|
roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'),
|
||||||
@@ -107,6 +108,7 @@ async function preparePaidTask(task) {
|
|||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const taskContext = parseTaskContext(task)
|
const taskContext = parseTaskContext(task)
|
||||||
const primaryRequirement = taskContext.primaryRequirement || null
|
const primaryRequirement = taskContext.primaryRequirement || null
|
||||||
|
const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim()
|
||||||
|
|
||||||
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) {
|
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) {
|
||||||
return task
|
return task
|
||||||
@@ -122,7 +124,7 @@ async function preparePaidTask(task) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.skuCode) {
|
if (!inventorySkuCode) {
|
||||||
return updateTask(task.id, {
|
return updateTask(task.id, {
|
||||||
task_status: 'manual_review',
|
task_status: 'manual_review',
|
||||||
last_error: '未匹配到 SKU,无法为任务分配库存凭据',
|
last_error: '未匹配到 SKU,无法为任务分配库存凭据',
|
||||||
@@ -147,7 +149,7 @@ async function preparePaidTask(task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reserved = await reserveInventoryForTask({
|
const reserved = await reserveInventoryForTask({
|
||||||
skuCode: task.skuCode,
|
skuCode: inventorySkuCode,
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
credentialType: primaryRequirement.credentialType,
|
credentialType: primaryRequirement.credentialType,
|
||||||
roleKey: primaryRequirement.roleKey || 'primary_code',
|
roleKey: primaryRequirement.roleKey || 'primary_code',
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||||
|
|
||||||
|
const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json')
|
||||||
|
|
||||||
|
export function getOrderFulfillmentBindingsFilePath() {
|
||||||
|
return ORDER_FULFILLMENT_BINDINGS_FILE_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderFulfillmentBindingConfigs() {
|
||||||
|
return loadOrderFulfillmentBindingConfigsFromFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveOrderFulfillmentBindingConfigs(rawValue) {
|
||||||
|
const normalized = normalizeOrderFulfillmentBindingConfigs(rawValue)
|
||||||
|
fs.mkdirSync(path.dirname(ORDER_FULFILLMENT_BINDINGS_FILE_PATH), { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
|
||||||
|
`${JSON.stringify(normalized, null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrderFulfillmentBindingConfigsFromFile() {
|
||||||
|
if (!fs.existsSync(ORDER_FULFILLMENT_BINDINGS_FILE_PATH)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawText = fs.readFileSync(ORDER_FULFILLMENT_BINDINGS_FILE_PATH, 'utf8')
|
||||||
|
const parsed = JSON.parse(rawText)
|
||||||
|
return normalizeOrderFulfillmentBindingConfigs(parsed)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrderFulfillmentBindingConfigs(rawValue) {
|
||||||
|
if (!Array.isArray(rawValue)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue
|
||||||
|
.map((item) => normalizeOrderFulfillmentBinding(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrderFulfillmentBinding(rawValue) {
|
||||||
|
if (!isPlainObject(rawValue)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = String(rawValue.provider || 'agiso').trim() || 'agiso'
|
||||||
|
const platform = String(rawValue.platform || '').trim()
|
||||||
|
const shopId = String(rawValue.shopId || '').trim()
|
||||||
|
const skuCode = String(rawValue.skuCode || '').trim()
|
||||||
|
const skuName = String(rawValue.skuName || '').trim()
|
||||||
|
const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review'
|
||||||
|
const priority = normalizePriority(rawValue.priority)
|
||||||
|
const enabled = rawValue.enabled !== false
|
||||||
|
const config = normalizeJsonObject(rawValue.config)
|
||||||
|
const match = normalizeOrderFulfillmentMatch(rawValue.match)
|
||||||
|
|
||||||
|
if (!skuCode || !match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
platform,
|
||||||
|
shopId,
|
||||||
|
skuCode,
|
||||||
|
skuName,
|
||||||
|
profileKey,
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
config,
|
||||||
|
match,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrderFulfillmentMatch(rawValue) {
|
||||||
|
if (!isPlainObject(rawValue)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalSkuCode = String(rawValue.externalSkuCode || '').trim()
|
||||||
|
const externalItemId = String(rawValue.externalItemId || '').trim()
|
||||||
|
const externalSkuName = String(rawValue.externalSkuName || '').trim()
|
||||||
|
|
||||||
|
if (!externalSkuCode && !externalItemId && !externalSkuName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalSkuCode,
|
||||||
|
externalItemId,
|
||||||
|
externalSkuName,
|
||||||
|
config: normalizeJsonObject(rawValue.config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriority(value) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.round(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJsonObject(value) {
|
||||||
|
return isPlainObject(value) ? value : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return Object.prototype.toString.call(value) === '[object Object]'
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
|||||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||||
import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js'
|
import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js'
|
||||||
|
import { resolveOrderItemForFulfillment } from './product-match-service.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { logWebhook } from '../../utils/logger.js'
|
import { logWebhook } from '../../utils/logger.js'
|
||||||
|
|
||||||
@@ -53,9 +54,18 @@ export async function upsertOrderFromWebhook(event) {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resolvedItems = await Promise.all(
|
||||||
|
event.items.map((item) => resolveOrderItemForFulfillment({
|
||||||
|
provider: event.provider,
|
||||||
|
platform: event.platform,
|
||||||
|
shopId: event.shopId,
|
||||||
|
item,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
const orderItems = await replaceOrderItems(
|
const orderItems = await replaceOrderItems(
|
||||||
order.id,
|
order.id,
|
||||||
event.items.map((item) => ({
|
resolvedItems.map((item) => ({
|
||||||
skuCode: item.skuCode,
|
skuCode: item.skuCode,
|
||||||
skuName: item.skuName,
|
skuName: item.skuName,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -78,6 +88,7 @@ export async function upsertOrderFromWebhook(event) {
|
|||||||
platformOrderId: order.platform_order_id,
|
platformOrderId: order.platform_order_id,
|
||||||
orderItemCount: orderItems.length,
|
orderItemCount: orderItems.length,
|
||||||
taskCount: tasks.length,
|
taskCount: tasks.length,
|
||||||
|
resolvedSkuCodes: resolvedItems.map((item) => item.skuCode),
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { resolveProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
||||||
|
|
||||||
|
export async function resolveOrderItemForFulfillment({
|
||||||
|
provider = '',
|
||||||
|
platform = '',
|
||||||
|
shopId = '',
|
||||||
|
item = {},
|
||||||
|
}) {
|
||||||
|
const externalItemId = pickFirstNonEmpty([
|
||||||
|
item.externalItemId,
|
||||||
|
item.itemId,
|
||||||
|
])
|
||||||
|
const externalSkuCode = pickFirstNonEmpty([
|
||||||
|
item.externalSkuCode,
|
||||||
|
item.skuCode,
|
||||||
|
externalItemId,
|
||||||
|
])
|
||||||
|
const externalSkuName = pickFirstNonEmpty([
|
||||||
|
item.externalSkuName,
|
||||||
|
item.skuName,
|
||||||
|
externalSkuCode,
|
||||||
|
])
|
||||||
|
const externalSkuNameNormalized = normalizeProductName(externalSkuName)
|
||||||
|
|
||||||
|
const matchedRule = await resolveProductMatchRule({
|
||||||
|
provider,
|
||||||
|
platform,
|
||||||
|
shopId,
|
||||||
|
externalItemId,
|
||||||
|
externalSkuCode,
|
||||||
|
externalSkuNameNormalized,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvedSkuCode = pickFirstNonEmpty([
|
||||||
|
matchedRule?.resolved_sku_code,
|
||||||
|
externalSkuCode,
|
||||||
|
externalItemId,
|
||||||
|
])
|
||||||
|
const resolvedSkuName = pickFirstNonEmpty([
|
||||||
|
readConfigValue(matchedRule?.config_json, 'resolvedSkuName'),
|
||||||
|
readConfigValue(matchedRule?.config_json, 'internalProductName'),
|
||||||
|
item.skuName,
|
||||||
|
externalSkuName,
|
||||||
|
resolvedSkuCode,
|
||||||
|
])
|
||||||
|
const snapshot = {
|
||||||
|
...(isPlainObject(item.snapshot) ? item.snapshot : {}),
|
||||||
|
externalItemId,
|
||||||
|
externalSkuCode,
|
||||||
|
externalSkuName,
|
||||||
|
externalSkuNameNormalized,
|
||||||
|
resolvedSkuCode,
|
||||||
|
matchedProductRuleId: matchedRule ? Number(matchedRule.id) : null,
|
||||||
|
matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
itemId: externalItemId,
|
||||||
|
externalItemId,
|
||||||
|
externalSkuCode,
|
||||||
|
externalSkuName,
|
||||||
|
skuCode: resolvedSkuCode,
|
||||||
|
skuName: resolvedSkuName,
|
||||||
|
snapshot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProductName(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[【】\[\]()()]/g, ' ')
|
||||||
|
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirstNonEmpty(values) {
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (normalized) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfigValue(rawValue, key) {
|
||||||
|
if (!rawValue) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = typeof rawValue === 'string' ? safeParseJson(rawValue) : rawValue
|
||||||
|
if (!isPlainObject(parsed)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(parsed[key] || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(rawValue) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(rawValue || '{}'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return Object.prototype.toString.call(value) === '[object Object]'
|
||||||
|
}
|
||||||
@@ -533,7 +533,17 @@ function normalizeOrderItems(payload) {
|
|||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const source = isPlainObject(item) ? item : {}
|
const source = isPlainObject(item) ? item : {}
|
||||||
|
const skuDescriptor = parseExternalSkuDescriptor(
|
||||||
|
pickFirstNonEmpty([
|
||||||
|
source.sku,
|
||||||
|
source.Sku,
|
||||||
|
payload.sku,
|
||||||
|
payload.Sku,
|
||||||
|
payload._agisoTradeDetail?.sku,
|
||||||
|
]),
|
||||||
|
)
|
||||||
const rawSkuCandidates = [
|
const rawSkuCandidates = [
|
||||||
|
skuDescriptor.externalSkuCode,
|
||||||
source.OuterSkuId,
|
source.OuterSkuId,
|
||||||
source.outerSkuId,
|
source.outerSkuId,
|
||||||
source.outer_sku_id,
|
source.outer_sku_id,
|
||||||
@@ -553,18 +563,31 @@ function normalizeOrderItems(payload) {
|
|||||||
payload.itemId,
|
payload.itemId,
|
||||||
]
|
]
|
||||||
const skuCode = resolveSkuCode(rawSkuCandidates)
|
const skuCode = resolveSkuCode(rawSkuCandidates)
|
||||||
|
const externalItemId = pickFirstNonEmpty([
|
||||||
|
source.item_id,
|
||||||
|
source.itemId,
|
||||||
|
source.goods_id,
|
||||||
|
source.goodsId,
|
||||||
|
payload.item_id,
|
||||||
|
payload.itemId,
|
||||||
|
])
|
||||||
|
const externalSkuName = pickFirstNonEmpty([
|
||||||
|
skuDescriptor.externalSkuName,
|
||||||
|
source.Title,
|
||||||
|
source.title,
|
||||||
|
source.sku_name,
|
||||||
|
source.skuName,
|
||||||
|
source.goods_name,
|
||||||
|
source.goodsName,
|
||||||
|
skuCode,
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skuCode,
|
skuCode,
|
||||||
skuName: pickFirstNonEmpty([
|
skuName: externalSkuName,
|
||||||
source.Title,
|
externalItemId,
|
||||||
source.title,
|
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
|
||||||
source.sku_name,
|
externalSkuName,
|
||||||
source.skuName,
|
|
||||||
source.goods_name,
|
|
||||||
source.goodsName,
|
|
||||||
skuCode,
|
|
||||||
]),
|
|
||||||
quantity: Math.max(
|
quantity: Math.max(
|
||||||
1,
|
1,
|
||||||
normalizeInteger(
|
normalizeInteger(
|
||||||
@@ -580,6 +603,11 @@ function normalizeOrderItems(payload) {
|
|||||||
) || 1,
|
) || 1,
|
||||||
),
|
),
|
||||||
spec: source,
|
spec: source,
|
||||||
|
snapshot: {
|
||||||
|
externalItemId,
|
||||||
|
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
|
||||||
|
externalSkuName,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -756,6 +784,56 @@ function resolveSkuCode(rawKey) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseExternalSkuDescriptor(rawValue) {
|
||||||
|
const raw = String(rawValue || '').trim()
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
externalSkuCode: '',
|
||||||
|
externalSkuName: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
|
||||||
|
let externalSkuCode = ''
|
||||||
|
let externalSkuName = ''
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!externalSkuCode && !part.includes(':') && !part.includes(':')) {
|
||||||
|
externalSkuCode = part
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':'))
|
||||||
|
if (separatorIndex >= 0) {
|
||||||
|
const label = part.slice(0, separatorIndex).trim()
|
||||||
|
const value = part.slice(separatorIndex + 1).trim()
|
||||||
|
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
|
||||||
|
externalSkuName = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!externalSkuCode && parts[0]) {
|
||||||
|
externalSkuCode = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!externalSkuName) {
|
||||||
|
externalSkuName = parts
|
||||||
|
.map((part) => {
|
||||||
|
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':'))
|
||||||
|
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
|
||||||
|
})
|
||||||
|
.find(Boolean) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
externalSkuCode,
|
||||||
|
externalSkuName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRecord(value) {
|
function normalizeRecord(value) {
|
||||||
return isPlainObject(value) ? value : {}
|
return isPlainObject(value) ? value : {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,7 +340,16 @@ function normalizeOrderItems(payload, fallbackItems = []) {
|
|||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const source = isPlainObject(item) ? item : {}
|
const source = isPlainObject(item) ? item : {}
|
||||||
|
const skuDescriptor = parseExternalSkuDescriptor(
|
||||||
|
pickFirstNonEmpty([
|
||||||
|
source.sku,
|
||||||
|
source.Sku,
|
||||||
|
payload.sku,
|
||||||
|
payload.Sku,
|
||||||
|
]),
|
||||||
|
)
|
||||||
const skuCode = pickFirstNonEmpty([
|
const skuCode = pickFirstNonEmpty([
|
||||||
|
skuDescriptor.externalSkuCode,
|
||||||
source.OuterSkuId,
|
source.OuterSkuId,
|
||||||
source.outerSkuId,
|
source.outerSkuId,
|
||||||
source.outer_sku_id,
|
source.outer_sku_id,
|
||||||
@@ -359,18 +368,31 @@ function normalizeOrderItems(payload, fallbackItems = []) {
|
|||||||
payload.item_id,
|
payload.item_id,
|
||||||
payload.itemId,
|
payload.itemId,
|
||||||
])
|
])
|
||||||
|
const externalItemId = pickFirstNonEmpty([
|
||||||
|
source.item_id,
|
||||||
|
source.itemId,
|
||||||
|
source.goods_id,
|
||||||
|
source.goodsId,
|
||||||
|
payload.item_id,
|
||||||
|
payload.itemId,
|
||||||
|
])
|
||||||
|
const externalSkuName = pickFirstNonEmpty([
|
||||||
|
skuDescriptor.externalSkuName,
|
||||||
|
source.Title,
|
||||||
|
source.title,
|
||||||
|
source.sku_name,
|
||||||
|
source.skuName,
|
||||||
|
source.goods_name,
|
||||||
|
source.goodsName,
|
||||||
|
skuCode,
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skuCode,
|
skuCode,
|
||||||
skuName: pickFirstNonEmpty([
|
skuName: externalSkuName,
|
||||||
source.Title,
|
externalItemId,
|
||||||
source.title,
|
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
|
||||||
source.sku_name,
|
externalSkuName,
|
||||||
source.skuName,
|
|
||||||
source.goods_name,
|
|
||||||
source.goodsName,
|
|
||||||
skuCode,
|
|
||||||
]),
|
|
||||||
quantity: Math.max(
|
quantity: Math.max(
|
||||||
1,
|
1,
|
||||||
normalizeInteger(
|
normalizeInteger(
|
||||||
@@ -386,10 +408,65 @@ function normalizeOrderItems(payload, fallbackItems = []) {
|
|||||||
) || 1,
|
) || 1,
|
||||||
),
|
),
|
||||||
spec: source,
|
spec: source,
|
||||||
|
snapshot: {
|
||||||
|
externalItemId,
|
||||||
|
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
|
||||||
|
externalSkuName,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseExternalSkuDescriptor(rawValue) {
|
||||||
|
const raw = String(rawValue || '').trim()
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
externalSkuCode: '',
|
||||||
|
externalSkuName: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
|
||||||
|
let externalSkuCode = ''
|
||||||
|
let externalSkuName = ''
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!externalSkuCode && !part.includes(':') && !part.includes(':')) {
|
||||||
|
externalSkuCode = part
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':'))
|
||||||
|
if (separatorIndex >= 0) {
|
||||||
|
const label = part.slice(0, separatorIndex).trim()
|
||||||
|
const value = part.slice(separatorIndex + 1).trim()
|
||||||
|
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
|
||||||
|
externalSkuName = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!externalSkuCode && parts[0]) {
|
||||||
|
externalSkuCode = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!externalSkuName) {
|
||||||
|
externalSkuName = parts
|
||||||
|
.map((part) => {
|
||||||
|
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':'))
|
||||||
|
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
|
||||||
|
})
|
||||||
|
.find(Boolean) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
externalSkuCode,
|
||||||
|
externalSkuName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isAgisoDetailSuccess(payload) {
|
function isAgisoDetailSuccess(payload) {
|
||||||
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
|
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const props = defineProps<{
|
|||||||
function resolveTone(status: string) {
|
function resolveTone(status: string) {
|
||||||
const normalized = String(status || '').trim().toLowerCase()
|
const normalized = String(status || '').trim().toLowerCase()
|
||||||
|
|
||||||
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) {
|
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'consumed', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) {
|
||||||
return 'success'
|
return 'success'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ function resolveLabel(status: string) {
|
|||||||
available: '可用',
|
available: '可用',
|
||||||
reserved: '已预占',
|
reserved: '已预占',
|
||||||
delivered: '已发放',
|
delivered: '已发放',
|
||||||
|
consumed: '已发放',
|
||||||
invalid: '已作废',
|
invalid: '已作废',
|
||||||
active: '生效中',
|
active: '生效中',
|
||||||
admin: '管理员',
|
admin: '管理员',
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ const router = createRouter({
|
|||||||
path: 'platform-shops',
|
path: 'platform-shops',
|
||||||
component: () => import('@/views/admin/AdminPlatformShopsView.vue'),
|
component: () => import('@/views/admin/AdminPlatformShopsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'platform-fulfillment',
|
||||||
|
component: () => import('@/views/admin/AdminFulfillmentBindingsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'webhook-events/:eventId',
|
path: 'webhook-events/:eventId',
|
||||||
component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'),
|
component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'),
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import type {
|
|||||||
AdminAgisoObservedShopItem,
|
AdminAgisoObservedShopItem,
|
||||||
AdminAgisoShopConfigItem,
|
AdminAgisoShopConfigItem,
|
||||||
AdminAuditLogItem,
|
AdminAuditLogItem,
|
||||||
|
AdminFulfillmentBindingConfigItem,
|
||||||
AdminInventoryItemListItem,
|
AdminInventoryItemListItem,
|
||||||
|
AdminObservedProductItem,
|
||||||
AdminTaskActionResponse,
|
AdminTaskActionResponse,
|
||||||
AdminDashboardSummary,
|
AdminDashboardSummary,
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
@@ -74,6 +76,21 @@ export function saveAdminAgisoShopConfigs(payload: { shops: Array<Record<string,
|
|||||||
}>('/api/v1/admin/platform-config/agiso-shops', payload)
|
}>('/api/v1/admin/platform-config/agiso-shops', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminFulfillmentBindingConfigs() {
|
||||||
|
return apiGet<{
|
||||||
|
filePath: string
|
||||||
|
bindings: AdminFulfillmentBindingConfigItem[]
|
||||||
|
observedProducts: AdminObservedProductItem[]
|
||||||
|
}>('/api/v1/admin/platform-config/fulfillment-bindings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAdminFulfillmentBindingConfigs(payload: { bindings: Array<Record<string, unknown>> }) {
|
||||||
|
return apiPost<{
|
||||||
|
filePath: string
|
||||||
|
bindings: AdminFulfillmentBindingConfigItem[]
|
||||||
|
}>('/api/v1/admin/platform-config/fulfillment-bindings', payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminOrders(params?: Record<string, unknown>) {
|
export function fetchAdminOrders(params?: Record<string, unknown>) {
|
||||||
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params)
|
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,37 @@ export interface AdminAgisoObservedShopItem {
|
|||||||
configured: boolean
|
configured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminFulfillmentBindingConfigItem {
|
||||||
|
provider: string
|
||||||
|
platform: string
|
||||||
|
shopId: string
|
||||||
|
skuCode: string
|
||||||
|
skuName: string
|
||||||
|
profileKey: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
config: Record<string, unknown>
|
||||||
|
match: {
|
||||||
|
externalSkuCode: string
|
||||||
|
externalItemId: string
|
||||||
|
externalSkuName: string
|
||||||
|
config: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminObservedProductItem {
|
||||||
|
provider: string
|
||||||
|
platform: string
|
||||||
|
shopId: string
|
||||||
|
shopName: string
|
||||||
|
externalItemId: string
|
||||||
|
externalSkuCode: string
|
||||||
|
externalSkuName: string
|
||||||
|
latestSeenAt: string | null
|
||||||
|
orderItemCount: number
|
||||||
|
configured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminDashboardSummary {
|
export interface AdminDashboardSummary {
|
||||||
todayOrders: number
|
todayOrders: number
|
||||||
paidPendingClaim: number
|
paidPendingClaim: number
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function formatAuditAction(action: string) {
|
|||||||
inventory_item_invalidated: '作废库存项',
|
inventory_item_invalidated: '作废库存项',
|
||||||
webhook_replayed: '重放 Webhook',
|
webhook_replayed: '重放 Webhook',
|
||||||
platform_shop_config_updated: '更新店铺配置',
|
platform_shop_config_updated: '更新店铺配置',
|
||||||
|
platform_fulfillment_config_updated: '更新履约配置',
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatStatusWithRaw(action, labelMap)
|
return formatStatusWithRaw(action, labelMap)
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ export const adminInventoryStatusOptions = [
|
|||||||
{ label: '已作废', value: 'invalid' },
|
{ label: '已作废', value: 'invalid' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const adminInventoryCredentialTypeOptions = [
|
||||||
|
{ label: '全部凭据类型', value: '' },
|
||||||
|
{ label: '腾讯兑换码(自动领取)', value: 'tencent_code' },
|
||||||
|
{ label: '卡号卡密', value: 'card_password' },
|
||||||
|
{ label: '账号密码', value: 'account_password' },
|
||||||
|
{ label: '激活链接', value: 'activation_link' },
|
||||||
|
{ label: '纯文本凭据', value: 'text_credential' },
|
||||||
|
]
|
||||||
|
|
||||||
export const adminWebhookProcessedOptions = [
|
export const adminWebhookProcessedOptions = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '' },
|
||||||
{ label: '处理成功', value: '1' },
|
{ label: '处理成功', value: '1' },
|
||||||
|
|||||||
@@ -0,0 +1,945 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchAdminFulfillmentBindingConfigs,
|
||||||
|
saveAdminFulfillmentBindingConfigs,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type { AdminFulfillmentBindingConfigItem, AdminObservedProductItem } from '@/types/admin'
|
||||||
|
import { hasAdminRole } from '@/utils/admin-auth'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
|
type EditableBinding = {
|
||||||
|
id: string
|
||||||
|
provider: string
|
||||||
|
platform: string
|
||||||
|
shopId: string
|
||||||
|
skuCode: string
|
||||||
|
skuName: string
|
||||||
|
profileKey: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
externalSkuCode: string
|
||||||
|
externalItemId: string
|
||||||
|
externalSkuName: string
|
||||||
|
resolvedSkuName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveBindingPayload = {
|
||||||
|
provider: string
|
||||||
|
platform: string
|
||||||
|
shopId: string
|
||||||
|
skuCode: string
|
||||||
|
skuName: string
|
||||||
|
profileKey: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
match: {
|
||||||
|
externalSkuCode: string
|
||||||
|
externalItemId: string
|
||||||
|
externalSkuName: string
|
||||||
|
config: {
|
||||||
|
resolvedSkuName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationField = 'skuCode' | 'match' | 'profileKey'
|
||||||
|
|
||||||
|
type ValidationState = {
|
||||||
|
bindingId: string
|
||||||
|
field: ValidationField
|
||||||
|
message: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
const PROFILE_OPTIONS = [
|
||||||
|
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
|
||||||
|
{ label: '人工发货', value: 'manual_review' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const filePath = ref('')
|
||||||
|
const bindings = ref<EditableBinding[]>([])
|
||||||
|
const observedProducts = ref<AdminObservedProductItem[]>([])
|
||||||
|
const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
|
||||||
|
const validationState = ref<ValidationState>(null)
|
||||||
|
const collapsedBindingIds = ref<string[]>([])
|
||||||
|
|
||||||
|
function createEmptyBinding(): EditableBinding {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
provider: 'agiso',
|
||||||
|
platform: '',
|
||||||
|
shopId: '',
|
||||||
|
skuCode: '',
|
||||||
|
skuName: '',
|
||||||
|
profileKey: 'manual_review',
|
||||||
|
enabled: true,
|
||||||
|
priority: 100,
|
||||||
|
externalSkuCode: '',
|
||||||
|
externalItemId: '',
|
||||||
|
externalSkuName: '',
|
||||||
|
resolvedSkuName: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
provider: item.provider || 'agiso',
|
||||||
|
platform: item.platform,
|
||||||
|
shopId: item.shopId,
|
||||||
|
skuCode: item.skuCode,
|
||||||
|
skuName: item.skuName,
|
||||||
|
profileKey: item.profileKey || 'manual_review',
|
||||||
|
enabled: item.enabled !== false,
|
||||||
|
priority: item.priority || 100,
|
||||||
|
externalSkuCode: item.match.externalSkuCode,
|
||||||
|
externalItemId: item.match.externalItemId,
|
||||||
|
externalSkuName: item.match.externalSkuName,
|
||||||
|
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMatchCondition(item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
|
||||||
|
return Boolean(item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableBindingComplete(item: EditableBinding) {
|
||||||
|
return Boolean(item.skuCode.trim() && hasMatchCondition(item) && VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCollapsedIds(items: EditableBinding[]) {
|
||||||
|
return items.filter(isEditableBindingComplete).map((item) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBindingCollapsed(bindingId: string) {
|
||||||
|
return collapsedBindingIds.value.includes(bindingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBindingCollapsed(bindingId: string, collapsed: boolean) {
|
||||||
|
const next = new Set(collapsedBindingIds.value)
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
next.add(bindingId)
|
||||||
|
} else {
|
||||||
|
next.delete(bindingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
collapsedBindingIds.value = Array.from(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBindingCollapsed(bindingId: string) {
|
||||||
|
setBindingCollapsed(bindingId, !isBindingCollapsed(bindingId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindingTitle(binding: EditableBinding, index: number) {
|
||||||
|
const internalName = binding.skuName.trim()
|
||||||
|
const externalName = binding.externalSkuName.trim()
|
||||||
|
const skuCode = binding.skuCode.trim()
|
||||||
|
|
||||||
|
return internalName || externalName || skuCode || `规则 ${index + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindingStatusLabel(binding: EditableBinding) {
|
||||||
|
return isEditableBindingComplete(binding) ? '已完成' : '待完善'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindingSummary(binding: EditableBinding) {
|
||||||
|
const parts = [
|
||||||
|
binding.provider.trim() || 'agiso',
|
||||||
|
binding.platform.trim() || '未选平台',
|
||||||
|
binding.shopId.trim() || '跨店铺',
|
||||||
|
binding.profileKey.trim() || 'manual_review',
|
||||||
|
]
|
||||||
|
|
||||||
|
return parts.join(' / ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigs() {
|
||||||
|
if (!hasAdminRole('admin')) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminFulfillmentBindingConfigs()
|
||||||
|
filePath.value = response.data.filePath
|
||||||
|
const nextBindings = response.data.bindings.map(mapEditableBinding)
|
||||||
|
bindings.value = nextBindings
|
||||||
|
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
|
||||||
|
observedProducts.value = response.data.observedProducts
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '读取履约配置失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBinding() {
|
||||||
|
validationState.value = null
|
||||||
|
const next = createEmptyBinding()
|
||||||
|
setBindingCollapsed(next.id, false)
|
||||||
|
bindings.value.unshift(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBinding(id: string) {
|
||||||
|
if (validationState.value?.bindingId === id) {
|
||||||
|
validationState.value = null
|
||||||
|
}
|
||||||
|
setBindingCollapsed(id, false)
|
||||||
|
bindings.value = bindings.value.filter((item) => item.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function importObservedProduct(item: AdminObservedProductItem) {
|
||||||
|
validationState.value = null
|
||||||
|
const nextBinding = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
provider: item.provider || 'agiso',
|
||||||
|
platform: item.platform,
|
||||||
|
shopId: item.shopId,
|
||||||
|
skuCode: '',
|
||||||
|
skuName: item.externalSkuName,
|
||||||
|
profileKey: 'manual_review',
|
||||||
|
enabled: true,
|
||||||
|
priority: 100,
|
||||||
|
externalSkuCode: item.externalSkuCode,
|
||||||
|
externalItemId: item.externalItemId,
|
||||||
|
externalSkuName: item.externalSkuName,
|
||||||
|
resolvedSkuName: item.externalSkuName,
|
||||||
|
}
|
||||||
|
setBindingCollapsed(nextBinding.id, false)
|
||||||
|
bindings.value.unshift(nextBinding)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
|
||||||
|
return {
|
||||||
|
provider: item.provider.trim() || 'agiso',
|
||||||
|
platform: item.platform.trim(),
|
||||||
|
shopId: item.shopId.trim(),
|
||||||
|
skuCode: item.skuCode.trim(),
|
||||||
|
skuName: item.skuName.trim(),
|
||||||
|
profileKey: item.profileKey.trim() || 'manual_review',
|
||||||
|
enabled: item.enabled,
|
||||||
|
priority: Number(item.priority || 100),
|
||||||
|
match: {
|
||||||
|
externalSkuCode: item.externalSkuCode.trim(),
|
||||||
|
externalItemId: item.externalItemId.trim(),
|
||||||
|
externalSkuName: item.externalSkuName.trim(),
|
||||||
|
config: {
|
||||||
|
resolvedSkuName: item.resolvedSkuName.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBindingContent(item: SaveBindingPayload) {
|
||||||
|
return Boolean(
|
||||||
|
item.platform
|
||||||
|
|| item.shopId
|
||||||
|
|| item.skuCode
|
||||||
|
|| item.skuName
|
||||||
|
|| item.match.externalSkuCode
|
||||||
|
|| item.match.externalItemId
|
||||||
|
|| item.match.externalSkuName
|
||||||
|
|| item.match.config.resolvedSkuName
|
||||||
|
|| item.provider !== 'agiso'
|
||||||
|
|| item.profileKey !== 'manual_review'
|
||||||
|
|| item.priority !== 100
|
||||||
|
|| item.enabled !== true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBindingValidationState(item: SaveBindingPayload, index: number, bindingId: string): ValidationState {
|
||||||
|
const label = `第 ${index + 1} 条规则`
|
||||||
|
|
||||||
|
if (!item.skuCode) {
|
||||||
|
return {
|
||||||
|
bindingId,
|
||||||
|
field: 'skuCode',
|
||||||
|
message: `${label} 还没填写“内部履约 SKU”`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.match.externalSkuCode && !item.match.externalItemId && !item.match.externalSkuName) {
|
||||||
|
return {
|
||||||
|
bindingId,
|
||||||
|
field: 'match',
|
||||||
|
message: `${label} 至少填写一种外部匹配条件`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_PROFILE_KEYS.has(item.profileKey)) {
|
||||||
|
return {
|
||||||
|
bindingId,
|
||||||
|
field: 'profileKey',
|
||||||
|
message: `${label} 的履约方式无效`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusValidationTarget(state: ValidationState) {
|
||||||
|
if (!state?.bindingId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBindingCollapsed(state.bindingId, false)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const card = document.querySelector<HTMLElement>(`[data-binding-id="${state.bindingId}"]`)
|
||||||
|
if (!card) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
|
||||||
|
const selectors: Record<ValidationField, string> = {
|
||||||
|
skuCode: '[data-field="skuCode"]',
|
||||||
|
match: '[data-field="externalSkuCode"]',
|
||||||
|
profileKey: '[data-field="profileKey"]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = card.querySelector<HTMLInputElement | HTMLSelectElement>(selectors[state.field])
|
||||||
|
target?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearValidationState() {
|
||||||
|
validationState.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBindingInvalid(bindingId: string) {
|
||||||
|
return validationState.value?.bindingId === bindingId
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName') {
|
||||||
|
if (validationState.value?.bindingId !== bindingId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationState.value.field === 'match') {
|
||||||
|
return ['externalSkuCode', 'externalItemId', 'externalSkuName'].includes(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return validationState.value.field === field
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfigs() {
|
||||||
|
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
|
||||||
|
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
|
||||||
|
const invalidState = normalizedBindings.reduce<ValidationState>((state, item, index) => {
|
||||||
|
if (state || !hasBindingContent(item)) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveBindingValidationState(item, index, bindings.value[index]?.id || '')
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
if (invalidState) {
|
||||||
|
validationState.value = invalidState
|
||||||
|
await focusValidationTarget(invalidState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindings.value.length > 0 && nonEmptyBindings.length === 0) {
|
||||||
|
validationState.value = {
|
||||||
|
bindingId: '',
|
||||||
|
field: 'skuCode',
|
||||||
|
message: '当前没有可保存的规则,请至少填写内部履约 SKU 和外部匹配条件',
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = nonEmptyBindings
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
validationState.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await saveAdminFulfillmentBindingConfigs({ bindings: payload })
|
||||||
|
filePath.value = response.data.filePath
|
||||||
|
bindings.value = response.data.bindings.map(mapEditableBinding)
|
||||||
|
ElMessage.success('履约配置已保存')
|
||||||
|
await loadConfigs()
|
||||||
|
} catch (error) {
|
||||||
|
validationState.value = {
|
||||||
|
bindingId: '',
|
||||||
|
field: 'skuCode',
|
||||||
|
message: error instanceof Error ? error.message : '保存履约配置失败',
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadConfigs)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h1>履约配置</h1>
|
||||||
|
<p>把外部商品识别成内部履约 SKU,再决定绑定库存还是转人工。保存后立即同步到后端规则表。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button round @click="loadConfigs">刷新</el-button>
|
||||||
|
<el-button round type="primary" @click="addBinding">新增规则</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护履约配置。</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="meta-card">
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">配置文件</span>
|
||||||
|
<code>{{ filePath || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">说明</span>
|
||||||
|
<span>每条规则同时定义“外部商品匹配条件”和“内部履约 SKU / 履约方式”。未命中的商品会进入人工处理。</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">字段怎么理解</span>
|
||||||
|
<div class="hint-grid">
|
||||||
|
<div class="hint-item">
|
||||||
|
<strong>外部 SKU 编码 / 外部商品 ID / 外部商品名</strong>
|
||||||
|
<span>这是平台或店铺回传的原始商品标识,用来识别你卖的是哪一种商品。</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint-item">
|
||||||
|
<strong>内部履约 SKU</strong>
|
||||||
|
<span>这是你系统里的统一商品编码,后续库存绑定、自动履约、人工处理都靠它继续往下走。</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint-item">
|
||||||
|
<strong>内部商品名</strong>
|
||||||
|
<span>主要给后台展示看,建议填你们内部真正认识的商品名。</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint-item">
|
||||||
|
<strong>履约方式</strong>
|
||||||
|
<span>`腾讯领取兑换` 表示走自动库存链路,`人工发货` 表示落到人工处理。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">内部履约 SKU 怎么填</span>
|
||||||
|
<div class="sku-guide">
|
||||||
|
<span>推荐用你们内部统一命名,不要直接抄店铺里的外部 SKU。</span>
|
||||||
|
<span>建议格式:`游戏或业务_商品类型_规格`,例如 `delta_havabi_500`、`dnf_cdk_blackdiamond`、`manual_peripheral_common`。</span>
|
||||||
|
<span>原则是“同一种履约商品,无论来自哪个平台、哪个店铺,都尽量落到同一个内部履约 SKU”。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||||
|
<div v-if="loading" class="empty-block">履约配置加载中</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="table-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<div>
|
||||||
|
<h3>已配置规则</h3>
|
||||||
|
<p>建议至少填写平台、店铺、外部 SKU 编码或外部商品名,再指定内部履约 SKU 和履约档案。</p>
|
||||||
|
</div>
|
||||||
|
<el-button :loading="saving" round type="primary" @click="saveConfigs">保存配置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="validationState?.message" class="validation-banner">{{ validationState.message }}</p>
|
||||||
|
|
||||||
|
<div v-if="bindings.length === 0" class="empty-inline">当前还没有履约规则,先新增一条。</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(binding, index) in bindings"
|
||||||
|
:key="binding.id"
|
||||||
|
:data-binding-id="binding.id"
|
||||||
|
:class="['binding-card', { 'binding-card--invalid': isBindingInvalid(binding.id) }]"
|
||||||
|
>
|
||||||
|
<div class="binding-header">
|
||||||
|
<div class="binding-summary">
|
||||||
|
<div class="binding-title-row">
|
||||||
|
<strong>{{ getBindingTitle(binding, index) }}</strong>
|
||||||
|
<span :class="['binding-status-chip', { 'binding-status-chip--done': isEditableBindingComplete(binding) }]">
|
||||||
|
{{ getBindingStatusLabel(binding) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="binding-subtle">{{ getBindingSummary(binding) }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="collapse-button" type="button" @click="toggleBindingCollapsed(binding.id)">
|
||||||
|
{{ isBindingCollapsed(binding.id) ? '展开' : '折叠' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isBindingCollapsed(binding.id)" class="binding-collapsed-preview">
|
||||||
|
<span>外部商品:{{ binding.externalSkuName || binding.externalSkuCode || binding.externalItemId || '-' }}</span>
|
||||||
|
<span>内部履约 SKU:{{ binding.skuCode || '-' }}</span>
|
||||||
|
<span>履约方式:{{ PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label || binding.profileKey }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="binding-grid">
|
||||||
|
<label class="field-block">
|
||||||
|
<span>渠道</span>
|
||||||
|
<input v-model="binding.provider" class="text-input" placeholder="agiso" @input="clearValidationState" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>平台</span>
|
||||||
|
<input v-model="binding.platform" class="text-input" placeholder="xianyu / taobao / pdd" @input="clearValidationState" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>店铺 ID</span>
|
||||||
|
<input v-model="binding.shopId" class="text-input" placeholder="留空表示跨店铺通用" @input="clearValidationState" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>优先级</span>
|
||||||
|
<input v-model.number="binding.priority" class="text-input" type="number" min="1" @input="clearValidationState" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>外部 SKU 编码</span>
|
||||||
|
<input
|
||||||
|
v-model="binding.externalSkuCode"
|
||||||
|
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') }]"
|
||||||
|
data-field="externalSkuCode"
|
||||||
|
placeholder="例如 6046726460016"
|
||||||
|
@input="clearValidationState"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>外部商品 ID</span>
|
||||||
|
<input
|
||||||
|
v-model="binding.externalItemId"
|
||||||
|
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') }]"
|
||||||
|
data-field="externalItemId"
|
||||||
|
placeholder="例如 1033324289962"
|
||||||
|
@input="clearValidationState"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block field-wide">
|
||||||
|
<span>外部商品名</span>
|
||||||
|
<input
|
||||||
|
v-model="binding.externalSkuName"
|
||||||
|
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') }]"
|
||||||
|
data-field="externalSkuName"
|
||||||
|
placeholder="例如 奥利奥动作"
|
||||||
|
@input="clearValidationState"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>内部履约 SKU</span>
|
||||||
|
<input
|
||||||
|
v-model="binding.skuCode"
|
||||||
|
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') }]"
|
||||||
|
data-field="skuCode"
|
||||||
|
placeholder="例如 sjz_hdl_test01"
|
||||||
|
@input="clearValidationState"
|
||||||
|
/>
|
||||||
|
<small class="field-help">填你们系统内部统一 SKU。相同履约商品,尽量跨平台共用同一个内部 SKU。</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>内部商品名</span>
|
||||||
|
<input v-model="binding.skuName" class="text-input" placeholder="用于后台展示" @input="clearValidationState" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>履约方式</span>
|
||||||
|
<select
|
||||||
|
v-model="binding.profileKey"
|
||||||
|
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') }]"
|
||||||
|
data-field="profileKey"
|
||||||
|
@change="clearValidationState"
|
||||||
|
>
|
||||||
|
<option v-for="item in PROFILE_OPTIONS" :key="item.value" :value="item.value">
|
||||||
|
{{ item.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>匹配后展示名</span>
|
||||||
|
<input
|
||||||
|
v-model="binding.resolvedSkuName"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="例如 三角洲行动-自动领取"
|
||||||
|
@input="clearValidationState"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">{{ validationState?.message }}</p>
|
||||||
|
|
||||||
|
<div class="binding-actions">
|
||||||
|
<label class="checkbox-line">
|
||||||
|
<input v-model="binding.enabled" type="checkbox" />
|
||||||
|
<span>启用规则</span>
|
||||||
|
</label>
|
||||||
|
<el-button link type="danger" @click="removeBinding(binding.id)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<div>
|
||||||
|
<h3>近期识别到的外部商品</h3>
|
||||||
|
<p>从已入库订单里提取的外部商品信息,可一键导入规则后补充内部履约 SKU。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>来源</th>
|
||||||
|
<th>外部商品</th>
|
||||||
|
<th>订单商品数</th>
|
||||||
|
<th>最近出现时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in observedProducts" :key="`${item.provider}:${item.platform}:${item.shopId}:${item.externalSkuCode}:${item.externalItemId}:${item.externalSkuName}`">
|
||||||
|
<td>
|
||||||
|
<div class="cell-stack">
|
||||||
|
<strong>{{ item.platform || '-' }}</strong>
|
||||||
|
<span class="cell-subtle">{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="cell-stack">
|
||||||
|
<strong>{{ item.externalSkuName || item.externalSkuCode || item.externalItemId || '-' }}</strong>
|
||||||
|
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
|
||||||
|
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.orderItemCount }}</td>
|
||||||
|
<td>{{ formatAdminDateTime(item.latestSeenAt) }}</td>
|
||||||
|
<td>{{ item.configured ? '已配置' : '未配置' }}</td>
|
||||||
|
<td>
|
||||||
|
<el-button
|
||||||
|
v-if="!item.configured"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="importObservedProduct(item)"
|
||||||
|
>
|
||||||
|
导入到规则
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="cell-subtle">已存在</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="observedProducts.length === 0">
|
||||||
|
<td colspan="6" class="empty-inline">最近还没有识别到可用的外部商品。</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header,
|
||||||
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h1,
|
||||||
|
.section-title-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1d3555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header p,
|
||||||
|
.section-title-row p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-card,
|
||||||
|
.table-card,
|
||||||
|
.empty-block,
|
||||||
|
.error-copy {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-grid,
|
||||||
|
.sku-guide {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item strong {
|
||||||
|
color: #1d3555;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item span,
|
||||||
|
.sku-guide span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-line {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-card {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-card--invalid {
|
||||||
|
border-color: rgba(220, 38, 38, 0.3);
|
||||||
|
box-shadow: 0 0 0 4px rgba(254, 226, 226, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #1d3555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-subtle {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
background: #fff1f2;
|
||||||
|
color: #be123c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-status-chip--done {
|
||||||
|
background: #ecfdf3;
|
||||||
|
color: #027a48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-button {
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: #1d3555;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-collapsed-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-banner {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff4f2;
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.18);
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-block span {
|
||||||
|
color: #475467;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input--invalid {
|
||||||
|
border-color: rgba(220, 38, 38, 0.45);
|
||||||
|
background: #fff8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input--invalid:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(220, 38, 38, 0.6);
|
||||||
|
box-shadow: 0 0 0 3px rgba(254, 226, 226, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-inline-error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-line {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||||||
|
color: #334155;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-subtle {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block,
|
||||||
|
.empty-inline {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-copy {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.panel-header,
|
||||||
|
.section-title-row,
|
||||||
|
.binding-actions,
|
||||||
|
.binding-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-grid,
|
||||||
|
.binding-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,8 @@ const navItems = computed(() => {
|
|||||||
|
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
baseItems.splice(1, 0, { to: '/admin/users', label: '用户' })
|
baseItems.splice(1, 0, { to: '/admin/users', label: '用户' })
|
||||||
baseItems.push({ to: '/admin/platform-shops', label: '店铺配置' })
|
baseItems.push({ to: '/admin/platform-shops', label: 'Agiso店铺' })
|
||||||
|
baseItems.push({ to: '/admin/platform-fulfillment', label: '履约配置' })
|
||||||
baseItems.push({ to: '/admin/audit-logs', label: '审计' })
|
baseItems.push({ to: '/admin/audit-logs', label: '审计' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ onMounted(loadConfigs)
|
|||||||
<section class="admin-panel">
|
<section class="admin-panel">
|
||||||
<header class="panel-header">
|
<header class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>店铺配置</h1>
|
<h1>Agiso 店铺配置</h1>
|
||||||
<p>Agiso 多店铺配置改为文件维护。保存后立即生效,不需要再手改 `.env`。</p>
|
<p>Agiso 多店铺配置改为文件维护。保存后立即生效,不需要再手改 `.env`。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ services:
|
|||||||
TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false}
|
TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false}
|
||||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
||||||
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
||||||
ORDER_FULFILLMENT_BINDINGS_JSON: ${ORDER_FULFILLMENT_BINDINGS_JSON:-[]}
|
|
||||||
AGISO_APP_SECRET: ${AGISO_APP_SECRET:-}
|
AGISO_APP_SECRET: ${AGISO_APP_SECRET:-}
|
||||||
AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false}
|
AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false}
|
||||||
AGISO_APP_ID: ${AGISO_APP_ID:-}
|
AGISO_APP_ID: ${AGISO_APP_ID:-}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ services:
|
|||||||
TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false}
|
TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false}
|
||||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
||||||
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
||||||
ORDER_FULFILLMENT_BINDINGS_JSON: ${ORDER_FULFILLMENT_BINDINGS_JSON:-[]}
|
|
||||||
AGISO_APP_SECRET: ${AGISO_APP_SECRET:-}
|
AGISO_APP_SECRET: ${AGISO_APP_SECRET:-}
|
||||||
AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false}
|
AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false}
|
||||||
AGISO_APP_ID: ${AGISO_APP_ID:-}
|
AGISO_APP_ID: ${AGISO_APP_ID:-}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"item_id": 1033324289962,
|
||||||
|
"seller_id": 2209880145223,
|
||||||
|
"MsgTypeDes": "订单已付款",
|
||||||
|
"biz_order_id": "2701827483088138281",
|
||||||
|
"order_status": 2,
|
||||||
|
"_agisoTradeDetail": {
|
||||||
|
"sku": "6046726460016|商品名称:奥利奥动作",
|
||||||
|
"item": {
|
||||||
|
"price": 70,
|
||||||
|
"title": "【自动发货】蛋仔派对奥利奥皮肤、奥利奥呦呦、奥利奥动作",
|
||||||
|
"item_id": 1033324289962,
|
||||||
|
"pic_url": "https://gw.alicdn.com/bao/uploaded/i1/2209880145223/O1CN01QAHaJL1oSBmQaGffk_!!4611686018427387207-53-xy_item.heic"
|
||||||
|
},
|
||||||
|
"payment": 70,
|
||||||
|
"end_time": 0,
|
||||||
|
"pay_time": 1775953431000,
|
||||||
|
"post_fee": 0,
|
||||||
|
"isEticket": false,
|
||||||
|
"ship_time": 1775953434000,
|
||||||
|
"buy_amount": 1,
|
||||||
|
"buyer_nick": "梦里梦",
|
||||||
|
"isRecharge": false,
|
||||||
|
"create_time": 1775953408000,
|
||||||
|
"seller_nick": "大锤号商",
|
||||||
|
"biz_order_id": "2701827483088138281",
|
||||||
|
"order_status": 3,
|
||||||
|
"coupon_eticket_info": [],
|
||||||
|
"encryption_buyer_id": "enV7xJs5DRbJBzIH8uUUcg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 商品识别与履约 SKU 配置
|
||||||
|
|
||||||
|
当前后端已支持把外部商品先识别为内部履约 SKU,再继续匹配库存或人工履约。
|
||||||
|
配置来源已经统一为后台管理页和后端数据文件,不再使用 `.env` 里的 `ORDER_FULFILLMENT_BINDINGS_JSON`。
|
||||||
|
|
||||||
|
## 配置目标
|
||||||
|
|
||||||
|
- `match` 负责识别外部商品
|
||||||
|
- `skuCode` 代表内部履约 SKU
|
||||||
|
- `profileKey` 决定后续走库存自动链路还是人工链路
|
||||||
|
|
||||||
|
## 推荐配置格式
|
||||||
|
|
||||||
|
后台保存后会写入后端数据文件:
|
||||||
|
|
||||||
|
- `/app/data/order-fulfillment-bindings.json`
|
||||||
|
|
||||||
|
配置结构如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"provider": "agiso",
|
||||||
|
"platform": "xianyu",
|
||||||
|
"shopId": "2209880145223",
|
||||||
|
"skuCode": "sjz_hdl_test01",
|
||||||
|
"profileKey": "tencent_claim_redeem",
|
||||||
|
"priority": 10,
|
||||||
|
"config": {
|
||||||
|
"note": "三角洲行动腾讯兑换码自动领取"
|
||||||
|
},
|
||||||
|
"match": {
|
||||||
|
"externalSkuCode": "6228372634012",
|
||||||
|
"externalItemId": "1040695550204",
|
||||||
|
"externalSkuName": "周边",
|
||||||
|
"config": {
|
||||||
|
"resolvedSkuName": "三角洲行动-自动领取"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"provider": "agiso",
|
||||||
|
"platform": "xianyu",
|
||||||
|
"shopId": "2209880145223",
|
||||||
|
"skuCode": "danzai_manual_special",
|
||||||
|
"profileKey": "manual_review",
|
||||||
|
"priority": 20,
|
||||||
|
"match": {
|
||||||
|
"externalSkuCode": "7000000000001",
|
||||||
|
"externalSkuName": "需要人工发货的商品"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 匹配顺序
|
||||||
|
|
||||||
|
系统会按下面顺序匹配外部商品:
|
||||||
|
|
||||||
|
1. `externalSkuCode`
|
||||||
|
2. `externalItemId`
|
||||||
|
3. 归一化后的 `externalSkuName`
|
||||||
|
4. 名称包含关系兜底
|
||||||
|
|
||||||
|
其中店铺级规则优先于平台级和全局级规则。
|
||||||
|
|
||||||
|
## 当前落库行为
|
||||||
|
|
||||||
|
- 订单商品的 `sku_code` 会保存为内部履约 SKU
|
||||||
|
- 原始外部商品信息会保留在 `item_snapshot_json`
|
||||||
|
- 后续履约任务、库存预占、领取链接都会按内部履约 SKU 继续执行
|
||||||
|
|
||||||
|
## 生效方式
|
||||||
|
|
||||||
|
- 后台保存后会立即同步到 `sku_fulfillment_bindings`
|
||||||
|
- 同时会同步到 `product_match_rules`
|
||||||
|
- 未匹配到规则的商品会自动落到人工处理
|
||||||
Reference in New Issue
Block a user