merge: SYB price column and numeric amounts in exports (#372)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-10-10 09:48:42 +08:00
co-authored by Claude Opus 5.5
6 changed files with 93 additions and 25 deletions
+5 -5
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -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 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
- 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。
+17 -5
View File
@@ -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 }
}
+8 -3
View File
@@ -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,
+2 -2
View File
@@ -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 => {
+59 -8
View File
@@ -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 = {}) {