diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index b49e3631..c884b599 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -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, + }), + }), +) + router.patch( '/cost-center/products/:productId', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index b449accd..bf64fc57 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -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 diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index 86c5aa10..d374aa30 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -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(null) const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) const [bootstrapApplying, setBootstrapApplying] = useState(false) + const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false) const bootstrapSyncOptionsRef = useRef({ 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 ? ( + {productSourceFilter === 'manual' ? ( + + ) : null} {productSourceFilter === 'work_order' ? ( <> ) { 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',