fix(cost-center): 归档历史人工商品且保留原始数据

This commit is contained in:
yml2213
2026-11-02 15:47:04 +08:00
parent 4ffc1c09be
commit e7b3e46378
4 changed files with 126 additions and 6 deletions
@@ -9,6 +9,7 @@ import { Router } from 'express'
import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-assign-service.js'
import {
archiveHistoricalManualProducts,
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
createAdminChannelMapping,
@@ -161,6 +162,22 @@ router.post(
}),
)
router.post(
'/cost-center/products/archive-historical-manual',
requireAdminPermission('finance.config'),
createJsonHandler((req) => archiveHistoricalManualProducts(bodyOf(req)), {
successMessage: '历史人工商品已归档',
errorMessage: '归档历史人工商品失败',
scope: '[admin/cost-center/products/archive-historical-manual]',
audit: (_req, data) => ({
action: 'cost_historical_manual_products_archived',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.patch(
'/cost-center/products/:productId',
requireAdminPermission('finance.config'),
@@ -103,12 +103,11 @@ export async function listAdminProducts(payload: JsonObject = {}) {
`(p.canonical_sku ILIKE $${baseParams.length} OR p.canonical_name ILIKE $${baseParams.length} OR p.category ILIKE $${baseParams.length} OR root.name ILIKE $${baseParams.length})`,
)
}
const status = text(payload.status)
if (status) {
assertOneOf(status, PRODUCT_STATUSES, '商品状态无效')
baseParams.push(status)
baseClauses.push(`p.status = $${baseParams.length}`)
}
// 商品目录默认只看当前启用商品;需要历史商品时必须显式传 status=archived。
const status = text(payload.status) || 'active'
assertOneOf(status, PRODUCT_STATUSES, '商品状态无效')
baseParams.push(status)
baseClauses.push(`p.status = $${baseParams.length}`)
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
if (source) {
@@ -438,6 +437,54 @@ export async function deleteAdminProduct(productId: number | string) {
return { deleted: true, product: mapProduct(current) }
}
/**
* 批量归档旧人工归一商品。
*
* 只处理 attributes_json.source=manual_normalization、当前为 active,
* 且所有关联订单项都已 archived 的商品。只改 status,不删除任何数据。
*/
export async function archiveHistoricalManualProducts(payload: JsonObject = {}) {
const execute = payload.execute === true || payload.execute === 'true'
const candidates = await query<{ id: number; canonical_sku: string; canonical_name: string }>(
`
SELECT p.id, p.canonical_sku, p.canonical_name
FROM product_catalog p
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'man:%'
AND p.attributes_json->>'source' = 'manual_normalization'
AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.canonical_product_id = p.id)
AND NOT EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.canonical_product_id = p.id
AND oi.fulfillment_status <> 'archived'
)
ORDER BY p.id
`,
)
const ids = candidates.rows.map((row) => Number(row.id))
if (!execute || ids.length === 0) {
return {
dryRun: !execute,
candidateCount: ids.length,
archivedCount: 0,
sample: candidates.rows.slice(0, 20),
}
}
const updated = await query(
`UPDATE product_catalog
SET status = 'archived', updated_at = NOW()
WHERE id = ANY($1::bigint[]) AND status = 'active'`,
[ids],
)
return {
dryRun: false,
candidateCount: ids.length,
archivedCount: updated.rowCount || 0,
sample: candidates.rows.slice(0, 20),
}
}
type MappingProductGroupRow = {
canonical_product_id: number
canonical_sku: string