fix(cost-center): 归档已被其他渠道接管的人工商品
This commit is contained in:
@@ -10,6 +10,7 @@ import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-as
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
archiveHistoricalManualProducts,
|
archiveHistoricalManualProducts,
|
||||||
|
archiveSupersededManualProducts,
|
||||||
backfillAdminNormalizedQuantities,
|
backfillAdminNormalizedQuantities,
|
||||||
backfillAdminProductMappings,
|
backfillAdminProductMappings,
|
||||||
createAdminChannelMapping,
|
createAdminChannelMapping,
|
||||||
@@ -178,6 +179,22 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/cost-center/products/archive-superseded-manual',
|
||||||
|
requireAdminPermission('finance.config'),
|
||||||
|
createJsonHandler((req) => archiveSupersededManualProducts(bodyOf(req)), {
|
||||||
|
successMessage: '重复人工归一商品已归档',
|
||||||
|
errorMessage: '归档重复人工归一商品失败',
|
||||||
|
scope: '[admin/cost-center/products/archive-superseded-manual]',
|
||||||
|
audit: (_req, data) => ({
|
||||||
|
action: 'cost_superseded_manual_products_archived',
|
||||||
|
targetType: 'product_catalog',
|
||||||
|
targetId: '',
|
||||||
|
data: data as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.patch(
|
router.patch(
|
||||||
'/cost-center/products/:productId',
|
'/cost-center/products/:productId',
|
||||||
requireAdminPermission('finance.config'),
|
requireAdminPermission('finance.config'),
|
||||||
|
|||||||
@@ -34,12 +34,27 @@ export function productSourceOf(canonicalSku: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
|
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
|
||||||
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
|
export function productSourceFilterSql(
|
||||||
|
paramIndex: number,
|
||||||
|
column = 'p.canonical_sku',
|
||||||
|
nameColumn = 'p.canonical_name',
|
||||||
|
): string {
|
||||||
return `(
|
return `(
|
||||||
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
||||||
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
||||||
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
|
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
|
||||||
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
|
OR (
|
||||||
|
$${paramIndex} = 'manual'
|
||||||
|
AND ${column} LIKE 'man:%'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM internal_sku_channels live_channel
|
||||||
|
WHERE live_channel.enabled = TRUE
|
||||||
|
AND live_channel.is_fallback = FALSE
|
||||||
|
AND live_channel.channel <> 'manual'
|
||||||
|
AND BTRIM(live_channel.internal_sku) = BTRIM(${nameColumn})
|
||||||
|
)
|
||||||
|
)
|
||||||
)`
|
)`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +136,7 @@ export function mapProduct(row: ProductRow) {
|
|||||||
row.template_sharing_enabled === null || row.template_sharing_enabled === undefined
|
row.template_sharing_enabled === null || row.template_sharing_enabled === undefined
|
||||||
? null
|
? null
|
||||||
: row.template_sharing_enabled === true,
|
: row.template_sharing_enabled === true,
|
||||||
|
activePrimaryChannel: row.active_primary_channel || null,
|
||||||
affiliateTaskCount: Number(row.affiliate_task_count || 0),
|
affiliateTaskCount: Number(row.affiliate_task_count || 0),
|
||||||
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
|
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
|
|||||||
@@ -42,7 +42,18 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
|||||||
OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%')
|
OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%')
|
||||||
OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%'))
|
OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%'))
|
||||||
OR ($1 = 'third_party_voucher' AND p.canonical_sku LIKE 'tpv:%')
|
OR ($1 = 'third_party_voucher' AND p.canonical_sku LIKE 'tpv:%')
|
||||||
OR ($1 = 'manual' AND p.canonical_sku LIKE 'man:%')
|
OR (
|
||||||
|
$1 = 'manual'
|
||||||
|
AND p.canonical_sku LIKE 'man:%'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM internal_sku_channels live_channel
|
||||||
|
WHERE live_channel.enabled = TRUE
|
||||||
|
AND live_channel.is_fallback = FALSE
|
||||||
|
AND live_channel.channel <> 'manual'
|
||||||
|
AND BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
scoped_cost_models AS (
|
scoped_cost_models AS (
|
||||||
@@ -99,11 +110,21 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
|||||||
)
|
)
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
const [productSourceResult, costModelResult] = await Promise.all([
|
const [productSourceResult, costModelResult] = await Promise.all([
|
||||||
query<{ canonical_sku: string; total: number }>(
|
query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>(
|
||||||
`SELECT canonical_sku, COUNT(*)::int AS total
|
`SELECT p.canonical_sku,
|
||||||
FROM product_catalog
|
(
|
||||||
WHERE status = 'active'
|
SELECT c.channel
|
||||||
GROUP BY canonical_sku`,
|
FROM internal_sku_channels c
|
||||||
|
WHERE c.enabled = TRUE
|
||||||
|
AND c.is_fallback = FALSE
|
||||||
|
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
|
||||||
|
ORDER BY c.priority, c.channel
|
||||||
|
LIMIT 1
|
||||||
|
) AS active_primary_channel,
|
||||||
|
COUNT(*)::int AS total
|
||||||
|
FROM product_catalog p
|
||||||
|
WHERE p.status = 'active'
|
||||||
|
GROUP BY p.canonical_sku, p.canonical_name`,
|
||||||
),
|
),
|
||||||
query<{ cost_model: string; total: number }>(
|
query<{ cost_model: string; total: number }>(
|
||||||
`SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`,
|
`SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`,
|
||||||
@@ -137,7 +158,9 @@ function normalizeScope(raw: unknown): CostCenterScope | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
|
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
|
||||||
function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) {
|
function productSourceCountsOf(
|
||||||
|
rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>,
|
||||||
|
) {
|
||||||
const counts: Record<string, number> = {
|
const counts: Record<string, number> = {
|
||||||
affiliate_dash: 0,
|
affiliate_dash: 0,
|
||||||
work_order: 0,
|
work_order: 0,
|
||||||
@@ -146,6 +169,14 @@ function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: numbe
|
|||||||
}
|
}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const source = productSourceOf(row.canonical_sku)
|
const source = productSourceOf(row.canonical_sku)
|
||||||
|
// man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。
|
||||||
|
if (
|
||||||
|
source === 'manual' &&
|
||||||
|
row.active_primary_channel &&
|
||||||
|
row.active_primary_channel !== 'manual'
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
const key = source || 'uncategorized'
|
const key = source || 'uncategorized'
|
||||||
counts[key] = (counts[key] || 0) + toNumber(row.total)
|
counts[key] = (counts[key] || 0) + toNumber(row.total)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type ProductRow = {
|
|||||||
template_pricing_mode?: string | null
|
template_pricing_mode?: string | null
|
||||||
template_enabled?: boolean | null
|
template_enabled?: boolean | null
|
||||||
template_sharing_enabled?: boolean | null
|
template_sharing_enabled?: boolean | null
|
||||||
|
active_primary_channel?: string | null
|
||||||
affiliate_task_count?: number
|
affiliate_task_count?: number
|
||||||
affiliate_last_fulfilled_at?: string | null
|
affiliate_last_fulfilled_at?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,6 +291,13 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
|
|||||||
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
|
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => {
|
||||||
|
const sql = productSourceFilterSql(1)
|
||||||
|
assert.ok(sql.includes(`$1 = 'manual'`))
|
||||||
|
assert.ok(sql.includes("live_channel.channel <> 'manual'"))
|
||||||
|
assert.ok(sql.includes('BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)'))
|
||||||
|
})
|
||||||
|
|
||||||
test('tpv: 商品来源归为第三方卡券', async () => {
|
test('tpv: 商品来源归为第三方卡券', async () => {
|
||||||
const { productSourceOf } = await import('./cost-center-mappers.js')
|
const { productSourceOf } = await import('./cost-center-mappers.js')
|
||||||
const assert = (await import('node:assert/strict')).default
|
const assert = (await import('node:assert/strict')).default
|
||||||
|
|||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
/** 旧人工归一商品清理集成测试(真实 PostgreSQL)。 */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const TEST_DATABASE_URL = String(
|
||||||
|
process.env.LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL || '',
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
test(
|
||||||
|
'已被其他主渠道接管的历史 man 商品只归档并修正兜底指向',
|
||||||
|
{ skip: !TEST_DATABASE_URL ? '未设置 LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL' : false },
|
||||||
|
async () => {
|
||||||
|
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||||
|
const { closeDb, query } = await import('../../db/client.js')
|
||||||
|
const { archiveSupersededManualProducts } = await import('./product-catalog-service.js')
|
||||||
|
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||||
|
const internalSku = `清理测试人工商品-${suffix}`
|
||||||
|
const manSku = `man:${internalSku}`
|
||||||
|
const liveSku = `aff:cleanup-${suffix}`
|
||||||
|
let manProductId = 0
|
||||||
|
let liveProductId = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inserted = await query<{ id: number; canonical_sku: string }>(
|
||||||
|
`
|
||||||
|
INSERT INTO product_catalog (
|
||||||
|
canonical_sku, canonical_name, category, unit_name, status,
|
||||||
|
attributes_json, created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
($1, $3, '人工客服', '件', 'active', '{"source":"manual_normalization"}'::jsonb, NOW(), NOW()),
|
||||||
|
($2, $3, '自动发货', '件', 'active', '{}'::jsonb, NOW(), NOW())
|
||||||
|
RETURNING id, canonical_sku
|
||||||
|
`,
|
||||||
|
[manSku, liveSku, internalSku],
|
||||||
|
)
|
||||||
|
manProductId = Number(inserted.rows.find((row) => row.canonical_sku === manSku)?.id)
|
||||||
|
liveProductId = Number(inserted.rows.find((row) => row.canonical_sku === liveSku)?.id)
|
||||||
|
assert.ok(manProductId > 0)
|
||||||
|
assert.ok(liveProductId > 0)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
INSERT INTO internal_sku_channels (
|
||||||
|
internal_sku, channel, priority, is_fallback, enabled,
|
||||||
|
canonical_product_id, config_json, source, created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
($1, 'affiliate_dash', 10, FALSE, TRUE, $3, '{}'::jsonb, 'test', NOW(), NOW()),
|
||||||
|
($1, 'manual', 90, TRUE, TRUE, $2, '{}'::jsonb, 'test', NOW(), NOW())
|
||||||
|
`,
|
||||||
|
[internalSku, manProductId, liveProductId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const preview = await archiveSupersededManualProducts()
|
||||||
|
assert.equal(preview.candidateCount, 1)
|
||||||
|
assert.ok(preview.sample.some((row) => Number(row.id) === manProductId))
|
||||||
|
|
||||||
|
const result = await archiveSupersededManualProducts({ execute: true })
|
||||||
|
assert.equal(result.archivedCount, 1)
|
||||||
|
assert.equal(result.repointedChannelCount, 1)
|
||||||
|
|
||||||
|
const products = await query<{ canonical_sku: string; status: string }>(
|
||||||
|
'SELECT canonical_sku, status FROM product_catalog WHERE id = ANY($1::bigint[])',
|
||||||
|
[[manProductId, liveProductId]],
|
||||||
|
)
|
||||||
|
assert.equal(products.rows.find((row) => row.canonical_sku === manSku)?.status, 'archived')
|
||||||
|
assert.equal(products.rows.find((row) => row.canonical_sku === liveSku)?.status, 'active')
|
||||||
|
const fallback = await query<{ canonical_product_id: number }>(
|
||||||
|
`SELECT canonical_product_id
|
||||||
|
FROM internal_sku_channels
|
||||||
|
WHERE internal_sku = $1 AND channel = 'manual'`,
|
||||||
|
[internalSku],
|
||||||
|
)
|
||||||
|
assert.equal(Number(fallback.rows[0]?.canonical_product_id), liveProductId)
|
||||||
|
} finally {
|
||||||
|
if (manProductId > 0 || liveProductId > 0) {
|
||||||
|
await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku])
|
||||||
|
await query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [
|
||||||
|
[manProductId, liveProductId],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
await closeDb()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -160,6 +160,15 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
|||||||
END AS template_pricing_mode,
|
END AS template_pricing_mode,
|
||||||
tpl_rule.enabled AS template_enabled,
|
tpl_rule.enabled AS template_enabled,
|
||||||
tpl_rule.sharing_enabled AS template_sharing_enabled,
|
tpl_rule.sharing_enabled AS template_sharing_enabled,
|
||||||
|
(
|
||||||
|
SELECT c.channel
|
||||||
|
FROM internal_sku_channels c
|
||||||
|
WHERE c.enabled = TRUE
|
||||||
|
AND c.is_fallback = FALSE
|
||||||
|
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
|
||||||
|
ORDER BY c.priority, c.channel
|
||||||
|
LIMIT 1
|
||||||
|
) AS active_primary_channel,
|
||||||
CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id,
|
CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id,
|
||||||
CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name,
|
CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name,
|
||||||
root.id AS root_category_id,
|
root.id AS root_category_id,
|
||||||
@@ -481,6 +490,114 @@ export async function archiveHistoricalManualProducts(payload: JsonObject = {})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SupersededManualProductRow = {
|
||||||
|
id: number
|
||||||
|
canonical_sku: string
|
||||||
|
canonical_name: string
|
||||||
|
primary_channel: string
|
||||||
|
target_product_id: number
|
||||||
|
target_product_sku: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档已被其他渠道主规则接管的旧人工归一商品。
|
||||||
|
*
|
||||||
|
* `man:` 只表示旧人工来源,不代表当前主渠道。同名内部 SKU 已有自动发货、
|
||||||
|
* 接单平台或卡券主渠道后,旧 man: 商品不再参与新路由,只需从当前目录归档。
|
||||||
|
* 历史订单、成本版本和成本快照全部保留;仍指向旧商品的手动兜底改指当前主商品。
|
||||||
|
*/
|
||||||
|
export async function archiveSupersededManualProducts(payload: JsonObject = {}) {
|
||||||
|
const execute = payload.execute === true || payload.execute === 'true'
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const candidates = await client.query<SupersededManualProductRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.canonical_sku,
|
||||||
|
p.canonical_name,
|
||||||
|
live.channel AS primary_channel,
|
||||||
|
target.id AS target_product_id,
|
||||||
|
target.canonical_sku AS target_product_sku
|
||||||
|
FROM product_catalog p
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT c.channel, c.canonical_product_id
|
||||||
|
FROM internal_sku_channels c
|
||||||
|
WHERE c.enabled = TRUE
|
||||||
|
AND c.is_fallback = FALSE
|
||||||
|
AND c.channel <> 'manual'
|
||||||
|
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
|
||||||
|
ORDER BY c.priority, c.channel
|
||||||
|
LIMIT 1
|
||||||
|
) live ON TRUE
|
||||||
|
JOIN product_catalog target
|
||||||
|
ON target.id = live.canonical_product_id
|
||||||
|
AND target.status = 'active'
|
||||||
|
WHERE p.status = 'active'
|
||||||
|
AND p.canonical_sku LIKE 'man:%'
|
||||||
|
AND p.attributes_json->>'source' = 'manual_normalization'
|
||||||
|
ORDER BY p.id
|
||||||
|
FOR UPDATE OF p
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
const rows = candidates.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
id: Number(row.id),
|
||||||
|
target_product_id: Number(row.target_product_id),
|
||||||
|
}))
|
||||||
|
if (!execute || rows.length === 0) {
|
||||||
|
return {
|
||||||
|
dryRun: !execute,
|
||||||
|
candidateCount: rows.length,
|
||||||
|
archivedCount: 0,
|
||||||
|
repointedChannelCount: 0,
|
||||||
|
sample: rows.slice(0, 20),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repointed = await client.query(
|
||||||
|
`
|
||||||
|
UPDATE internal_sku_channels c
|
||||||
|
SET canonical_product_id = input.target_product_id,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM unnest($1::text[], $2::bigint[], $3::bigint[])
|
||||||
|
AS input(internal_sku, source_product_id, target_product_id)
|
||||||
|
WHERE c.internal_sku = input.internal_sku
|
||||||
|
AND c.channel = 'manual'
|
||||||
|
AND c.canonical_product_id = input.source_product_id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
rows.map((row) => row.canonical_name),
|
||||||
|
rows.map((row) => row.id),
|
||||||
|
rows.map((row) => row.target_product_id),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const archived = await client.query(
|
||||||
|
`
|
||||||
|
UPDATE product_catalog
|
||||||
|
SET status = 'archived',
|
||||||
|
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ANY($2::bigint[]) AND status = 'active'
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
legacyManualArchived: true,
|
||||||
|
legacyManualArchiveReason: 'superseded_by_active_primary_channel',
|
||||||
|
legacyManualArchivedAt: nowIso(),
|
||||||
|
}),
|
||||||
|
rows.map((row) => row.id),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
dryRun: false,
|
||||||
|
candidateCount: rows.length,
|
||||||
|
archivedCount: archived.rowCount || 0,
|
||||||
|
repointedChannelCount: repointed.rowCount || 0,
|
||||||
|
sample: rows.slice(0, 20),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type MappingProductGroupRow = {
|
type MappingProductGroupRow = {
|
||||||
canonical_product_id: number
|
canonical_product_id: number
|
||||||
canonical_sku: string
|
canonical_sku: string
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import dayjs from '@/lib/dayjs'
|
|||||||
import {
|
import {
|
||||||
applyAdminTemplateProductBootstrap,
|
applyAdminTemplateProductBootstrap,
|
||||||
archiveAdminHistoricalManualProducts,
|
archiveAdminHistoricalManualProducts,
|
||||||
|
archiveAdminSupersededManualProducts,
|
||||||
createAdminProductCatalog,
|
createAdminProductCatalog,
|
||||||
deleteAdminProductCatalog,
|
deleteAdminProductCatalog,
|
||||||
fetchAdminProductCatalog,
|
fetchAdminProductCatalog,
|
||||||
@@ -56,6 +57,7 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
|
|||||||
|
|
||||||
import { useCostCenter } from './CostCenterContext'
|
import { useCostCenter } from './CostCenterContext'
|
||||||
import {
|
import {
|
||||||
|
FULFILLMENT_MODE_LABELS,
|
||||||
PRODUCT_SOURCE_OPTIONS,
|
PRODUCT_SOURCE_OPTIONS,
|
||||||
PRODUCT_SOURCE_TAG_COLORS,
|
PRODUCT_SOURCE_TAG_COLORS,
|
||||||
labelOf,
|
labelOf,
|
||||||
@@ -108,6 +110,7 @@ export function ProductCatalogPanel() {
|
|||||||
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
|
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
|
||||||
const [bootstrapApplying, setBootstrapApplying] = useState(false)
|
const [bootstrapApplying, setBootstrapApplying] = useState(false)
|
||||||
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
|
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
|
||||||
|
const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false)
|
||||||
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
|
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
|
||||||
const [productPage, setProductPage] = useState(1)
|
const [productPage, setProductPage] = useState(1)
|
||||||
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||||
@@ -487,6 +490,41 @@ export function ProductCatalogPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function archiveSupersededManuals() {
|
||||||
|
setArchivingSupersededManuals(true)
|
||||||
|
try {
|
||||||
|
const preview = (await archiveAdminSupersededManualProducts()).data
|
||||||
|
if (preview.candidateCount === 0) {
|
||||||
|
message.info('没有已被其他渠道接管的人工归一商品')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal.confirm({
|
||||||
|
title: '归档跨渠道重复的人工归一商品',
|
||||||
|
content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`,
|
||||||
|
okText: '确认归档',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setArchivingSupersededManuals(true)
|
||||||
|
try {
|
||||||
|
const result = (await archiveAdminSupersededManualProducts({ execute: true })).data
|
||||||
|
message.success(
|
||||||
|
`已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`,
|
||||||
|
)
|
||||||
|
refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败')
|
||||||
|
} finally {
|
||||||
|
setArchivingSupersededManuals(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败')
|
||||||
|
} finally {
|
||||||
|
setArchivingSupersededManuals(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function deleteProduct(item: ProductCatalogItem) {
|
function deleteProduct(item: ProductCatalogItem) {
|
||||||
const affiliateHint =
|
const affiliateHint =
|
||||||
item.source === 'affiliate_dash'
|
item.source === 'affiliate_dash'
|
||||||
@@ -554,6 +592,12 @@ export function ProductCatalogPanel() {
|
|||||||
{row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'}
|
{row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
|
{row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? (
|
||||||
|
<Tag color="purple" style={{ marginInlineEnd: 0 }}>
|
||||||
|
当前主渠道:
|
||||||
|
{FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -716,13 +760,22 @@ export function ProductCatalogPanel() {
|
|||||||
canConfig ? (
|
canConfig ? (
|
||||||
<Space size={6} wrap>
|
<Space size={6} wrap>
|
||||||
{productSourceFilter === 'manual' ? (
|
{productSourceFilter === 'manual' ? (
|
||||||
<Button
|
<>
|
||||||
icon={<ClearOutlined />}
|
<Button
|
||||||
loading={archivingHistoricalManuals}
|
icon={<ClearOutlined />}
|
||||||
onClick={() => void archiveHistoricalManuals()}
|
loading={archivingSupersededManuals}
|
||||||
>
|
onClick={() => void archiveSupersededManuals()}
|
||||||
归档历史人工商品
|
>
|
||||||
</Button>
|
归档跨渠道重复商品
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
loading={archivingHistoricalManuals}
|
||||||
|
onClick={() => void archiveHistoricalManuals()}
|
||||||
|
>
|
||||||
|
归档纯历史人工商品
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{productSourceFilter === 'work_order' ? (
|
{productSourceFilter === 'work_order' ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ export function archiveAdminHistoricalManualProducts(payload: { execute?: boolea
|
|||||||
}>('/api/v1/admin/cost-center/products/archive-historical-manual', payload)
|
}>('/api/v1/admin/cost-center/products/archive-historical-manual', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function archiveAdminSupersededManualProducts(payload: { execute?: boolean } = {}) {
|
||||||
|
return apiPost<{
|
||||||
|
dryRun: boolean
|
||||||
|
candidateCount: number
|
||||||
|
archivedCount: number
|
||||||
|
repointedChannelCount: number
|
||||||
|
sample: Array<{
|
||||||
|
id: number
|
||||||
|
canonical_sku: string
|
||||||
|
canonical_name: string
|
||||||
|
primary_channel: string
|
||||||
|
target_product_id: number
|
||||||
|
target_product_sku: string
|
||||||
|
}>
|
||||||
|
}>('/api/v1/admin/cost-center/products/archive-superseded-manual', payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function syncAdminAffiliateDashProducts() {
|
export function syncAdminAffiliateDashProducts() {
|
||||||
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
|
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
|
||||||
'/api/v1/admin/cost-center/affiliate-products/sync',
|
'/api/v1/admin/cost-center/affiliate-products/sync',
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface ProductCatalogItem {
|
|||||||
templatePricingMode?: 'fixed' | 'unit' | null
|
templatePricingMode?: 'fixed' | 'unit' | null
|
||||||
templateEnabled?: boolean | null
|
templateEnabled?: boolean | null
|
||||||
templateSharingEnabled?: boolean | null
|
templateSharingEnabled?: boolean | null
|
||||||
|
activePrimaryChannel?: FulfillmentChannel | null
|
||||||
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
|
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
|
||||||
affiliateTaskCount: number
|
affiliateTaskCount: number
|
||||||
affiliateLastFulfilledAt: string | null
|
affiliateLastFulfilledAt: string | null
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ fulfillment_decided_at / fulfillment_reason
|
|||||||
4. **商品来源不能反推渠道**:`man:` 商品也可能由自动发货履约,判定只认规则与证据。
|
4. **商品来源不能反推渠道**:`man:` 商品也可能由自动发货履约,判定只认规则与证据。
|
||||||
5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。
|
5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。
|
||||||
6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。
|
6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。
|
||||||
|
7. **旧人工来源清理只归档**:同名内部 SKU 已有其他非兜底主渠道时,旧 `man:` 商品从当前目录归档;历史订单、成本版本、成本快照和事实数据保持不动,人工兜底改指当前主商品。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user