From d8d9ab9eddc4a56d1a75b1807e96755a48a75081 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 10 Oct 2026 09:40:02 +0800 Subject: [PATCH 1/2] feat(web): add SYB price column and numeric amount cells to exports (#372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- web/src/utils/list-export.js | 22 ++++-- web/src/vendor/Export2Excel.js | 11 ++- web/src/views/goauto/purchase-tasks/index.vue | 4 +- web/src/views/goauto/syb-products/index.vue | 4 +- web/tests/unit/list-export.test.cjs | 67 ++++++++++++++++--- 5 files changed, 88 insertions(+), 20 deletions(-) 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 = {}) { From d528c74c57a343581fae16e7a7c4ad1a545fc82b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 10 Oct 2026 09:47:57 +0800 Subject: [PATCH 2/2] docs: mirror #372 v2 export rules (#372) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- docs/03-business-rules-and-glossary.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 601ddee..2f96c7a 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 1c57a8670034fe08420bcac48b58a9c4b2755f23 -synchronized_at: 2026-10-10T00:59:12Z +wiki_revision: 35c42f6136426bd3c295fe912d491c3fd980fcb4 +synchronized_at: 2026-10-10T01:41:05Z @@ -952,8 +952,8 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 实现绑定 `a4a793f`,分支 `feat/372-list-export`,未合并、未发布。 - 范围:采购管理和 SYB 商品页的「导出」导出**当前筛选条件下的全部页**,不是只导出当前页;筛选以点击导出时的值为准。总数超过 **5000** 条时提示缩小筛选范围并不导出;任一页读取失败不生成文件;无数据提示且不生成文件。分页读取期间数据变化可能有少量重复或遗漏,以导出时刻近似结果为准。 -- 格式:时间 `YYYY-MM-DD HH:mm:ss`(浏览器本地时区);金额单位元,保留两位小数;空值留空;文件名 `采购导出_YYYYMMDD-HHmmss.xlsx`、`SYB商品导出_YYYYMMDD-HHmmss.xlsx`。不导出收货人、地址、账号等个人信息。 -- 采购导出列(按顺序):SYB订单号、虾皮商品ID、颜色、尺码(均为 SYB 目标规格快照)、数量、状态(页面同款中文)、PDD单号、下单时间、价格(元)。价格取 `pddOrderAmountCent / 100`,即 Agent 在 PDD 待付款页读到的应付金额;旧版 Agent 或人工补录前为空则留空。要导出成功采购,先把状态筛为「订单已创建」并按需选择下单日期。 +- 格式:时间 `YYYY-MM-DD HH:mm:ss`(浏览器本地时区);金额单位元,写成 Excel **数字单元格**(按分换算,例如 1230 分 → 12.3,可直接求和)并显示为 `0.00`;空值留空,0 写成 0;文件名 `采购导出_YYYYMMDD-HHmmss.xlsx`、`SYB商品导出_YYYYMMDD-HHmmss.xlsx`。不导出收货人、地址、账号等个人信息。 +- 采购导出列(按顺序):SYB订单号、虾皮商品ID、颜色、尺码(均为 SYB 目标规格快照)、数量、状态(页面同款中文)、PDD单号、下单时间、价格(元)(数字单元格)。价格取 `pddOrderAmountCent / 100`,即 Agent 在 PDD 待付款页读到的应付金额;旧版 Agent 或人工补录前为空则留空。要导出成功采购,先把状态筛为「订单已创建」并按需选择下单日期。 - 采购管理新增「下单日期」筛选:含首尾两天,按 `order_submitted_at`,日期边界 UTC+8,与 SYB 商品页创建时间筛选一致;没有下单时间的任务在设置该筛选时不出现。 -- SYB 商品导出列(按顺序):SYB订单、虾皮商品ID、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。 +- SYB 商品导出列(按顺序):SYB订单、虾皮商品ID、**SYB售价(TWD)**、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;SYB售价(TWD)取商品行 `unitPriceCent / 100`,与页面「售价」列同源(SYB `detail/listByStock` 的 `productPrice`,单件成交单价,元/TWD),数字单元格,无值留空;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。 - 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。