feat(web): add SYB price column and numeric amount cells to exports (#372)
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
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
919744ec05
commit
d8d9ab9edd
@@ -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 }
|
||||
}
|
||||
|
||||
Vendored
+8
-3
@@ -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'] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
Reference in New Issue
Block a user