规格管理
This commit is contained in:
parent
2a736f0aae
commit
06bf3c7365
21
admin/src/api/inventory.ts
Normal file
21
admin/src/api/inventory.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -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': '版房管理',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
359
admin/src/views/inventory/InventoryList.vue
Normal file
359
admin/src/views/inventory/InventoryList.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
371
server/src/controllers/inventory.ts
Normal file
371
server/src/controllers/inventory.ts
Normal 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: '获取商品列表失败' })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user