diff --git a/admin/src/api/inventory.ts b/admin/src/api/inventory.ts new file mode 100644 index 00000000..c7f1f2e6 --- /dev/null +++ b/admin/src/api/inventory.ts @@ -0,0 +1,21 @@ +import http from './request' + +export function getInventoryList(params: { page?: number; pageSize?: number; keyword?: string }) { + return http.get('/admin/inventory', { params }) +} + +export function createInventoryItem(data: any) { + return http.post('/admin/inventory', data) +} + +export function deleteInventoryItems(specIds: number[]) { + return http.delete('/admin/inventory', { data: { specIds } }) +} + +export function exportInventory(keyword?: string) { + return http.get('/admin/inventory/export', { params: { keyword }, responseType: 'blob' } as any) +} + +export function getProductsForSelect() { + return http.get('/admin/inventory/products') +} diff --git a/admin/src/layout/AdminLayout.vue b/admin/src/layout/AdminLayout.vue index e87587fd..f034b1d1 100644 --- a/admin/src/layout/AdminLayout.vue +++ b/admin/src/layout/AdminLayout.vue @@ -23,6 +23,10 @@ 商品管理 + + + 库存管理 + 分类管理 @@ -101,6 +105,7 @@ const titleMap: Record = { '/dashboard': '', '/products': '商品管理', '/products/create': '新增商品', + '/inventory': '库存管理', '/categories': '分类管理', '/orders': '订单管理', '/molds': '版房管理', diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts index fc59088d..9e4c7fe4 100644 --- a/admin/src/router/index.ts +++ b/admin/src/router/index.ts @@ -34,6 +34,11 @@ const router = createRouter({ name: 'ProductEdit', component: () => import('../views/product/ProductForm.vue'), }, + { + path: 'inventory', + name: 'InventoryList', + component: () => import('../views/inventory/InventoryList.vue'), + }, { path: 'orders', name: 'OrderList', diff --git a/admin/src/views/inventory/InventoryList.vue b/admin/src/views/inventory/InventoryList.vue new file mode 100644 index 00000000..9eef0a30 --- /dev/null +++ b/admin/src/views/inventory/InventoryList.vue @@ -0,0 +1,359 @@ + + + + + + 库存管理 + + + 导出 CSV + + + 导入 CSV + + + 新增库存 + + + + + + + + + + + + + + + + + {{ row.productName }} + + + + + + + + + + + {{ row.count }} + + + + + + 删除 + + + + + + + + 批量删除 ({{ selectedRows.length }}) + + + + + + + + + 选择商品 + + + + + + + + + + 基本信息 + + + + + + + + + + + + + + + + + + 金料信息 + + + + + + + + + + + 主石信息 + + + + + + + 副石信息 + + + + + + + 工费信息 + + + 读取商品工费 + 根据主石副石自动计算 + + + + + + + + 取消 + 确定 + + + + + + + + diff --git a/admin/src/views/product/ProductForm.vue b/admin/src/views/product/ProductForm.vue index 518e907a..fb67ac93 100644 --- a/admin/src/views/product/ProductForm.vue +++ b/admin/src/views/product/ProductForm.vue @@ -225,8 +225,8 @@ - - + + @@ -293,10 +293,6 @@ - - - - @@ -328,8 +324,8 @@ 工费信息 - - + 读取商品基本信息的工费 + 根据主石副石自动计算 @@ -450,7 +446,7 @@ function resetSpecForm() { goldPrice: globalGoldPrice.value, goldValue: 0, mainStoneCount: 0, mainStoneWeight: 0, mainStoneUnitPrice: 0, mainStoneAmount: 0, sideStoneCount: 0, sideStoneWeight: 0, sideStoneUnitPrice: 0, sideStoneAmount: 0, - accessoryAmount: 0, processingFee: 0, settingFee: 0, totalLaborCost: 0, totalPrice: 0, + accessoryAmount: 0, processingFee: Number(form.laborCost) || 0, settingFee: 0, totalLaborCost: 0, totalPrice: 0, }) } @@ -464,6 +460,21 @@ function recalcSpec() { f.goldValue = +(f.goldLoss * n(f.goldPrice)).toFixed(2) f.mainStoneAmount = +(n(f.mainStoneWeight) * n(f.mainStoneUnitPrice)).toFixed(2) f.sideStoneAmount = +(n(f.sideStoneWeight) * n(f.sideStoneUnitPrice)).toFixed(2) + // 镶石工费自动计算 + const mc = n(f.mainStoneCount) + const mw = n(f.mainStoneWeight) + const sc = n(f.sideStoneCount) + 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 + } + f.settingFee = +(mc * unitPrice + sc * 3).toFixed(2) + f.processingFee = Number(form.laborCost) || 0 f.totalLaborCost = +(n(f.accessoryAmount) + n(f.processingFee) + n(f.settingFee)).toFixed(2) f.totalPrice = +(f.goldValue + f.mainStoneAmount + f.sideStoneAmount + f.totalLaborCost).toFixed(2) } @@ -475,8 +486,8 @@ function onFinenesChange(val: string) { watch( () => [ specForm.goldTotalWeight, specForm.loss, specForm.goldPrice, - specForm.mainStoneWeight, specForm.mainStoneUnitPrice, - specForm.sideStoneWeight, specForm.sideStoneUnitPrice, + specForm.mainStoneCount, specForm.mainStoneWeight, specForm.mainStoneUnitPrice, + specForm.sideStoneCount, specForm.sideStoneWeight, specForm.sideStoneUnitPrice, specForm.accessoryAmount, specForm.processingFee, specForm.settingFee, ], recalcSpec, @@ -498,7 +509,7 @@ function handleEditSpec(row: any) { goldPrice: row.goldPrice, goldValue: row.goldValue, mainStoneCount: row.mainStoneCount, mainStoneWeight: row.mainStoneWeight, mainStoneUnitPrice: row.mainStoneUnitPrice, mainStoneAmount: row.mainStoneAmount, sideStoneCount: row.sideStoneCount, sideStoneWeight: row.sideStoneWeight, sideStoneUnitPrice: row.sideStoneUnitPrice, sideStoneAmount: row.sideStoneAmount, - accessoryAmount: row.accessoryAmount, processingFee: row.processingFee, settingFee: row.settingFee, totalLaborCost: row.totalLaborCost, totalPrice: row.totalPrice, + accessoryAmount: row.accessoryAmount, processingFee: Number(form.laborCost) || 0, settingFee: row.settingFee, totalLaborCost: row.totalLaborCost, totalPrice: row.totalPrice, }) specDialogVisible.value = true } diff --git a/admin/src/views/product/ProductList.vue b/admin/src/views/product/ProductList.vue index d6f70f08..4e6bbff8 100644 --- a/admin/src/views/product/ProductList.vue +++ b/admin/src/views/product/ProductList.vue @@ -27,8 +27,8 @@ :cell-style="{ padding:'6px 0', fontSize:'12px' }"> - - + + @@ -112,12 +112,12 @@ async function handleExpandChange(row: any, expandedRows: any[]) { const groupMap = new Map; count: number }>() for (const s of specs) { - const key = `${s.fineness || '-'}|${s.mainStone || '-'}|${s.subStone || '-'}|${s.ringSize || '-'}` + const key = `${s.fineness || '-'}|${s.mainStoneWeight ?? '-'}|${s.sideStoneWeight ?? '-'}|${s.ringSize || '-'}` if (!groupMap.has(key)) { groupMap.set(key, { fineness: s.fineness || '-', - mainStone: s.mainStone || '-', - subStone: s.subStone || '-', + mainStoneWeight: s.mainStoneWeight ?? '-', + sideStoneWeight: s.sideStoneWeight ?? '-', ringSize: s.ringSize || '-', modelNames: new Set(), count: 0, diff --git a/miniprogram/components/SpecPanel.vue b/miniprogram/components/SpecPanel.vue index 6fa70f1f..cb729c6e 100644 --- a/miniprogram/components/SpecPanel.vue +++ b/miniprogram/components/SpecPanel.vue @@ -77,18 +77,6 @@ 损耗 {{ spec.loss }}% - - 金耗 - {{ spec.goldLoss }}g - - - 金价 - ¥{{ spec.goldPrice }} - - - 金值 - ¥{{ spec.goldValue }} - 主石数量 {{ spec.mainStoneCount }}粒 @@ -101,42 +89,14 @@ 主石单价 ¥{{ spec.mainStoneUnitPrice }} - - 主石金额 - ¥{{ spec.mainStoneAmount }} - - - 副石数量 - {{ spec.sideStoneCount }}粒 - 副石石重 {{ spec.sideStoneWeight }}ct - - 副石单价 - ¥{{ spec.sideStoneUnitPrice }} - - - 副石金额 - ¥{{ spec.sideStoneAmount }} - - - 配件金额 - ¥{{ spec.accessoryAmount }} - 加工工费 ¥{{ spec.processingFee }} - - 镶石工费 - ¥{{ spec.settingFee }} - - - 总工费 - ¥{{ spec.totalLaborCost }} - diff --git a/miniprogram/utils/request.ts b/miniprogram/utils/request.ts index 4f50c27a..e3dcde00 100644 --- a/miniprogram/utils/request.ts +++ b/miniprogram/utils/request.ts @@ -1,6 +1,6 @@ // 手动切换后端地址,部署时改成线上域名即可 -const BASE_URL = 'http://localhost:3000' -// const BASE_URL = 'http://115.190.188.216:2850' +// const BASE_URL = 'http://localhost:3000' +const BASE_URL = 'http://115.190.188.216:2850' export { BASE_URL } diff --git a/server/src/controllers/adminProduct.ts b/server/src/controllers/adminProduct.ts index 96585797..69783a4f 100644 --- a/server/src/controllers/adminProduct.ts +++ b/server/src/controllers/adminProduct.ts @@ -2,6 +2,20 @@ import { Request, Response } from 'express' import pool from '../utils/db' import { RowDataPacket, ResultSetHeader } from 'mysql2' +// 镶石工费计算 +function calcSettingFee(mc: number, mw: number, sc: number): number { + const avg = mc > 0 ? mw / mc : 0 + let u = 0 + if (mc > 0) { + if (avg <= 0.1) u = 5 + else if (avg < 0.5) u = 10 + else if (avg <= 1.0) u = 20 + else if (avg < 1.5) u = 30 + else u = 50 + } + return +(mc * u + sc * 3).toFixed(2) +} + // GET /api/admin/products - 管理后台商品列表 export async function adminGetProducts(req: Request, res: Response): Promise { try { @@ -126,7 +140,7 @@ export async function adminCreateProduct(req: Request, res: Response): Promise 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 { + 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( + `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( + `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 { + try { + const d = req.body + if (!d.productId) { + res.status(400).json({ code: 400, message: '请选择商品' }) + return + } + // 验证商品存在并获取工费 + const [pRows] = await pool.execute('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('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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [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 { + 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( + `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 { + 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( + `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 { + 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 [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 + + // 按款号查商品 + 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, labor_cost FROM products WHERE style_no IN (${styleNos.map(() => '?').join(',')})`, + styleNos + ) + const styleNoToProductId: Record = {} + const styleNoToLaborCost: Record = {} + 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('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]) + 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 = { + 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 { + try { + const [rows] = await pool.execute( + '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: '获取商品列表失败' }) + } +} diff --git a/server/src/controllers/specDataIO.ts b/server/src/controllers/specDataIO.ts index 46391d58..da612859 100644 --- a/server/src/controllers/specDataIO.ts +++ b/server/src/controllers/specDataIO.ts @@ -48,7 +48,21 @@ function calcSpecFields(row: Record) { 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 mc = n(row.main_stone_count) + const mw = n(row.main_stone_weight) + const sc = n(row.side_stone_count) + 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 + } + const settingFee = +(mc * unitPrice + sc * 3).toFixed(2) + const totalLaborCost = +(n(row.accessory_amount) + n(row.processing_fee) + settingFee).toFixed(2) const totalPrice = +(goldValue + mainStoneAmount + sideStoneAmount + totalLaborCost).toFixed(2) return { gold_net_weight: safeGoldNetWeight, @@ -56,11 +70,29 @@ function calcSpecFields(row: Record) { gold_value: goldValue, main_stone_amount: mainStoneAmount, side_stone_amount: sideStoneAmount, + setting_fee: settingFee, total_labor_cost: totalLaborCost, total_price: totalPrice, } } +// 镶石工费计算 +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) +} + function escapeCSVField(value: string | number): string { const str = String(value) if (str.includes(',') || str.includes('"') || str.includes('\n')) { @@ -164,6 +196,12 @@ export async function adminCreateSpecData(req: Request, res: Response): Promise< 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 [pRows] = await pool.execute('SELECT labor_cost FROM products WHERE id = ?', [id]) + const laborCost = pRows.length > 0 ? Number(pRows[0].labor_cost) || 0 : 0 + const processingFee = laborCost + // 镶石工费自动计算 + const settingFee = calcSettingFee(Number(d.mainStoneCount)||0, Number(d.mainStoneWeight)||0, Number(d.sideStoneCount)||0) 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, @@ -176,7 +214,7 @@ export async function adminCreateSpecData(req: Request, res: Response): Promise< 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] + d.accessoryAmount||0, processingFee, settingFee, d.totalLaborCost||0, d.totalPrice||0] ) await syncProductMinPrice(Number(id)) res.json({ code: 0, data: { id: result.insertId } }) @@ -208,6 +246,12 @@ export async function adminUpdateSpecData(req: Request, res: Response): Promise< 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 } } + // 读取商品工费作为加工工费 + const [pRows] = await pool.execute('SELECT labor_cost FROM products WHERE id = ?', [productId]) + const laborCost = pRows.length > 0 ? Number(pRows[0].labor_cost) || 0 : 0 + const processingFee = laborCost + // 镶石工费自动计算 + const settingFee = calcSettingFee(Number(d.mainStoneCount)||0, Number(d.mainStoneWeight)||0, Number(d.sideStoneCount)||0) 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=?, @@ -220,7 +264,7 @@ export async function adminUpdateSpecData(req: Request, res: Response): Promise< 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, + d.accessoryAmount||0, processingFee, settingFee, d.totalLaborCost||0, d.totalPrice||0, specId] ) await syncProductMinPrice(Number(productId)) @@ -365,12 +409,14 @@ export async function importSpecData(req: Request, res: Response): Promise } const [productRows] = await pool.execute( - `SELECT id, style_no FROM products WHERE style_no IN (${styleNos.map(() => '?').join(',')})`, + `SELECT id, style_no, labor_cost FROM products WHERE style_no IN (${styleNos.map(() => '?').join(',')})`, styleNos ) const styleNoToProductId: Record = {} + const styleNoToLaborCost: Record = {} for (const p of productRows) { styleNoToProductId[p.style_no] = p.id + styleNoToLaborCost[p.style_no] = Number(p.labor_cost) || 0 } const notFoundStyleNos = styleNos.filter(s => !styleNoToProductId[s]) @@ -424,6 +470,9 @@ export async function importSpecData(req: Request, res: Response): Promise numericVals[h] = isNaN(num) ? 0 : num } + // 加工工费统一使用商品的工费 + numericVals['processing_fee'] = styleNoToLaborCost[styleNo] || 0 + // 自动计算派生字段 const calc = calcSpecFields(numericVals) diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 4dcd8185..83508fac 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -38,6 +38,14 @@ import { import { adminGetConfigs, adminUpdateConfig } from '../controllers/config' import { adminGetUsers } from '../controllers/adminUser' import { getGoldPriceLogs, getLatestGoldPrice, setGoldPrice, getPlatinumPriceLogs, getLatestPlatinumPrice, setPlatinumPrice } from '../controllers/goldPrice' +import { + getInventoryList, + createInventoryItem, + deleteInventoryItems, + exportInventory, + importInventory, + getProductsForSelect, +} from '../controllers/inventory' const csvUpload = multer({ storage: multer.memoryStorage() }) @@ -117,6 +125,14 @@ adminRoutes.get('/gold-price', getGoldPriceLogs) adminRoutes.get('/gold-price/latest', getLatestGoldPrice) adminRoutes.post('/gold-price', setGoldPrice) +// Inventory management +adminRoutes.get('/inventory', getInventoryList) +adminRoutes.post('/inventory', createInventoryItem) +adminRoutes.delete('/inventory', deleteInventoryItems) +adminRoutes.get('/inventory/export', exportInventory) +adminRoutes.post('/inventory/import', csvUpload.single('file'), importInventory) +adminRoutes.get('/inventory/products', getProductsForSelect) + // Platinum price management adminRoutes.get('/platinum-price', getPlatinumPriceLogs) adminRoutes.get('/platinum-price/latest', getLatestPlatinumPrice)