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 = { 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 = Object.fromEntries( Object.entries(CSV_HEADERS_CN).map(([en, cn]) => [cn, en]) ) // 根据手动输入字段自动计算派生字段 function calcSpecFields(row: Record) { 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[] { 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[] = [] for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]) const row: Record = {} 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[], 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 { try { const { id } = req.params const [rows] = await pool.execute( `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 { try { const { id } = req.params const d = req.body if (d.barcode) { const [dup] = await pool.execute('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( `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 { 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 { try { const { productId, specId } = req.params const d = req.body if (d.barcode) { const [dup] = await pool.execute('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 { try { const { id } = req.params const [productRows] = await pool.execute( '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( `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 { 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( `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 = {} 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 { 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('�') || 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( '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( '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( `SELECT id, style_no FROM products WHERE style_no IN (${styleNos.map(() => '?').join(',')})`, styleNos ) const styleNoToProductId: Record = {} 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( '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 = { 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() } }