- 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
134 lines
7.0 KiB
JavaScript
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)
|
|
})
|