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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user