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
@@ -5,6 +5,7 @@
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
*/
import {
ClearOutlined,
DeleteOutlined,
DollarOutlined,
EditOutlined,
@@ -41,6 +42,7 @@ import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs from '@/lib/dayjs'
import {
applyAdminTemplateProductBootstrap,
archiveAdminHistoricalManualProducts,
createAdminProductCatalog,
deleteAdminProductCatalog,
fetchAdminProductCatalog,
@@ -105,6 +107,7 @@ export function ProductCatalogPanel() {
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
const [productPage, setProductPage] = useState(1)
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
@@ -441,6 +444,41 @@ export function ProductCatalogPanel() {
}
}
async function archiveHistoricalManuals() {
setArchivingHistoricalManuals(true)
try {
const preview = (await archiveAdminHistoricalManualProducts()).data
if (preview.candidateCount === 0) {
message.info('没有需要归档的历史人工商品')
return
}
modal.confirm({
title: '归档历史人工商品',
content: `将归档 ${preview.candidateCount} 个仅被已归档订单引用的历史人工商品。不会删除订单、订单项、成本或工单数据,商品只是从当前目录隐藏。`,
okText: '确认归档',
cancelText: '取消',
onOk: async () => {
setArchivingHistoricalManuals(true)
try {
const result = (await archiveAdminHistoricalManualProducts({ execute: true })).data
message.success(`已归档 ${result.archivedCount} 个历史人工商品`)
await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '归档历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
}
function deleteProduct(item: ProductCatalogItem) {
const affiliateHint =
item.source === 'affiliate_dash'
@@ -659,6 +697,15 @@ export function ProductCatalogPanel() {
extra={
canConfig ? (
<Space size={6} wrap>
{productSourceFilter === 'manual' ? (
<Button
icon={<ClearOutlined />}
loading={archivingHistoricalManuals}
onClick={() => void archiveHistoricalManuals()}
>
归档历史人工商品
</Button>
) : null}
{productSourceFilter === 'work_order' ? (
<>
<AdminRangePicker
@@ -107,6 +107,15 @@ export function createAdminProductCatalog(payload: Record<string, unknown>) {
return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload)
}
export function archiveAdminHistoricalManualProducts(payload: { execute?: boolean } = {}) {
return apiPost<{
dryRun: boolean
candidateCount: number
archivedCount: number
sample: Array<{ id: number; canonical_sku: string; canonical_name: string }>
}>('/api/v1/admin/cost-center/products/archive-historical-manual', payload)
}
export function syncAdminAffiliateDashProducts() {
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
'/api/v1/admin/cost-center/affiliate-products/sync',