JewelryMall/server/src/controllers/inventory.ts
2026-04-19 16:08:16 +08:00

372 lines
16 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'
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: '获取商品列表失败' })
}
}