Insert a text "店铺" column (row.shopName) right after SYB订单 and move the 0.00 number format to the shifted price column. Sync the wiki mirror. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
149 lines
6.5 KiB
JavaScript
149 lines
6.5 KiB
JavaScript
// #372: export every page of an admin list (current filters) to xlsx.
|
||
// Pure helpers with injected dependencies so they can be unit tested without a
|
||
// browser; the pages inject the API calls and the Export2Excel saver.
|
||
|
||
export const EXPORT_MAX_ROWS = 5000
|
||
|
||
const pad = (value, width = 2) => String(value).padStart(width, '0')
|
||
|
||
// YYYY-MM-DD HH:mm:ss in the browser's local time zone; empty for blank/invalid.
|
||
export function formatExportTime(value) {
|
||
if (!value) return ''
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return ''
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||
}
|
||
|
||
// YYYYMMDD-HHmmss for file names.
|
||
export function formatExportStamp(date = new Date()) {
|
||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||
}
|
||
|
||
// Cents -> yuan text with two decimals; null/undefined/'' stay empty.
|
||
export function centToYuan(cent) {
|
||
if (cent === null || cent === undefined || cent === '') return ''
|
||
const value = Number(cent)
|
||
if (!Number.isFinite(value)) return ''
|
||
const sign = value < 0 ? '-' : ''
|
||
const abs = Math.abs(Math.round(value))
|
||
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) {
|
||
const text = String(name ?? '').trim()
|
||
if (!text) return { color: '', size: '' }
|
||
const index = text.search(/[,,]/)
|
||
if (index < 0) return { color: text, size: '' }
|
||
return { color: text.slice(0, index).trim(), size: text.slice(index + 1).trim() }
|
||
}
|
||
|
||
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 = [3]
|
||
|
||
export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)']
|
||
|
||
// statusLabel: the page's own status -> Chinese label function.
|
||
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), centToYuanNumber(task.pddOrderAmountCent)
|
||
]
|
||
}
|
||
|
||
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')
|
||
|
||
// match: the active (matched/confirmed) return match of this product, or null.
|
||
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.shopName), 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) : ''
|
||
]
|
||
}
|
||
|
||
export function exportFailureMessage(result, maxRows = EXPORT_MAX_ROWS) {
|
||
switch (result.reason) {
|
||
case 'empty': return '当前筛选没有数据,未生成文件'
|
||
case 'too_many': return `共 ${result.total} 条,超过 ${maxRows} 条上限,请缩小筛选范围`
|
||
case 'page_failed': return `导出失败:第 ${result.page} 页读取失败,未生成文件`
|
||
case 'enrich_failed': return '导出失败:匹配信息读取失败,未生成文件'
|
||
default: return '导出失败,未生成文件'
|
||
}
|
||
}
|
||
|
||
function chunk(items, size) {
|
||
const result = []
|
||
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
|
||
return result
|
||
}
|
||
|
||
// Reads page 1 for the total, refuses above maxRows, then reads the remaining
|
||
// pages. Any failure returns { ok: false } and nothing is saved.
|
||
// fetchPage(page, pageSize) -> { items, total }
|
||
// enrich(items) -> decorated items of the same length (optional, per batch)
|
||
// mapRow(item) -> array of cells
|
||
// 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, numberFormatCols = [], onProgress = () => {}
|
||
}) {
|
||
let first
|
||
try {
|
||
first = await fetchPage(1, pageSize)
|
||
} catch (error) {
|
||
return { ok: false, reason: 'page_failed', page: 1, error }
|
||
}
|
||
const total = Number(first?.total) || 0
|
||
if (total === 0) return { ok: false, reason: 'empty', total: 0 }
|
||
if (total > maxRows) return { ok: false, reason: 'too_many', total }
|
||
const pages = Math.ceil(total / pageSize)
|
||
const enrichSteps = enrich ? Math.ceil(total / enrichBatchSize) : 0
|
||
const steps = pages + enrichSteps
|
||
let done = 1
|
||
onProgress(done, steps)
|
||
let items = [...(first.items || [])]
|
||
for (let page = 2; page <= pages; page++) {
|
||
let response
|
||
try {
|
||
response = await fetchPage(page, pageSize)
|
||
} catch (error) {
|
||
return { ok: false, reason: 'page_failed', page, error }
|
||
}
|
||
items = items.concat(response?.items || [])
|
||
onProgress(++done, steps)
|
||
}
|
||
if (enrich) {
|
||
const decorated = []
|
||
for (const part of chunk(items, enrichBatchSize)) {
|
||
try {
|
||
decorated.push(...await enrich(part))
|
||
} catch (error) {
|
||
return { ok: false, reason: 'enrich_failed', error }
|
||
}
|
||
onProgress(++done, steps)
|
||
}
|
||
items = decorated
|
||
}
|
||
if (items.length === 0) return { ok: false, reason: 'empty', total: 0 }
|
||
const data = items.map(mapRow)
|
||
await save({ header, data, filename: `${filename}_${formatExportStamp()}`, numberFormatCols })
|
||
return { ok: true, total, rows: data.length }
|
||
}
|