JewelryMall/server/src/controllers/specDataIO.ts
2026-04-06 19:46:40 +08:00

466 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Request, Response } from 'express'
import pool from '../utils/db'
import { RowDataPacket, ResultSetHeader } from 'mysql2'
import { syncProductMinPrice } from '../utils/syncPrice'
// 所有字段(用于数据库读写)
const ALL_DB_HEADERS = [
'model_name', 'barcode', 'fineness', 'main_stone', 'sub_stone', 'ring_size',
'gold_total_weight', 'gold_net_weight', 'loss', 'gold_loss',
'gold_price', 'gold_value',
'main_stone_count', 'main_stone_weight', 'main_stone_unit_price', 'main_stone_amount',
'side_stone_count', 'side_stone_weight', 'side_stone_unit_price', 'side_stone_amount',
'accessory_amount', 'processing_fee', 'setting_fee', 'total_labor_cost', 'total_price',
]
// CSV 手动输入列(不含自动计算字段)
const CSV_INPUT_HEADERS = [
'style_no',
'model_name', 'barcode', 'fineness', 'main_stone', 'sub_stone', 'ring_size',
'gold_total_weight', 'loss',
'main_stone_count', 'main_stone_weight', 'main_stone_unit_price',
'side_stone_count', 'side_stone_weight', 'side_stone_unit_price',
'accessory_amount', 'processing_fee', 'setting_fee',
]
// 全部中文表头映射
const CSV_HEADERS_CN: Record<string, string> = {
style_no: '款号',
model_name: '规格名称', barcode: '条型码', fineness: '成色', main_stone: '主石', sub_stone: '副石', ring_size: '手寸',
gold_total_weight: '金料总重', gold_net_weight: '金料净重', loss: '损耗', gold_loss: '金损',
gold_price: '金价', gold_value: '金料价值',
main_stone_count: '主石数', main_stone_weight: '主石重', main_stone_unit_price: '主石单价', main_stone_amount: '主石金额',
side_stone_count: '副石数', side_stone_weight: '副石重', side_stone_unit_price: '副石单价', side_stone_amount: '副石金额',
accessory_amount: '配件费', processing_fee: '工费', setting_fee: '镶石费', total_labor_cost: '工费合计', total_price: '总价',
}
// 中文→英文反向映射(导入用)
const CN_TO_EN: Record<string, string> = Object.fromEntries(
Object.entries(CSV_HEADERS_CN).map(([en, cn]) => [cn, en])
)
// 根据手动输入字段自动计算派生字段
function calcSpecFields(row: Record<string, number>) {
const n = (v: any) => Number(v) || 0
const goldNetWeight = +(n(row.gold_total_weight) - n(row.main_stone_weight) * 0.2 - n(row.side_stone_weight) * 0.2).toFixed(4)
const safeGoldNetWeight = goldNetWeight < 0 ? 0 : goldNetWeight
const goldLoss = +(safeGoldNetWeight * n(row.loss)).toFixed(4)
const goldValue = +(goldLoss * n(row.gold_price)).toFixed(2)
const mainStoneAmount = +(n(row.main_stone_weight) * n(row.main_stone_unit_price)).toFixed(2)
const sideStoneAmount = +(n(row.side_stone_weight) * n(row.side_stone_unit_price)).toFixed(2)
const totalLaborCost = +(n(row.accessory_amount) + n(row.processing_fee) + n(row.setting_fee)).toFixed(2)
const totalPrice = +(goldValue + mainStoneAmount + sideStoneAmount + totalLaborCost).toFixed(2)
return {
gold_net_weight: safeGoldNetWeight,
gold_loss: goldLoss,
gold_value: goldValue,
main_stone_amount: mainStoneAmount,
side_stone_amount: sideStoneAmount,
total_labor_cost: totalLaborCost,
total_price: totalPrice,
}
}
function escapeCSVField(value: string | number): string {
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function parseCSV(content: string): Record<string, string>[] {
const lines = content.split(/\r?\n/).filter((l) => l.trim())
if (lines.length < 2) return []
const rawHeaders = parseCSVLine(lines[0])
// 支持中文表头:自动转为英文字段名
const headers = rawHeaders.map((h) => {
const trimmed = h.trim()
return CN_TO_EN[trimmed] || trimmed
})
const rows: Record<string, string>[] = []
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i])
const row: Record<string, string> = {}
headers.forEach((h, idx) => {
row[h] = (values[idx] || '').trim()
})
rows.push(row)
}
return rows
}
function parseCSVLine(line: string): string[] {
const result: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"'
i++
} else if (ch === '"') {
inQuotes = false
} else {
current += ch
}
} else {
if (ch === '"') {
inQuotes = true
} else if (ch === ',') {
result.push(current)
current = ''
} else {
current += ch
}
}
}
result.push(current)
return result
}
export function generateCSV(rows: Record<string, any>[], headers: string[] = CSV_INPUT_HEADERS): string {
const headerLine = headers.map((h) => escapeCSVField(CSV_HEADERS_CN[h] || h)).join(',')
const dataLines = rows.map((row) =>
headers.map((h) => escapeCSVField(row[h] ?? '')).join(',')
)
return [headerLine, ...dataLines].join('\n')
}
// GET /api/admin/products/:id/spec-data - 获取规格数据列表
export async function adminGetSpecData(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT id, product_id AS productId, model_name AS modelName, barcode, fineness, main_stone AS mainStone,
sub_stone AS subStone, ring_size AS ringSize, gold_total_weight AS goldTotalWeight, gold_net_weight AS goldNetWeight,
loss, gold_loss AS goldLoss, gold_price AS goldPrice, gold_value AS goldValue,
main_stone_count AS mainStoneCount, main_stone_weight AS mainStoneWeight,
main_stone_unit_price AS mainStoneUnitPrice, main_stone_amount AS mainStoneAmount,
side_stone_count AS sideStoneCount, side_stone_weight AS sideStoneWeight,
side_stone_unit_price AS sideStoneUnitPrice, side_stone_amount AS sideStoneAmount,
accessory_amount AS accessoryAmount, processing_fee AS processingFee,
setting_fee AS settingFee, total_labor_cost AS totalLaborCost, total_price AS totalPrice
FROM spec_data WHERE product_id = ? ORDER BY id ASC`,
[id]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('adminGetSpecData error:', err)
res.status(500).json({ code: 500, message: '获取规格数据失败' })
}
}
// POST /api/admin/products/:id/spec-data - 新增单条规格数据
export async function adminCreateSpecData(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params
const d = req.body
if (d.barcode) {
const [dup] = await pool.execute<RowDataPacket[]>('SELECT id FROM spec_data WHERE barcode = ?', [d.barcode])
if (dup.length > 0) { res.status(400).json({ code: 400, message: `条型码 "${d.barcode}" 已存在` }); return }
}
const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO spec_data (product_id, model_name, barcode, fineness, main_stone, sub_stone, ring_size,
gold_total_weight, gold_net_weight, loss, gold_loss, gold_price, gold_value,
main_stone_count, main_stone_weight, main_stone_unit_price, main_stone_amount,
side_stone_count, side_stone_weight, side_stone_unit_price, side_stone_amount,
accessory_amount, processing_fee, setting_fee, total_labor_cost, total_price)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, d.modelName||'', d.barcode||null, d.fineness||'', d.mainStone||'', d.subStone||'', d.ringSize||'',
d.goldTotalWeight||0, d.goldNetWeight||0, d.loss||0, d.goldLoss||0,
d.goldPrice||0, d.goldValue||0,
d.mainStoneCount||0, d.mainStoneWeight||0, d.mainStoneUnitPrice||0, d.mainStoneAmount||0,
d.sideStoneCount||0, d.sideStoneWeight||0, d.sideStoneUnitPrice||0, d.sideStoneAmount||0,
d.accessoryAmount||0, d.processingFee||0, d.settingFee||0, d.totalLaborCost||0, d.totalPrice||0]
)
await syncProductMinPrice(Number(id))
res.json({ code: 0, data: { id: result.insertId } })
} catch (err) {
console.error('adminCreateSpecData error:', err)
res.status(500).json({ code: 500, message: '新增规格数据失败' })
}
}
// DELETE /api/admin/products/:productId/spec-data/:specId
export async function adminDeleteSpecData(req: Request, res: Response): Promise<void> {
try {
const { productId, specId } = req.params
await pool.execute('DELETE FROM spec_data WHERE id = ?', [specId])
await syncProductMinPrice(Number(productId))
res.json({ code: 0, message: '删除成功' })
} catch (err) {
console.error('adminDeleteSpecData error:', err)
res.status(500).json({ code: 500, message: '删除规格数据失败' })
}
}
// PUT /api/admin/products/:productId/spec-data/:specId - 编辑规格数据
export async function adminUpdateSpecData(req: Request, res: Response): Promise<void> {
try {
const { productId, specId } = req.params
const d = req.body
if (d.barcode) {
const [dup] = await pool.execute<RowDataPacket[]>('SELECT id FROM spec_data WHERE barcode = ? AND id != ?', [d.barcode, specId])
if (dup.length > 0) { res.status(400).json({ code: 400, message: `条型码 "${d.barcode}" 已存在` }); return }
}
await pool.execute(
`UPDATE spec_data SET model_name=?, barcode=?, fineness=?, main_stone=?, sub_stone=?, ring_size=?,
gold_total_weight=?, gold_net_weight=?, loss=?, gold_loss=?, gold_price=?, gold_value=?,
main_stone_count=?, main_stone_weight=?, main_stone_unit_price=?, main_stone_amount=?,
side_stone_count=?, side_stone_weight=?, side_stone_unit_price=?, side_stone_amount=?,
accessory_amount=?, processing_fee=?, setting_fee=?, total_labor_cost=?, total_price=?
WHERE id=?`,
[d.modelName||'', d.barcode||null, d.fineness||'', d.mainStone||'', d.subStone||'', d.ringSize||'',
d.goldTotalWeight||0, d.goldNetWeight||0, d.loss||0, d.goldLoss||0,
d.goldPrice||0, d.goldValue||0,
d.mainStoneCount||0, d.mainStoneWeight||0, d.mainStoneUnitPrice||0, d.mainStoneAmount||0,
d.sideStoneCount||0, d.sideStoneWeight||0, d.sideStoneUnitPrice||0, d.sideStoneAmount||0,
d.accessoryAmount||0, d.processingFee||0, d.settingFee||0, d.totalLaborCost||0, d.totalPrice||0,
specId]
)
await syncProductMinPrice(Number(productId))
res.json({ code: 0, message: '更新成功' })
} catch (err) {
console.error('adminUpdateSpecData error:', err)
res.status(500).json({ code: 500, message: '更新规格数据失败' })
}
}
// GET /api/admin/products/:id/spec-data/export
export async function exportSpecData(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params
const [productRows] = await pool.execute<RowDataPacket[]>(
'SELECT style_no FROM products WHERE id = ?', [id]
)
const styleNo = productRows.length > 0 ? productRows[0].style_no : ''
const inputDbCols = CSV_INPUT_HEADERS.filter(h => h !== 'style_no')
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT ${inputDbCols.join(', ')} FROM spec_data WHERE product_id = ?`,
[id]
)
const rowsWithStyleNo = rows.map(r => ({ style_no: styleNo, ...r }))
const csv = generateCSV(rowsWithStyleNo, CSV_INPUT_HEADERS)
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename=spec_data_${id}.csv`)
res.send('\uFEFF' + csv)
} catch (err) {
console.error('exportSpecData error:', err)
res.status(500).json({ code: 500, message: '导出规格数据失败' })
}
}
// POST /api/admin/spec-data/lookup - 批量条形码查询商品+规格信息
export async function lookupByBarcodes(req: Request, res: Response): Promise<void> {
try {
const { barcodes } = req.body
if (!barcodes || !Array.isArray(barcodes) || barcodes.length === 0) {
res.status(400).json({ code: 400, message: '请提供条形码列表' })
return
}
const uniqueBarcodes = [...new Set(barcodes.map((b: string) => b.trim()).filter(Boolean))]
if (uniqueBarcodes.length === 0) {
res.status(400).json({ code: 400, message: '条形码列表为空' })
return
}
const placeholders = uniqueBarcodes.map(() => '?').join(',')
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT sd.id AS specDataId, sd.product_id AS productId, sd.barcode,
sd.model_name AS modelName, sd.fineness, sd.main_stone AS mainStone,
sd.sub_stone AS subStone, sd.ring_size AS ringSize,
sd.gold_total_weight AS goldTotalWeight, sd.gold_net_weight AS goldNetWeight,
sd.loss, sd.gold_loss AS goldLoss, sd.gold_price AS goldPrice, sd.gold_value AS goldValue,
sd.main_stone_count AS mainStoneCount, sd.main_stone_weight AS mainStoneWeight,
sd.main_stone_unit_price AS mainStoneUnitPrice, sd.main_stone_amount AS mainStoneAmount,
sd.side_stone_count AS sideStoneCount, sd.side_stone_weight AS sideStoneWeight,
sd.side_stone_unit_price AS sideStoneUnitPrice, sd.side_stone_amount AS sideStoneAmount,
sd.accessory_amount AS accessoryAmount, sd.processing_fee AS processingFee,
sd.setting_fee AS settingFee, sd.total_labor_cost AS totalLaborCost,
sd.total_price AS totalPrice,
p.name AS productName, p.style_no AS styleNo, p.thumb
FROM spec_data sd
LEFT JOIN products p ON sd.product_id = p.id
WHERE sd.barcode IN (${placeholders})`,
uniqueBarcodes
)
const itemsMap: Record<string, any> = {}
for (const row of rows) {
itemsMap[row.barcode] = row
}
const notFound = uniqueBarcodes.filter(b => !itemsMap[b])
res.json({ code: 0, data: { items: itemsMap, notFound } })
} catch (err) {
console.error('lookupByBarcodes error:', err)
res.status(500).json({ code: 500, message: '条形码查询失败' })
}
}
// POST /api/admin/products/:id/spec-data/import
export async function importSpecData(req: Request, res: Response): Promise<void> {
const conn = await pool.getConnection()
try {
if (!req.file) {
res.status(400).json({ code: 400, message: '请上传 CSV 文件' })
return
}
let content = req.file.buffer.toString('utf-8')
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1)
}
if (content.includes('<27>') || content.includes('\ufffd')) {
try {
const iconv = require('iconv-lite')
content = iconv.decode(req.file.buffer, 'gbk')
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1)
}
} catch { /* fallback utf-8 */ }
}
const rows = parseCSV(content)
if (rows.length === 0) {
res.status(400).json({ code: 400, message: 'CSV 文件为空或格式错误' })
return
}
// 校验必填的手动输入列
const firstRow = rows[0]
const requiredHeaders = CSV_INPUT_HEADERS
const missingHeaders = requiredHeaders.filter((h) => !(h in firstRow))
if (missingHeaders.length > 0) {
const missingCN = missingHeaders.map(h => CSV_HEADERS_CN[h] || h).join(', ')
res.status(400).json({ code: 400, message: `缺少必填列: ${missingCN}。请确保CSV表头包含款号、规格名称、条型码等编码为UTF-8` })
return
}
// 获取最新金价和铂金价格
const [gpRows] = await pool.execute<RowDataPacket[]>(
'SELECT price FROM gold_price_logs ORDER BY id DESC LIMIT 1'
)
const goldPrice = gpRows.length > 0 ? Number(gpRows[0].price) : 0
const [ppRows] = await pool.execute<RowDataPacket[]>(
'SELECT price FROM platinum_price_logs ORDER BY id DESC LIMIT 1'
)
const platinumPrice = ppRows.length > 0 ? Number(ppRows[0].price) : 0
// 按款号分组,批量查询对应的 product_id
const styleNos = [...new Set(rows.map(r => (r.style_no || '').trim()).filter(Boolean))]
if (styleNos.length === 0) {
res.status(400).json({ code: 400, message: 'CSV 中未找到有效的"款号"列数据' })
return
}
const [productRows] = await pool.execute<RowDataPacket[]>(
`SELECT id, style_no FROM products WHERE style_no IN (${styleNos.map(() => '?').join(',')})`,
styleNos
)
const styleNoToProductId: Record<string, number> = {}
for (const p of productRows) {
styleNoToProductId[p.style_no] = p.id
}
const notFoundStyleNos = styleNos.filter(s => !styleNoToProductId[s])
if (notFoundStyleNos.length > 0) {
res.status(400).json({ code: 400, message: `以下款号未找到对应商品: ${notFoundStyleNos.join(', ')}` })
return
}
const textFields = ['model_name', 'barcode', 'fineness', 'main_stone', 'sub_stone', 'ring_size']
const numericInputFields = CSV_INPUT_HEADERS.filter(h => h !== 'style_no' && !textFields.includes(h))
const errors: string[] = []
let imported = 0
let skipped = 0
await conn.beginTransaction()
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const styleNo = (row.style_no || '').trim()
const productId = styleNoToProductId[styleNo]
if (!productId) {
errors.push(`${i + 2} 行:款号 "${styleNo}" 未找到对应商品,已跳过`)
skipped++
continue
}
// 条型码去重:如果已存在则跳过
const barcode = (row.barcode || '').trim()
if (barcode) {
const [dup] = await conn.execute<RowDataPacket[]>(
'SELECT id FROM spec_data WHERE barcode = ?', [barcode]
)
if (dup.length > 0) {
errors.push(`${i + 2} 行:条型码 "${barcode}" 已存在,已跳过`)
skipped++
continue
}
}
// 根据成色决定使用金价还是铂金价格
const fineness = (row.fineness || '').trim()
const rowPrice = fineness === '铂金PT950' ? platinumPrice : goldPrice
// 解析手动输入的数值字段
const numericVals: Record<string, number> = { gold_price: rowPrice }
for (const h of numericInputFields) {
const num = Number(row[h])
if (row[h] !== '' && isNaN(num)) {
errors.push(`${i + 2} 行 "${CSV_HEADERS_CN[h] || h}" 列不是有效数字`)
}
numericVals[h] = isNaN(num) ? 0 : num
}
// 自动计算派生字段
const calc = calcSpecFields(numericVals)
const insertCols = ALL_DB_HEADERS
const insertVals = insertCols.map(h => {
if (textFields.includes(h)) return (row[h] || '').trim()
if (h in calc) return (calc as any)[h]
if (h === 'gold_price') return rowPrice
return numericVals[h] ?? 0
})
await conn.execute(
`INSERT INTO spec_data (product_id, ${insertCols.join(', ')})
VALUES (?, ${insertCols.map(() => '?').join(', ')})`,
[productId, ...insertVals]
)
imported++
}
await conn.commit()
// 同步所有涉及商品的最低价
const affectedProductIds = [...new Set(Object.values(styleNoToProductId))]
for (const pid of affectedProductIds) {
await syncProductMinPrice(pid)
}
res.json({
code: 0,
data: { imported, skipped, warnings: errors.length > 0 ? errors : undefined },
})
} catch (err) {
await conn.rollback()
console.error('importSpecData error:', err)
res.status(500).json({ code: 500, message: '导入规格数据失败' })
} finally {
conn.release()
}
}