372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
import { Request, Response } from 'express'
|
||
import pool from '../utils/db'
|
||
import { RowDataPacket, ResultSetHeader } from 'mysql2'
|
||
import { syncProductMinPrice } from '../utils/syncPrice'
|
||
import { parseCSV, generateCSV } from './specDataIO'
|
||
|
||
// 镶石工费计算
|
||
function calcSettingFee(mainStoneCount: number, mainStoneWeight: number, sideStoneCount: number): number {
|
||
const mc = mainStoneCount || 0
|
||
const mw = mainStoneWeight || 0
|
||
const sc = sideStoneCount || 0
|
||
const avg = mc > 0 ? mw / mc : 0
|
||
let unitPrice = 0
|
||
if (mc > 0) {
|
||
if (avg <= 0.1) unitPrice = 5
|
||
else if (avg < 0.5) unitPrice = 10
|
||
else if (avg <= 1.0) unitPrice = 20
|
||
else if (avg < 1.5) unitPrice = 30
|
||
else unitPrice = 50
|
||
}
|
||
return +(mc * unitPrice + sc * 3).toFixed(2)
|
||
}
|
||
|
||
// GET /api/admin/inventory - 库存列表(合并相同参数的规格)
|
||
export async function getInventoryList(req: Request, res: Response): Promise<void> {
|
||
try {
|
||
const page = Math.max(1, Number(req.query.page) || 1)
|
||
const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 20))
|
||
const keyword = req.query.keyword as string | undefined
|
||
const offset = (page - 1) * pageSize
|
||
|
||
let where = 'WHERE 1=1'
|
||
const params: any[] = []
|
||
|
||
if (keyword && keyword.trim()) {
|
||
where += ' AND (p.name LIKE ? OR p.style_no LIKE ? OR sd.model_name LIKE ?)'
|
||
const kw = `%${keyword.trim()}%`
|
||
params.push(kw, kw, kw)
|
||
}
|
||
|
||
// 合并查询:按商品+规格名称+4个基本参数分组
|
||
const groupCols = 'p.id, p.name, p.style_no, sd.model_name, sd.fineness, sd.main_stone_weight, sd.side_stone_weight, sd.ring_size'
|
||
|
||
const [countRows] = await pool.execute<RowDataPacket[]>(
|
||
`SELECT COUNT(*) AS total FROM (
|
||
SELECT 1 FROM spec_data sd
|
||
INNER JOIN products p ON sd.product_id = p.id
|
||
${where}
|
||
GROUP BY ${groupCols}
|
||
) t`,
|
||
params
|
||
)
|
||
const total = countRows[0].total
|
||
|
||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||
`SELECT p.id AS productId, p.name AS productName, p.style_no AS styleNo,
|
||
sd.model_name AS modelName, sd.fineness,
|
||
sd.main_stone_weight AS mainStoneWeight,
|
||
sd.side_stone_weight AS sideStoneWeight,
|
||
sd.ring_size AS ringSize,
|
||
COUNT(*) AS count,
|
||
GROUP_CONCAT(sd.id ORDER BY sd.id) AS specIds
|
||
FROM spec_data sd
|
||
INNER JOIN products p ON sd.product_id = p.id
|
||
${where}
|
||
GROUP BY ${groupCols}
|
||
ORDER BY p.id DESC, sd.model_name ASC
|
||
LIMIT ? OFFSET ?`,
|
||
[...params, String(pageSize), String(offset)]
|
||
)
|
||
|
||
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
|
||
} catch (err) {
|
||
console.error('getInventoryList error:', err)
|
||
res.status(500).json({ code: 500, message: '获取库存列表失败' })
|
||
}
|
||
}
|
||
|
||
|
||
// POST /api/admin/inventory - 新增库存(同商品规格新增一样)
|
||
export async function createInventoryItem(req: Request, res: Response): Promise<void> {
|
||
try {
|
||
const d = req.body
|
||
if (!d.productId) {
|
||
res.status(400).json({ code: 400, message: '请选择商品' })
|
||
return
|
||
}
|
||
// 验证商品存在并获取工费
|
||
const [pRows] = await pool.execute<RowDataPacket[]>('SELECT id, labor_cost FROM products WHERE id = ?', [d.productId])
|
||
if (pRows.length === 0) {
|
||
res.status(400).json({ code: 400, message: '商品不存在' })
|
||
return
|
||
}
|
||
const laborCost = Number(pRows[0].labor_cost) || 0
|
||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||
[d.productId, 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, laborCost, calcSettingFee(Number(d.mainStoneCount)||0, Number(d.mainStoneWeight)||0, Number(d.sideStoneCount)||0), d.totalLaborCost||0, d.totalPrice||0]
|
||
)
|
||
await syncProductMinPrice(Number(d.productId))
|
||
res.json({ code: 0, data: { id: result.insertId } })
|
||
} catch (err) {
|
||
console.error('createInventoryItem error:', err)
|
||
res.status(500).json({ code: 500, message: '新增库存失败' })
|
||
}
|
||
}
|
||
|
||
// DELETE /api/admin/inventory - 批量删除库存(按合并组的specIds)
|
||
export async function deleteInventoryItems(req: Request, res: Response): Promise<void> {
|
||
const conn = await pool.getConnection()
|
||
try {
|
||
const { specIds } = req.body
|
||
if (!specIds || !Array.isArray(specIds) || specIds.length === 0) {
|
||
res.status(400).json({ code: 400, message: '请提供要删除的规格ID' })
|
||
return
|
||
}
|
||
|
||
// 获取涉及的商品ID
|
||
const placeholders = specIds.map(() => '?').join(',')
|
||
const [productRows] = await conn.execute<RowDataPacket[]>(
|
||
`SELECT DISTINCT product_id FROM spec_data WHERE id IN (${placeholders})`,
|
||
specIds
|
||
)
|
||
|
||
await conn.beginTransaction()
|
||
await conn.execute(`DELETE FROM spec_data WHERE id IN (${placeholders})`, specIds)
|
||
await conn.commit()
|
||
|
||
// 同步商品最低价
|
||
for (const row of productRows) {
|
||
await syncProductMinPrice(row.product_id)
|
||
}
|
||
|
||
res.json({ code: 0, message: '删除成功' })
|
||
} catch (err) {
|
||
await conn.rollback()
|
||
console.error('deleteInventoryItems error:', err)
|
||
res.status(500).json({ code: 500, message: '删除库存失败' })
|
||
} finally {
|
||
conn.release()
|
||
}
|
||
}
|
||
|
||
// GET /api/admin/inventory/export - 导出全部库存CSV
|
||
export async function exportInventory(req: Request, res: Response): Promise<void> {
|
||
try {
|
||
const keyword = req.query.keyword as string | undefined
|
||
let where = 'WHERE 1=1'
|
||
const params: any[] = []
|
||
if (keyword && keyword.trim()) {
|
||
where += ' AND (p.name LIKE ? OR p.style_no LIKE ? OR sd.model_name LIKE ?)'
|
||
const kw = `%${keyword.trim()}%`
|
||
params.push(kw, kw, kw)
|
||
}
|
||
|
||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||
`SELECT p.style_no, sd.model_name, sd.barcode, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size,
|
||
sd.gold_total_weight, sd.loss,
|
||
sd.main_stone_count, sd.main_stone_weight, sd.main_stone_unit_price,
|
||
sd.side_stone_count, sd.side_stone_weight, sd.side_stone_unit_price,
|
||
sd.accessory_amount, sd.processing_fee, sd.setting_fee
|
||
FROM spec_data sd
|
||
INNER JOIN products p ON sd.product_id = p.id
|
||
${where}
|
||
ORDER BY p.id DESC, sd.id ASC`,
|
||
params
|
||
)
|
||
|
||
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 = generateCSV(rows, CSV_INPUT_HEADERS)
|
||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||
res.setHeader('Content-Disposition', 'attachment; filename=inventory_export.csv')
|
||
res.send('\uFEFF' + csv)
|
||
} catch (err) {
|
||
console.error('exportInventory error:', err)
|
||
res.status(500).json({ code: 500, message: '导出库存失败' })
|
||
}
|
||
}
|
||
|
||
// POST /api/admin/inventory/import - 导入库存CSV(复用specDataIO的导入逻辑)
|
||
export async function importInventory(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 [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
|
||
|
||
// 按款号查商品
|
||
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, labor_cost FROM products WHERE style_no IN (${styleNos.map(() => '?').join(',')})`,
|
||
styleNos
|
||
)
|
||
const styleNoToProductId: Record<string, number> = {}
|
||
const styleNoToLaborCost: Record<string, number> = {}
|
||
for (const p of productRows) {
|
||
styleNoToProductId[p.style_no] = p.id
|
||
styleNoToLaborCost[p.style_no] = Number(p.labor_cost) || 0
|
||
}
|
||
|
||
const notFound = styleNos.filter(s => !styleNoToProductId[s])
|
||
if (notFound.length > 0) {
|
||
res.status(400).json({ code: 400, message: `以下款号未找到对应商品: ${notFound.join(', ')}` })
|
||
return
|
||
}
|
||
|
||
const textFields = ['model_name', 'barcode', 'fineness', 'main_stone', 'sub_stone', 'ring_size']
|
||
const numericInputFields = ['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 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',
|
||
]
|
||
|
||
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) { 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])
|
||
numericVals[h] = isNaN(num) ? 0 : num
|
||
}
|
||
|
||
// 加工工费统一使用商品的工费
|
||
numericVals['processing_fee'] = styleNoToLaborCost[styleNo] || 0
|
||
|
||
// calc derived fields
|
||
const n = (v: any) => Number(v) || 0
|
||
const goldNetWeight = +(n(numericVals.gold_total_weight) - n(numericVals.main_stone_weight) * 0.2 - n(numericVals.side_stone_weight) * 0.2).toFixed(4)
|
||
const safeGoldNetWeight = goldNetWeight < 0 ? 0 : goldNetWeight
|
||
const goldLoss = +(safeGoldNetWeight * n(numericVals.loss)).toFixed(4)
|
||
const goldValue = +(goldLoss * rowPrice).toFixed(2)
|
||
const mainStoneAmount = +(n(numericVals.main_stone_weight) * n(numericVals.main_stone_unit_price)).toFixed(2)
|
||
const sideStoneAmount = +(n(numericVals.side_stone_weight) * n(numericVals.side_stone_unit_price)).toFixed(2)
|
||
// 镶石工费自动计算
|
||
const mc = n(numericVals.main_stone_count)
|
||
const mw = n(numericVals.main_stone_weight)
|
||
const sc = n(numericVals.side_stone_count)
|
||
const avg = mc > 0 ? mw / mc : 0
|
||
let unitPriceSetting = 0
|
||
if (mc > 0) {
|
||
if (avg <= 0.1) unitPriceSetting = 5
|
||
else if (avg < 0.5) unitPriceSetting = 10
|
||
else if (avg <= 1.0) unitPriceSetting = 20
|
||
else if (avg < 1.5) unitPriceSetting = 30
|
||
else unitPriceSetting = 50
|
||
}
|
||
const settingFeeCalc = +(mc * unitPriceSetting + sc * 3).toFixed(2)
|
||
numericVals['setting_fee'] = settingFeeCalc
|
||
const totalLaborCost = +(n(numericVals.accessory_amount) + n(numericVals.processing_fee) + settingFeeCalc).toFixed(2)
|
||
const totalPrice = +(goldValue + mainStoneAmount + sideStoneAmount + totalLaborCost).toFixed(2)
|
||
|
||
const calc: Record<string, number> = {
|
||
gold_net_weight: safeGoldNetWeight, gold_loss: goldLoss, gold_value: goldValue,
|
||
main_stone_amount: mainStoneAmount, side_stone_amount: sideStoneAmount,
|
||
setting_fee: settingFeeCalc,
|
||
total_labor_cost: totalLaborCost, total_price: totalPrice,
|
||
}
|
||
|
||
const insertVals = ALL_DB_HEADERS.map(h => {
|
||
if (textFields.includes(h)) return (row[h] || '').trim()
|
||
if (h in calc) return calc[h]
|
||
if (h === 'gold_price') return rowPrice
|
||
return numericVals[h] ?? 0
|
||
})
|
||
|
||
await conn.execute(
|
||
`INSERT INTO spec_data (product_id, ${ALL_DB_HEADERS.join(', ')}) VALUES (?, ${ALL_DB_HEADERS.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('importInventory error:', err)
|
||
res.status(500).json({ code: 500, message: '导入库存失败' })
|
||
} finally {
|
||
conn.release()
|
||
}
|
||
}
|
||
|
||
// GET /api/admin/inventory/products - 获取商品列表(用于新增时选择商品)
|
||
export async function getProductsForSelect(req: Request, res: Response): Promise<void> {
|
||
try {
|
||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||
'SELECT id, name, style_no AS styleNo, labor_cost AS laborCost FROM products ORDER BY id DESC'
|
||
)
|
||
res.json({ code: 0, data: rows })
|
||
} catch (err) {
|
||
console.error('getProductsForSelect error:', err)
|
||
res.status(500).json({ code: 500, message: '获取商品列表失败' })
|
||
}
|
||
}
|