feat(supply-chain): 新增组合限价导出
This commit is contained in:
parent
1b6f0cd7ce
commit
8c520de0aa
@ -23,22 +23,24 @@ 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)
|
||||
}
|
||||
// 字段权限在创建任务时快照进参数:后台执行器拿不到用户上下文,
|
||||
// 且排队期间权限变化不应影响已建任务产物的口径。
|
||||
const { productArchiveFieldPermissionService } = await import(
|
||||
'@/server/services/product-archive-field-permission.service'
|
||||
)
|
||||
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
|
||||
params.fieldBlocks = [...blockedFields]
|
||||
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 {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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([]),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user