fix(cost-center): 归档历史人工商品且保留原始数据
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user