SYB product export gets "SYB售价(TWD)" after "虾皮商品ID" (unitPriceCent / 100, same source as the page price column). Purchase price and SYB price are written as numeric cells; an optional numberFormatCols option on export_json_to_excel shows them as 0.00 without changing other pages' exports. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
185 lines
10 KiB
JavaScript
185 lines
10 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, centToYuanNumber, PURCHASE_EXPORT_NUMBER_COLS, SYB_PRODUCT_EXPORT_NUMBER_COLS, 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.9])
|
||
assert.deepEqual(lib.purchaseExportRow({ quantity: 1 }), ['', '', '', '', 1, '', '', '', ''])
|
||
assert.equal(lib.purchaseExportRow({ shopeeOrderNoSnapshot: 'A', status: 'failed', pddOrderAmountCent: null })[8], '')
|
||
assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: undefined })[8], '')
|
||
assert.equal(typeof row[8], 'number')
|
||
assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: 0 })[8], 0)
|
||
assert.deepEqual(lib.PURCHASE_EXPORT_NUMBER_COLS, [8])
|
||
assert.equal(lib.PURCHASE_EXPORT_HEADER[8], '价格(元)')
|
||
})
|
||
|
||
test('cent to yuan number is numeric, zero stays zero, empty stays empty', () => {
|
||
assert.equal(lib.centToYuanNumber(1230), 12.3)
|
||
assert.equal(lib.centToYuanNumber(0), 0)
|
||
assert.equal(lib.centToYuanNumber('1230'), 12.3)
|
||
for (const value of [null, undefined, '', 'abc']) assert.equal(lib.centToYuanNumber(value), '')
|
||
})
|
||
|
||
test('syb product row uses only an active match and ignores cancelled ones', () => {
|
||
const product = { orderCode: 'O1', shopeeItemId: '99', unitPriceCent: 1230, targetColor: '白色', targetSize: 'L', quantity: 1 }
|
||
const matchedAt = new Date(2026, 9, 9, 9, 0, 0).toISOString()
|
||
assert.deepEqual(lib.SYB_PRODUCT_EXPORT_HEADER, ['SYB订单', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间'])
|
||
assert.deepEqual(lib.SYB_PRODUCT_EXPORT_NUMBER_COLS, [2])
|
||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }),
|
||
['O1', '99', 12.3, '白色', 'L', 1, '白色', 'L', 3, '2026-10-09 09:00:00'])
|
||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'matched', variationName: '均碼', yeekeQuantity: 1, matchedAt }).slice(6, 9), ['均碼', '', 1])
|
||
assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
|
||
assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
|
||
assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: 0 }, null)[2], 0)
|
||
for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: value }, null)[2], '')
|
||
})
|
||
|
||
// Runs the real Export2Excel writer with file-saver stubbed and reads the xlsx back.
|
||
function writeWorkbook(options) {
|
||
const XLSX = require('xlsx')
|
||
const src = fs.readFileSync(path.join(__dirname, '../../src/vendor/Export2Excel.js'), 'utf8')
|
||
.replace(/^import .*$/gm, '').replace(/^export function/gm, 'function')
|
||
let blobParts
|
||
class FakeBlob { constructor(parts) { blobParts = parts } }
|
||
new Function('XLSX', 'saveAs', 'Blob', 'opts', src + '\nexport_json_to_excel(opts)')(XLSX, () => {}, FakeBlob, options)
|
||
return XLSX.read(blobParts[0], { type: 'array', cellNF: true }).Sheets.SheetJS
|
||
}
|
||
|
||
test('excel writer makes numeric cells, 0.00 format only for requested columns', () => {
|
||
const header = ['a', 'b']
|
||
const data = [[12.3, 12.3], [0, 0], ['', '']]
|
||
const withFmt = writeWorkbook({ header, data, filename: 't', numberFormatCols: [0] })
|
||
assert.equal(withFmt.A2.t, 'n'); assert.equal(withFmt.A2.v, 12.3); assert.equal(withFmt.A2.z, '0.00')
|
||
assert.equal(withFmt.A3.t, 'n'); assert.equal(withFmt.A3.v, 0)
|
||
assert.equal(withFmt.B2.t, 'n'); assert.notEqual(withFmt.B2.z, '0.00')
|
||
const plain = writeWorkbook({ header, data, filename: 't' })
|
||
assert.equal(plain.A2.t, 'n'); assert.notEqual(plain.A2.z, '0.00')
|
||
})
|
||
|
||
test('real export row types and format end up in the sheet', () => {
|
||
const header = lib.SYB_PRODUCT_EXPORT_HEADER
|
||
const rows = [
|
||
lib.sybProductExportRow({ orderCode: 'O1', unitPriceCent: 1230 }, null),
|
||
lib.sybProductExportRow({ orderCode: 'O2', unitPriceCent: 0 }, null),
|
||
lib.sybProductExportRow({ orderCode: 'O3', unitPriceCent: null }, null)
|
||
]
|
||
const sheet = writeWorkbook({ header, data: rows, filename: 't', numberFormatCols: lib.SYB_PRODUCT_EXPORT_NUMBER_COLS })
|
||
assert.equal(sheet.C1.v, 'SYB售价(TWD)')
|
||
assert.equal(sheet.C2.t, 'n'); assert.equal(sheet.C2.v, 12.3); assert.equal(sheet.C2.z, '0.00')
|
||
assert.equal(sheet.C3.t, 'n'); assert.equal(sheet.C3.v, 0)
|
||
assert.equal(sheet.C4.v, ''); assert.notEqual(sheet.C4.t, 'n')
|
||
})
|
||
|
||
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)
|
||
})
|