Files
goauto/web/tests/unit/list-export.test.cjs
T
QiuSWandClaude Sonnet 5.5 a4a793f449 feat(web): export all filtered pages to Excel for purchase tasks and SYB products (#372)
- Web: shared list-export util (5000-row cap, progress, no partial file,
  filters frozen at click time) and Export buttons on the purchase
  management and SYB products pages; return matches are read per batch
  of product ids and only matched/confirmed ones are used.
- Server: purchase list gains inclusive orderSubmittedFrom/To day filter
  (UTC+8, same as SYB createdFrom/To, INVALID_REQUEST on bad dates);
  return-matches list items gain read-only yeekeQuantity from the
  existing join. No migration, no new endpoint or permission.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 08:58:28 +08:00

134 lines
7.0 KiB
JavaScript

const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/list-export.js'), 'utf8')
const lib = new Function(source.replace(/^export /gm, '') + `
return { EXPORT_MAX_ROWS, formatExportTime, formatExportStamp, centToYuan, splitYeekeVariation, purchaseExportRow, PURCHASE_EXPORT_HEADER,
sybProductExportRow, SYB_PRODUCT_EXPORT_HEADER, exportFailureMessage, runListExport }`)()
test('time and stamp use local time, empty and invalid stay empty', () => {
const date = new Date(2026, 9, 9, 8, 5, 3)
assert.equal(lib.formatExportTime(date.toISOString()), '2026-10-09 08:05:03')
assert.equal(lib.formatExportTime(null), '')
assert.equal(lib.formatExportTime(''), '')
assert.equal(lib.formatExportTime('not a date'), '')
assert.equal(lib.formatExportStamp(date), '20261009-080503')
})
test('cent to yuan keeps two decimals and leaves empty values empty', () => {
assert.equal(lib.centToYuan(12345), '123.45')
assert.equal(lib.centToYuan(5), '0.05')
assert.equal(lib.centToYuan(1000), '10.00')
assert.equal(lib.centToYuan(0), '0.00')
for (const value of [null, undefined, '']) assert.equal(lib.centToYuan(value), '')
})
test('yeeke variation splits at the first English or Chinese comma, none puts all in color', () => {
assert.deepEqual(lib.splitYeekeVariation('白色,L【建議65-75公斤】'), { color: '白色', size: 'L【建議65-75公斤】' })
assert.deepEqual(lib.splitYeekeVariation('黑色+白色,簡約,L'), { color: '黑色+白色', size: '簡約,L' })
assert.deepEqual(lib.splitYeekeVariation('均碼'), { color: '均碼', size: '' })
assert.deepEqual(lib.splitYeekeVariation(''), { color: '', size: '' })
assert.deepEqual(lib.splitYeekeVariation(undefined), { color: '', size: '' })
})
test('purchase row maps columns in order with status label, amount and blanks', () => {
assert.equal(lib.PURCHASE_EXPORT_HEADER.length, 9)
const row = lib.purchaseExportRow({
shopeeOrderNoSnapshot: '001', shopeeItemIdSnapshot: '5020', targetColorSnapshot: '白色', targetSizeSnapshot: 'L', quantity: 2,
status: 'order_created', pddOrderNo: 'PDD-1', orderSubmittedAt: new Date(2026, 9, 9, 1, 2, 3).toISOString(), pddOrderAmountCent: 3990,
receiverName: 'must not leak', address: 'must not leak'
}, value => ({ order_created: '订单已创建' })[value])
assert.deepEqual(row, ['001', '5020', '白色', 'L', 2, '订单已创建', 'PDD-1', '2026-10-09 01:02:03', '39.90'])
assert.deepEqual(lib.purchaseExportRow({ quantity: 1 }), ['', '', '', '', 1, '', '', '', ''])
assert.equal(lib.purchaseExportRow({ shopeeOrderNoSnapshot: 'A', status: 'failed', pddOrderAmountCent: null })[8], '')
})
test('syb product row uses only an active match and ignores cancelled ones', () => {
const product = { orderCode: 'O1', shopeeItemId: '99', targetColor: '白色', targetSize: 'L', quantity: 1 }
const matchedAt = new Date(2026, 9, 9, 9, 0, 0).toISOString()
assert.equal(lib.SYB_PRODUCT_EXPORT_HEADER.length, 9)
assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }),
['O1', '99', '白色', 'L', 1, '白色', 'L', 3, '2026-10-09 09:00:00'])
assert.deepEqual(lib.sybProductExportRow(product, { status: 'matched', variationName: '均碼', yeekeQuantity: 1, matchedAt }).slice(5, 8), ['均碼', '', 1])
assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', '99', '白色', 'L', 1, '', '', '', ''])
assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', '99', '白色', 'L', 1, '', '', '', ''])
})
function harness(total, options = {}) {
const calls = []; const saved = []; const progress = []
const pageSize = options.pageSize || 100
const run = () => lib.runListExport({
pageSize, header: ['id'], filename: '测试', mapRow: item => [item.id],
fetchPage: async (page, size) => {
calls.push(page)
if (options.failPage === page) throw new Error('boom')
const start = (page - 1) * size
return { total, items: Array.from({ length: Math.max(0, Math.min(size, total - start)) }, (_, i) => ({ id: start + i + 1 })) }
},
save: async file => { saved.push(file) }, onProgress: (done, steps) => progress.push([done, steps]), ...options.extra
})
return { run, calls, saved, progress }
}
test('reads every page, reports progress, saves once with a timestamped name', async () => {
const h = harness(250)
const result = await h.run()
assert.deepEqual(result, { ok: true, total: 250, rows: 250 })
assert.deepEqual(h.calls, [1, 2, 3])
assert.deepEqual(h.progress, [[1, 3], [2, 3], [3, 3]])
assert.equal(h.saved.length, 1)
assert.equal(h.saved[0].data.length, 250)
assert.deepEqual(h.saved[0].data[249], [250])
assert.match(h.saved[0].filename, /^测试_\d{8}-\d{6}$/)
})
test('exactly 5000 rows are allowed, 5001 is refused after reading only page 1', async () => {
const ok = harness(5000, { pageSize: 500 })
assert.equal((await ok.run()).ok, true)
assert.equal(ok.calls.length, 10)
const over = harness(5001, { pageSize: 500 })
const result = await over.run()
assert.deepEqual(result, { ok: false, reason: 'too_many', total: 5001 })
assert.deepEqual(over.calls, [1])
assert.equal(over.saved.length, 0)
assert.equal(lib.exportFailureMessage(result), '共 5001 条,超过 5000 条上限,请缩小筛选范围')
})
test('empty result does not save', async () => {
const h = harness(0)
const result = await h.run()
assert.deepEqual(result, { ok: false, reason: 'empty', total: 0 })
assert.equal(h.saved.length, 0)
assert.match(lib.exportFailureMessage(result), /没有数据/)
})
test('a failing page stops the export without saving', async () => {
const h = harness(450, { failPage: 3 })
const result = await h.run()
assert.equal(result.ok, false)
assert.equal(result.reason, 'page_failed')
assert.equal(result.page, 3)
assert.deepEqual(h.calls, [1, 2, 3])
assert.equal(h.saved.length, 0)
assert.equal(lib.exportFailureMessage(result), '导出失败:第 3 页读取失败,未生成文件')
const first = harness(10, { failPage: 1 })
assert.equal((await first.run()).page, 1)
assert.equal(first.saved.length, 0)
})
test('enrich runs per batch (not per row), counts in progress, and a failure saves nothing', async () => {
const batches = []
const h = harness(450, { pageSize: 200, extra: { enrichBatchSize: 200, enrich: async items => { batches.push(items.length); return items.map(item => ({ ...item, id: item.id * 10 })) } } })
const result = await h.run()
assert.equal(result.ok, true)
assert.deepEqual(batches, [200, 200, 50])
assert.deepEqual(h.saved[0].data[0], [10])
assert.deepEqual(h.progress[h.progress.length - 1], [6, 6])
const bad = harness(450, { pageSize: 200, extra: { enrich: async () => { throw new Error('boom') } } })
const failed = await bad.run()
assert.equal(failed.reason, 'enrich_failed')
assert.equal(bad.saved.length, 0)
})