规格管理

This commit is contained in:
18631081161 2026-04-19 16:08:16 +08:00
parent 2a736f0aae
commit 06bf3c7365
12 changed files with 875 additions and 64 deletions

View File

@ -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')
}

View File

@ -23,6 +23,10 @@
<el-icon><Goods /></el-icon>
<template #title>商品管理</template>
</el-menu-item>
<el-menu-item index="/inventory">
<el-icon><Files /></el-icon>
<template #title>库存管理</template>
</el-menu-item>
<el-menu-item index="/categories">
<el-icon><Grid /></el-icon>
<template #title>分类管理</template>
@ -101,6 +105,7 @@ const titleMap: Record<string, string> = {
'/dashboard': '',
'/products': '商品管理',
'/products/create': '新增商品',
'/inventory': '库存管理',
'/categories': '分类管理',
'/orders': '订单管理',
'/molds': '版房管理',

View File

@ -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',

View File

@ -0,0 +1,359 @@
<template>
<div>
<el-card shadow="never" class="page-card">
<template #header>
<div class="page-card__header">
<span class="page-card__title">库存管理</span>
<div style="display:flex;gap:8px">
<el-button type="success" @click="handleExport">
<el-icon><Download /></el-icon> CSV
</el-button>
<el-upload
:action="importUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImportSuccess"
:on-error="handleImportError"
accept=".csv"
style="display:inline-block"
>
<el-button type="warning"><el-icon><Upload /></el-icon> CSV</el-button>
</el-upload>
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
</el-button>
</div>
</div>
</template>
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索商品名称/款号/规格名称" style="width:360px" clearable
@clear="fetchList" @keyup.enter="fetchList">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<el-table :data="list" v-loading="loading" stripe border
:header-cell-style="{ background:'#f5f5ff', fontWeight:600 }"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" align="center" />
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column prop="productName" label="商品名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-weight:600">{{ row.productName }}</span>
</template>
</el-table-column>
<el-table-column prop="styleNo" label="款号" width="120" align="center" />
<el-table-column prop="modelName" label="规格名称" width="130" align="center" show-overflow-tooltip />
<el-table-column prop="fineness" label="成色" width="100" align="center" />
<el-table-column prop="mainStoneWeight" label="主石" width="90" align="center" />
<el-table-column prop="sideStoneWeight" label="副石" width="90" align="center" />
<el-table-column prop="ringSize" label="手寸" width="80" align="center" />
<el-table-column prop="count" label="数量" width="80" align="center">
<template #default="{ row }">
<span style="font-weight:700;color:#7c5cfc;font-size:15px">{{ row.count }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px">
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
批量删除 ({{ selectedRows.length }})
</el-button>
<el-pagination v-if="total > 0" layout="total, prev, pager, next"
:total="total" :page-size="pageSize" :current-page="page"
@current-change="handlePageChange" />
</div>
</el-card>
<!-- 新增库存弹窗 -->
<el-dialog v-model="addDialogVisible" title="新增库存" width="720px" @close="resetSpecForm" destroy-on-close>
<el-form :model="specForm" label-width="90px" size="small">
<div class="dialog-section">选择商品</div>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="商品">
<el-select v-model="specForm.productId" placeholder="请选择商品" filterable style="width:100%">
<el-option v-for="p in productOptions" :key="p.id" :label="`${p.name} (${p.styleNo})`" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="dialog-section">基本信息</div>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="规格名称"><el-input v-model="specForm.modelName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="条形码"><el-input v-model="specForm.barcode" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="成色">
<el-select v-model="specForm.fineness" placeholder="请选择成色" style="width:100%" @change="onFinenesChange">
<el-option label="18k白" value="18k白" />
<el-option label="18k黄" value="18k黄" />
<el-option label="18k玫瑰金" value="18k玫瑰金" />
<el-option label="铂金PT950" value="铂金PT950" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="手寸"><el-input v-model="specForm.ringSize" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">金料信息</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="金料总重"><el-input-number v-model="specForm.goldTotalWeight" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="金料净重"><el-input-number v-model="specForm.goldNetWeight" :precision="4" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="损耗"><el-input-number v-model="specForm.loss" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="金耗"><el-input-number v-model="specForm.goldLoss" :precision="4" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="金价"><el-input-number v-model="specForm.goldPrice" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="金值"><el-input-number v-model="specForm.goldValue" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">主石信息</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="数量"><el-input-number v-model="specForm.mainStoneCount" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="石重"><el-input-number v-model="specForm.mainStoneWeight" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="单价"><el-input-number v-model="specForm.mainStoneUnitPrice" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="金额"><el-input-number v-model="specForm.mainStoneAmount" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">副石信息</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="数量"><el-input-number v-model="specForm.sideStoneCount" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="石重"><el-input-number v-model="specForm.sideStoneWeight" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="单价"><el-input-number v-model="specForm.sideStoneUnitPrice" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="金额"><el-input-number v-model="specForm.sideStoneAmount" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">工费信息</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="配件金额"><el-input-number v-model="specForm.accessoryAmount" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="加工工费"><el-input-number v-model="specForm.processingFee" :precision="2" disabled controls-position="right" style="width:100%" /><div style="font-size:11px;color:#999;margin-top:2px">读取商品工费</div></el-form-item></el-col>
<el-col :span="6"><el-form-item label="镶石工费"><el-input-number v-model="specForm.settingFee" :precision="2" disabled controls-position="right" style="width:100%" /><div style="font-size:11px;color:#999;margin-top:2px">根据主石副石自动计算</div></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总工费"><el-input-number v-model="specForm.totalLaborCost" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="总价"><el-input-number v-model="specForm.totalPrice" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleCreate">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getInventoryList, createInventoryItem, deleteInventoryItems, exportInventory, getProductsForSelect } from '../../api/inventory'
import { getLatestGoldPrice, getLatestPlatinumPrice } from '../../api/goldPrice'
import { getUploadUrl } from '../../api/request'
const list = ref<any[]>([])
const loading = ref(false)
const keyword = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
const selectedRows = ref<any[]>([])
const addDialogVisible = ref(false)
const saving = ref(false)
const productOptions = ref<any[]>([])
const globalGoldPrice = ref(0)
const globalPlatinumPrice = ref(0)
const apiBaseUrl = getUploadUrl().replace('/admin/upload', '')
const importUrl = `${apiBaseUrl}/admin/inventory/import`
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}`,
}))
const specForm = reactive({
productId: null as number | null,
modelName: '', barcode: '', fineness: '', mainStone: '', subStone: '', ringSize: '',
goldTotalWeight: 0, goldNetWeight: 0, loss: 0, goldLoss: 0,
goldPrice: 0, 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,
})
function resetSpecForm() {
Object.assign(specForm, {
productId: null,
modelName: '', barcode: '', fineness: '', mainStone: '', subStone: '', ringSize: '',
goldTotalWeight: 0, goldNetWeight: 0, loss: 0, goldLoss: 0,
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,
})
}
function recalcSpec() {
const f = specForm
const n = (v: any) => Number(v) || 0
f.goldNetWeight = +(n(f.goldTotalWeight) - n(f.mainStoneWeight) * 0.2 - n(f.sideStoneWeight) * 0.2).toFixed(4)
if (f.goldNetWeight < 0) f.goldNetWeight = 0
f.goldLoss = +(f.goldNetWeight * n(f.loss)).toFixed(4)
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.totalLaborCost = +(n(f.accessoryAmount) + n(f.processingFee) + n(f.settingFee)).toFixed(2)
f.totalPrice = +(f.goldValue + f.mainStoneAmount + f.sideStoneAmount + f.totalLaborCost).toFixed(2)
}
function onFinenesChange(val: string) {
specForm.goldPrice = val === '铂金PT950' ? globalPlatinumPrice.value : globalGoldPrice.value
}
watch(() => specForm.productId, (pid) => {
const p = productOptions.value.find((o: any) => o.id === pid)
specForm.processingFee = p ? Number(p.laborCost) || 0 : 0
})
watch(
() => [
specForm.goldTotalWeight, specForm.loss, specForm.goldPrice,
specForm.mainStoneCount, specForm.mainStoneWeight, specForm.mainStoneUnitPrice,
specForm.sideStoneCount, specForm.sideStoneWeight, specForm.sideStoneUnitPrice,
specForm.accessoryAmount, specForm.processingFee, specForm.settingFee,
],
recalcSpec,
)
async function fetchList() {
loading.value = true
try {
const res: any = await getInventoryList({ page: page.value, pageSize, keyword: keyword.value })
list.value = res.data.list
total.value = res.data.total
} catch { ElMessage.error('获取库存列表失败') }
finally { loading.value = false }
}
function handlePageChange(p: number) { page.value = p; fetchList() }
function handleSelectionChange(rows: any[]) { selectedRows.value = rows }
async function openAddDialog() {
addDialogVisible.value = true
if (productOptions.value.length === 0) {
try {
const res: any = await getProductsForSelect()
productOptions.value = res.data
} catch { /* ignore */ }
}
}
async function handleCreate() {
if (!specForm.productId) { ElMessage.warning('请选择商品'); return }
saving.value = true
try {
await createInventoryItem({ ...specForm })
ElMessage.success('新增成功')
addDialogVisible.value = false
resetSpecForm()
fetchList()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '新增失败')
} finally { saving.value = false }
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(`确定删除该组 ${row.count} 条规格数据?`, '提示', { type: 'warning' })
const ids = row.specIds.split(',').map(Number)
await deleteInventoryItems(ids)
ElMessage.success('删除成功')
fetchList()
} catch (e: any) {
if (e === 'cancel') return
ElMessage.error('删除失败')
}
}
async function handleBatchDelete() {
const totalCount = selectedRows.value.reduce((s: number, r: any) => s + r.count, 0)
try {
await ElMessageBox.confirm(`确定删除选中的 ${selectedRows.value.length} 组(共 ${totalCount} 条)规格数据?`, '提示', { type: 'warning' })
const ids = selectedRows.value.flatMap((r: any) => r.specIds.split(',').map(Number))
await deleteInventoryItems(ids)
ElMessage.success('删除成功')
fetchList()
} catch (e: any) {
if (e === 'cancel') return
ElMessage.error('删除失败')
}
}
async function handleExport() {
try {
const res: any = await exportInventory(keyword.value)
const blob = new Blob([res], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'inventory_export.csv'
a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('导出失败') }
}
function handleImportSuccess(response: any) {
if (response.code === 0) {
const { imported, skipped, warnings } = response.data
let msg = `导入成功,新增 ${imported}`
if (skipped) msg += `,跳过 ${skipped}`
ElMessage.success(msg)
if (warnings?.length) setTimeout(() => ElMessage.warning(warnings.join('')), 500)
fetchList()
} else {
ElMessage.error(response.message || '导入失败')
}
}
function handleImportError() { ElMessage.error('导入失败') }
onMounted(async () => {
try {
const [gRes, pRes]: any[] = await Promise.all([getLatestGoldPrice(), getLatestPlatinumPrice()])
globalGoldPrice.value = gRes.data?.price || 0
globalPlatinumPrice.value = pRes.data?.price || 0
specForm.goldPrice = globalGoldPrice.value
} catch { /* ignore */ }
fetchList()
})
</script>
<style scoped>
.page-card { border-radius: 12px; border: none; }
.page-card :deep(.el-card__header) { border-bottom: 1px solid #f0f0f0; padding: 16px 20px; }
.page-card__header { display: flex; justify-content: space-between; align-items: center; }
.page-card__title { font-size: 16px; font-weight: 600; color: #333; }
.filter-bar { margin-bottom: 16px; }
.dialog-section {
font-size: 13px; font-weight: 600; color: #666;
margin: 12px 0 8px; padding-left: 8px;
border-left: 2px solid #7c5cfc;
}
</style>

View File

@ -225,8 +225,8 @@
<el-table-column prop="barcode" label="条形码" width="130" fixed show-overflow-tooltip />
<el-table-column label="基本参数" align="center">
<el-table-column prop="fineness" label="成色" width="70" align="center" />
<el-table-column prop="mainStone" label="主石" width="70" align="center" />
<el-table-column prop="subStone" label="副石" width="70" align="center" />
<el-table-column prop="mainStoneWeight" label="主石" width="70" align="center" />
<el-table-column prop="sideStoneWeight" label="副石" width="70" align="center" />
<el-table-column prop="ringSize" label="手寸" width="70" align="center" />
</el-table-column>
<el-table-column label="金料信息" align="center">
@ -293,10 +293,6 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="主石"><el-input v-model="specForm.mainStone" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="副石"><el-input v-model="specForm.subStone" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="手寸"><el-input v-model="specForm.ringSize" /></el-form-item></el-col>
</el-row>
@ -328,8 +324,8 @@
<div class="dialog-section">工费信息</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="配件金额"><el-input-number v-model="specForm.accessoryAmount" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="加工工费"><el-input-number v-model="specForm.processingFee" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="镶石工费"><el-input-number v-model="specForm.settingFee" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="加工工费"><el-input-number v-model="specForm.processingFee" :precision="2" disabled controls-position="right" style="width:100%" /><div style="font-size:11px;color:#999;margin-top:2px">读取商品基本信息的工费</div></el-form-item></el-col>
<el-col :span="6"><el-form-item label="镶石工费"><el-input-number v-model="specForm.settingFee" :precision="2" disabled controls-position="right" style="width:100%" /><div style="font-size:11px;color:#999;margin-top:2px">根据主石副石自动计算</div></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总工费"><el-input-number v-model="specForm.totalLaborCost" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
@ -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
}

View File

@ -27,8 +27,8 @@
:cell-style="{ padding:'6px 0', fontSize:'12px' }">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="fineness" label="成色" width="80" align="center" />
<el-table-column prop="mainStone" label="主石" width="80" align="center" />
<el-table-column prop="subStone" label="副石" width="80" align="center" />
<el-table-column prop="mainStoneWeight" label="主石" width="80" align="center" />
<el-table-column prop="sideStoneWeight" label="副石" width="80" align="center" />
<el-table-column prop="ringSize" label="手寸" width="80" align="center" />
<el-table-column prop="modelNames" label="规格名称" min-width="200">
<template #default="{ row: g }">
@ -112,12 +112,12 @@ async function handleExpandChange(row: any, expandedRows: any[]) {
const groupMap = new Map<string, { fineness: string; mainStone: string; subStone: string; ringSize: string; modelNames: Set<string>; 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,

View File

@ -77,18 +77,6 @@
<text class="spec-card__label">损耗</text>
<text class="spec-card__value">{{ spec.loss }}%</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">金耗</text>
<text class="spec-card__value">{{ spec.goldLoss }}g</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">金价</text>
<text class="spec-card__value">¥{{ spec.goldPrice }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">金值</text>
<text class="spec-card__value">¥{{ spec.goldValue }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">主石数量</text>
<text class="spec-card__value">{{ spec.mainStoneCount }}</text>
@ -101,42 +89,14 @@
<text class="spec-card__label">主石单价</text>
<text class="spec-card__value">¥{{ spec.mainStoneUnitPrice }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">主石金额</text>
<text class="spec-card__value">¥{{ spec.mainStoneAmount }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">副石数量</text>
<text class="spec-card__value">{{ spec.sideStoneCount }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">副石石重</text>
<text class="spec-card__value">{{ spec.sideStoneWeight }}ct</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">副石单价</text>
<text class="spec-card__value">¥{{ spec.sideStoneUnitPrice }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">副石金额</text>
<text class="spec-card__value">¥{{ spec.sideStoneAmount }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">配件金额</text>
<text class="spec-card__value">¥{{ spec.accessoryAmount }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">加工工费</text>
<text class="spec-card__value">¥{{ spec.processingFee }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">镶石工费</text>
<text class="spec-card__value">¥{{ spec.settingFee }}</text>
</view>
<view class="spec-card__cell">
<text class="spec-card__label">总工费</text>
<text class="spec-card__value">¥{{ spec.totalLaborCost }}</text>
</view>
</view>
<!-- 总价行 -->
<view class="spec-card__footer">

View File

@ -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 }

View File

@ -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<void> {
try {
@ -126,7 +140,7 @@ export async function adminCreateProduct(req: Request, res: Response): Promise<v
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, Number(laborCost)||0, calcSettingFee(Number(d.mainStoneCount)||0, Number(d.mainStoneWeight)||0, Number(d.sideStoneCount)||0), d.totalLaborCost||0, d.totalPrice||0]
)
}
}

View File

@ -0,0 +1,371 @@
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: '获取商品列表失败' })
}
}

View File

@ -48,7 +48,21 @@ function calcSpecFields(row: Record<string, number>) {
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<string, number>) {
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<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 [pRows] = await pool.execute<RowDataPacket[]>('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<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,
@ -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<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 }
}
// 读取商品工费作为加工工费
const [pRows] = await pool.execute<RowDataPacket[]>('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<void>
}
const [productRows] = await pool.execute<RowDataPacket[]>(
`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<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 notFoundStyleNos = styleNos.filter(s => !styleNoToProductId[s])
@ -424,6 +470,9 @@ export async function importSpecData(req: Request, res: Response): Promise<void>
numericVals[h] = isNaN(num) ? 0 : num
}
// 加工工费统一使用商品的工费
numericVals['processing_fee'] = styleNoToLaborCost[styleNo] || 0
// 自动计算派生字段
const calc = calcSpecFields(numericVals)

View File

@ -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)