diff --git a/web/src/utils/list-export.js b/web/src/utils/list-export.js index 3e88474..9a698f7 100644 --- a/web/src/utils/list-export.js +++ b/web/src/utils/list-export.js @@ -29,6 +29,14 @@ export function centToYuan(cent) { return `${sign}${Math.floor(abs / 100)}.${pad(abs % 100)}` } +// Cents -> yuan number (1230 -> 12.3) for numeric Excel cells; null/undefined/''/NaN stay empty, 0 stays 0. +export function centToYuanNumber(cent) { + if (cent === null || cent === undefined || cent === '') return '' + const value = Number(cent) + if (!Number.isFinite(value)) return '' + return Math.round(value) / 100 +} + // yeeke only has one variation_name ("color,size"); split at the first English // or Chinese comma. Without a comma the whole text is the color. export function splitYeekeVariation(name) { @@ -41,6 +49,10 @@ export function splitYeekeVariation(name) { const blank = value => (value === null || value === undefined ? '' : value) +// Column indexes written as numbers; the saver shows them as 0.00. +export const PURCHASE_EXPORT_NUMBER_COLS = [8] +export const SYB_PRODUCT_EXPORT_NUMBER_COLS = [2] + export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)'] // statusLabel: the page's own status -> Chinese label function. @@ -48,11 +60,11 @@ export function purchaseExportRow(task, statusLabel = value => value) { return [ blank(task.shopeeOrderNoSnapshot), blank(task.shopeeItemIdSnapshot), blank(task.targetColorSnapshot), blank(task.targetSizeSnapshot), blank(task.quantity), task.status ? statusLabel(task.status) : '', blank(task.pddOrderNo), - formatExportTime(task.orderSubmittedAt), centToYuan(task.pddOrderAmountCent) + formatExportTime(task.orderSubmittedAt), centToYuanNumber(task.pddOrderAmountCent) ] } -export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '虾皮商品ID', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间'] +export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间'] export const isActiveReturnMatch = match => Boolean(match) && (match.status === 'matched' || match.status === 'confirmed') @@ -61,7 +73,7 @@ export function sybProductExportRow(product, match) { const active = isActiveReturnMatch(match) ? match : null const yeeke = active ? splitYeekeVariation(active.variationName) : { color: '', size: '' } return [ - blank(product.orderCode), blank(product.shopeeItemId), blank(product.targetColor), blank(product.targetSize), blank(product.quantity), + blank(product.orderCode), blank(product.shopeeItemId), centToYuanNumber(product.unitPriceCent), blank(product.targetColor), blank(product.targetSize), blank(product.quantity), yeeke.color, yeeke.size, active ? blank(active.yeekeQuantity) : '', active ? formatExportTime(active.matchedAt) : '' ] } @@ -90,7 +102,7 @@ function chunk(items, size) { // save({ header, data, filename }) -> writes the file (only after success) export async function runListExport({ fetchPage, pageSize, maxRows = EXPORT_MAX_ROWS, enrich = null, enrichBatchSize = 200, - header, mapRow, filename, save, onProgress = () => {} + header, mapRow, filename, save, numberFormatCols = [], onProgress = () => {} }) { let first try { @@ -131,6 +143,6 @@ export async function runListExport({ } if (items.length === 0) return { ok: false, reason: 'empty', total: 0 } const data = items.map(mapRow) - await save({ header, data, filename: `${filename}_${formatExportStamp()}` }) + await save({ header, data, filename: `${filename}_${formatExportStamp()}`, numberFormatCols }) return { ok: true, total, rows: data.length } } diff --git a/web/src/vendor/Export2Excel.js b/web/src/vendor/Export2Excel.js index b64e7d8..b1c48d5 100644 --- a/web/src/vendor/Export2Excel.js +++ b/web/src/vendor/Export2Excel.js @@ -85,7 +85,11 @@ function sheet_from_array_of_arrays(data, opts) { r: R }); - if (typeof cell.v === 'number') cell.t = 'n'; + if (typeof cell.v === 'number') { + cell.t = 'n'; + // optional display format for chosen columns (#372); unset keeps the old output + if (opts && opts.numberFormatCols && opts.numberFormatCols.indexOf(C) >= 0) cell.z = '0.00'; + } else if (typeof cell.v === 'boolean') cell.t = 'b'; else if (cell.v instanceof Date) { cell.t = 'n'; @@ -151,7 +155,8 @@ export function export_json_to_excel({ filename, merges = [], autoWidth = true, - bookType = 'xlsx' + bookType = 'xlsx', + numberFormatCols = [] } = {}) { /* original data */ filename = filename || 'excel-list' @@ -164,7 +169,7 @@ export function export_json_to_excel({ var ws_name = "SheetJS"; var wb = new Workbook(), - ws = sheet_from_array_of_arrays(data); + ws = sheet_from_array_of_arrays(data, { numberFormatCols: numberFormatCols }); if (merges.length > 0) { if (!ws['!merges']) ws['!merges'] = []; diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index 4b06035..c446345 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -156,7 +156,7 @@ import { RefreshLeft, Search } from '@element-plus/icons-vue' import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks' import { createRequestId } from '@/utils/request-id' import { saveExcel } from '@/utils/save-excel' -import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export' +import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, PURCHASE_EXPORT_NUMBER_COLS, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export' import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots' import FailureSnapshotCell from './FailureSnapshotCell.vue' @@ -249,7 +249,7 @@ export default { this.exporting = true; this.exportProgress = { done: 0, steps: 0 } try { const result = await runListExport({ - pageSize: 100, maxRows: EXPORT_MAX_ROWS, header: PURCHASE_EXPORT_HEADER, filename: '采购导出', + pageSize: 100, maxRows: EXPORT_MAX_ROWS, header: PURCHASE_EXPORT_HEADER, numberFormatCols: PURCHASE_EXPORT_NUMBER_COLS, filename: '采购导出', fetchPage: async(pageNo, size) => { const r = await listPurchaseTasks({ ...filters, page: pageNo, pageSize: size }); return { items: r.data.items || [], total: r.data.total || 0 } }, mapRow: task => purchaseExportRow(task, this.statusLabel), save: saveExcel, diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index f2f6988..c169004 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -396,7 +396,7 @@ import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } fro import { listCollectionRules } from '@/api/goauto/collection-rules' import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches' import { saveExcel } from '@/utils/save-excel' -import { EXPORT_MAX_ROWS, SYB_PRODUCT_EXPORT_HEADER, exportFailureMessage, isActiveReturnMatch, runListExport, sybProductExportRow } from '@/utils/list-export' +import { EXPORT_MAX_ROWS, SYB_PRODUCT_EXPORT_HEADER, SYB_PRODUCT_EXPORT_NUMBER_COLS, exportFailureMessage, isActiveReturnMatch, runListExport, sybProductExportRow } from '@/utils/list-export' import { createRequestId } from '@/utils/request-id' import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference' import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue' @@ -544,7 +544,7 @@ export default { this.exporting = true; this.exportProgress = { done: 0, steps: 0 } try { const result = await runListExport({ - pageSize: 500, maxRows: EXPORT_MAX_ROWS, header: SYB_PRODUCT_EXPORT_HEADER, filename: 'SYB商品导出', + pageSize: 500, maxRows: EXPORT_MAX_ROWS, header: SYB_PRODUCT_EXPORT_HEADER, numberFormatCols: SYB_PRODUCT_EXPORT_NUMBER_COLS, filename: 'SYB商品导出', fetchPage: async(page, size) => { const r = await listSybProducts({ ...filters, page, pageSize: size }, { suppressErrorMessage: true }); return { items: r.data.items || [], total: r.data.total || 0 } }, enrichBatchSize: 200, enrich: async items => { diff --git a/web/tests/unit/list-export.test.cjs b/web/tests/unit/list-export.test.cjs index ac288aa..fbad6cc 100644 --- a/web/tests/unit/list-export.test.cjs +++ b/web/tests/unit/list-export.test.cjs @@ -6,7 +6,7 @@ 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 }`)() + 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) @@ -40,20 +40,71 @@ test('purchase row maps columns in order with status label, amount and blanks', 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(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', targetColor: '白色', targetSize: 'L', quantity: 1 } + 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.equal(lib.SYB_PRODUCT_EXPORT_HEADER.length, 9) + 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', '白色', '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, '', '', '', '']) + ['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 = {}) {