feat(supply-chain): 新增组合限价导出

This commit is contained in:
居居 2026-10-20 13:04:35 +08:00
parent 1b6f0cd7ce
commit 8c520de0aa
8 changed files with 290 additions and 9 deletions

View File

@ -23,23 +23,25 @@ export async function POST(request: NextRequest) {
const params = (body?.params && typeof body.params === 'object' ? body.params : {}) as Record<string, unknown>
// 任务级权限:产品档案导出走「产品全量档案 · 下载」,限价基础信息导出走「限价管理 · 下载」
if (type === 'archive-export' || type === 'limit-export') {
if (type === 'archive-export' || type === 'limit-export' || type === 'combo-limit-export') {
const { supplyChainPermissionSettingsService } = await import(
'@/server/services/supply-chain-permission-settings.service'
)
const target = type === 'limit-export' ? 'limitManagement' : 'productArchive'
const target = type === 'archive-export' ? 'productArchive' : 'limitManagement'
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, target)
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}
// 字段权限在创建任务时快照进参数:后台执行器拿不到用户上下文,
// 且排队期间权限变化不应影响已建任务产物的口径。
if (type !== 'combo-limit-export') {
const { productArchiveFieldPermissionService } = await import(
'@/server/services/product-archive-field-permission.service'
)
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
params.fieldBlocks = [...blockedFields]
}
}
try {
const task = await downloadCenterService.createDownloadTask({

View File

@ -202,6 +202,12 @@ function mockLimitManagementFetch(rows: SupplyChainProductArchiveRow[]) {
},
})
}
if (url.includes('/api/downloads')) {
return jsonResponse({
success: true,
data: { id: 'download-task-1', status: 'pending' },
})
}
throw new Error(`unexpected fetch: ${url}`)
}) as unknown as typeof fetch
}
@ -257,6 +263,8 @@ describe('限价管理', () => {
await screen.findByText('测试岩板餐桌')
expect(screen.getByRole('button', { name: '导出明细' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '导出明细(含图片)' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '组合限价' }))
expect(await screen.findByRole('button', { name: '导出组合限价' })).toBeInTheDocument()
})
it('品牌商品旁展示组合限价页,并只展示组合编码与限价价格字段', async () => {
@ -279,6 +287,16 @@ describe('限价管理', () => {
const columnLabels = screen.getAllByRole('columnheader').map((cell) => (cell.textContent ?? '').replace(/[↕↑↓▾]/g, '').trim())
expect(columnLabels.indexOf('运营负责人')).toBe(columnLabels.indexOf('组合编码') + 1)
expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '导出组合限价' }))
await waitFor(() => {
const exportCall = (global.fetch as jest.Mock).mock.calls
.find((call) => String(call[0]).includes('/api/downloads'))
expect(exportCall).toBeTruthy()
const body = JSON.parse(String((exportCall?.[1] as RequestInit | undefined)?.body ?? '{}'))
expect(body.type).toBe('combo-limit-export')
expect(body.params.sort).toEqual({ field: 'ownerLimitPrice', direction: 'desc' })
})
})
it('条码候选值跟随已选商品状态,避免选出与明细条件冲突的条码', async () => {

View File

@ -241,7 +241,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
// 使用 filters(用户主动选择的筛选条件,如商品状态/品牌/类目等);
// 「大货 / 品牌」页签的分区不参与导出——导出始终包含大货 + 品牌全部数据;
// columnSet=limit 让导出抬头与限价界面完全一致(在下载中心执行器里固定)。
const [creatingExport, setCreatingExport] = useState<'plain' | 'image' | null>(null)
const [creatingExport, setCreatingExport] = useState<'plain' | 'image' | 'combo' | null>(null)
const [exportMessage, setExportMessage] = useState<string | null>(null)
useEffect(() => {
if (!exportMessage) return
@ -270,6 +270,31 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
}
}, [filters])
const createComboLimitExportTask = useCallback(async () => {
setCreatingExport('combo')
setExportMessage(null)
try {
const response = await fetch('/api/downloads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'combo-limit-export',
params: { filters: comboFilters, sort: comboSort },
}),
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || '创建组合限价导出任务失败')
}
requestDownloadCenterRefresh()
setExportMessage('已加入下载中心,点击右上角下载图标查看进度')
} catch (error) {
setExportMessage(error instanceof Error ? error.message : '创建组合限价导出任务失败')
} finally {
setCreatingExport(null)
}
}, [comboFilters, comboSort])
// 单页排序:只对当前页已加载的数据排序(与截图里的「升序(单页)」口径一致)。
const displayRows = useMemo(() => {
if (!sort) return rows
@ -489,7 +514,17 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
</button>
</>
)}
{productKind !== 'combo' && exportMessage ? <span className={styles.exportHint}>{exportMessage}</span> : null}
{productKind === 'combo' && canExport && (
<button
type="button"
className={styles.exportButton}
disabled={creatingExport !== null}
onClick={() => void createComboLimitExportTask()}
>
{creatingExport === 'combo' ? '提交中…' : '导出组合限价'}
</button>
)}
{exportMessage ? <span className={styles.exportHint}>{exportMessage}</span> : null}
<span className={styles.toolbarSpacer} />
{productKind !== 'combo' && (
<LimitTableSettingsMenu

View File

@ -0,0 +1,59 @@
jest.mock('@/server/repositories/jst-combo-limit-source.repository', () => ({
jstComboLimitSourceRepository: {
listCompleteComboLimits: jest.fn().mockResolvedValue([]),
},
}))
import ExcelJS from 'exceljs'
import { COMBO_LIMIT_FIELDS, buildComboLimitRow } from '@/lib/supply-chain-combo-limit-price'
import { SupplyChainComboLimitExportService } from '../supply-chain-combo-limit-export.service'
describe('SupplyChainComboLimitExportService', () => {
it('exports all filtered combo limit columns to an Excel worksheet', async () => {
const rows = [
buildComboLimitRow({
combineCode: 'COMBO-A',
opsHead: '运营甲',
itemCount: 2,
costCount: 2,
ownerTotal: 69,
nonOwnerTotal: 79,
costTotal: 37,
}),
buildComboLimitRow({
combineCode: 'COMBO-B',
opsHead: '运营乙',
itemCount: 1,
costCount: 1,
ownerTotal: 50,
nonOwnerTotal: 60,
costTotal: 30,
}),
]
const reader = {
listAll: jest.fn().mockResolvedValue(rows),
}
const service = new SupplyChainComboLimitExportService(
reader,
() => new Date('2026-10-20T02:30:45.000Z'),
)
const result = await service.createWorkbook({
filters: { comboCode: ['COMBO-A'] },
sort: { field: 'ownerLimitPrice', direction: 'desc' },
})
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(result.content)
const sheet = workbook.getWorksheet('组合限价')
expect(result.fileName).toBe('组合限价-20261020-103045.xlsx')
expect(result.rowCount).toBe(2)
expect(reader.listAll).toHaveBeenCalledWith({
filters: { comboCode: ['COMBO-A'] },
sort: { field: 'ownerLimitPrice', direction: 'desc' },
})
expect(sheet?.getRow(1).values.slice(1)).toEqual(COMBO_LIMIT_FIELDS.map((field) => field.label))
expect(sheet?.getRow(2).getCell(1).value).toBe('COMBO-A')
expect(sheet?.getRow(2).getCell(3).value).toBe(69)
})
})

View File

@ -71,6 +71,30 @@ describe('SupplyChainComboLimitService', () => {
expect(result.values).toEqual(['COMBO-B'])
})
it('returns all filtered rows for exports without pagination', async () => {
const repository = {
listCompleteComboLimits: jest.fn().mockResolvedValue([
aggregate('COMBO-B', 80),
aggregate('COMBO-A', 69),
aggregate('COMBO-C', 90),
]),
}
const service = new SupplyChainComboLimitService(
repository,
jest.fn().mockResolvedValue([
{ barcode: 'A', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 },
]),
() => 1_000,
)
const rows = await service.listAll({
filters: { opsHead: ['运营甲'] },
sort: { field: 'ownerLimitPrice', direction: 'desc' },
})
expect(rows.map((row) => row.comboCode)).toEqual(['COMBO-C', 'COMBO-B', 'COMBO-A'])
})
it('removes stopped and discontinued child products before building combinations', async () => {
const repository = {
listCompleteComboLimits: jest.fn().mockResolvedValue([]),

View File

@ -12,6 +12,14 @@ import {
stripProductArchiveBlockedFilters,
isProductArchiveFieldKey,
} from '@/lib/product-archive-field-permissions'
import {
isComboLimitFieldKey,
type ComboLimitFilters,
type ComboLimitSort,
} from '@/lib/supply-chain-combo-limit-price'
import {
supplyChainComboLimitExportService,
} from '@/server/services/supply-chain-combo-limit-export.service'
export type DownloadExecutorContext = {
taskId: string
@ -116,9 +124,61 @@ const limitExportExecutor: DownloadExecutor = {
},
}
function parseComboLimitExportParams(params: Record<string, unknown>) {
const filters: ComboLimitFilters = {}
const rawFilters = params.filters
if (rawFilters !== undefined) {
if (!rawFilters || typeof rawFilters !== 'object' || Array.isArray(rawFilters)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价筛选条件不正确')
}
for (const [key, value] of Object.entries(rawFilters)) {
if (!isComboLimitFieldKey(key) || !Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价筛选条件不正确')
}
filters[key] = value
}
}
let sort: ComboLimitSort = null
if (params.sort !== undefined && params.sort !== null) {
const rawSort = params.sort
if (!rawSort || typeof rawSort !== 'object' || Array.isArray(rawSort)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价排序条件不正确')
}
const record = rawSort as Record<string, unknown>
if (
typeof record.field !== 'string'
|| !isComboLimitFieldKey(record.field)
|| (record.direction !== 'asc' && record.direction !== 'desc')
) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价排序条件不正确')
}
sort = { field: record.field, direction: record.direction }
}
return { filters, sort }
}
const comboLimitExportExecutor: DownloadExecutor = {
label: '组合限价',
validateParams(params) {
parseComboLimitExportParams(params)
},
async run({ taskDir, params, onProgress }) {
const parsed = parseComboLimitExportParams(params)
const result = await supplyChainComboLimitExportService.createWorkbook({
...parsed,
onProgress: (processedRows, totalRows) => onProgress?.(rowProgress(processedRows, totalRows)),
})
const filePath = path.join(taskDir, path.basename(result.fileName))
await fs.promises.writeFile(filePath, result.content)
return { fileName: result.fileName, rowCount: result.rowCount }
},
}
export const downloadExecutors: Record<string, DownloadExecutor> = {
'archive-export': archiveExportExecutor,
'limit-export': limitExportExecutor,
'combo-limit-export': comboLimitExportExecutor,
}
export function getDownloadExecutor(type: string): DownloadExecutor | null {

View File

@ -0,0 +1,73 @@
import ExcelJS from 'exceljs'
import {
COMBO_LIMIT_FIELDS,
type ComboLimitFilters,
type ComboLimitRow,
type ComboLimitSort,
} from '@/lib/supply-chain-combo-limit-price'
import {
SupplyChainComboLimitService,
supplyChainComboLimitService,
} from './supply-chain-combo-limit.service'
type ComboLimitReader = Pick<SupplyChainComboLimitService, 'listAll'>
function exportStamp(date: Date) {
const digits = date
.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai', hour12: false })
.replace(/\D/g, '')
.slice(0, 14)
return `${digits.slice(0, 8)}-${digits.slice(8, 14)}`
}
export class SupplyChainComboLimitExportService {
constructor(
private readonly reader: ComboLimitReader = supplyChainComboLimitService,
private readonly now: () => Date = () => new Date(),
) {}
async createWorkbook(input: {
filters?: ComboLimitFilters
sort?: ComboLimitSort
onProgress?: (processedRows: number, totalRows: number) => void
} = {}) {
input.onProgress?.(0, 0)
const rows = await this.reader.listAll({
filters: input.filters,
sort: input.sort,
})
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('组合限价')
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.columns = COMBO_LIMIT_FIELDS.map((field) => ({
header: field.label,
key: field.key,
width: field.key === 'comboCode' ? 48 : field.key === 'opsHead' ? 22 : 16,
style: field.numeric ? { numFmt: '#,##0.00' } : undefined,
}))
worksheet.getRow(1).font = { bold: true }
worksheet.getRow(1).alignment = { vertical: 'middle' }
for (const [index, row] of rows.entries()) {
worksheet.addRow(Object.fromEntries(COMBO_LIMIT_FIELDS.map((field) => {
const value = row[field.key]
return [field.key, field.numeric && value ? Number(value) : value]
})) as Partial<ComboLimitRow>)
if (index === rows.length - 1 || index % 500 === 0) {
input.onProgress?.(index + 1, rows.length)
}
}
worksheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: Math.max(1, rows.length + 1), column: COMBO_LIMIT_FIELDS.length },
}
const content = Buffer.from(await workbook.xlsx.writeBuffer())
input.onProgress?.(rows.length, rows.length)
return {
fileName: `组合限价-${exportStamp(this.now())}.xlsx`,
content,
rowCount: rows.length,
}
}
}
export const supplyChainComboLimitExportService = new SupplyChainComboLimitExportService()

View File

@ -117,6 +117,16 @@ export class SupplyChainComboLimitService {
)
}
async listAll(input: {
filters?: ComboLimitFilters
sort?: ComboLimitSort
forceRefresh?: boolean
} = {}) {
const rows = await this.loadRows(input.forceRefresh)
const filtered = filterComboLimitRows(rows, input.filters ?? {})
return sortComboLimitRows(filtered, input.sort ?? null)
}
async listOptions(input: {
field: ComboLimitFieldKey
q?: string