管理后台修改
This commit is contained in:
parent
d3a7e96094
commit
4ed0d66757
|
|
@ -28,6 +28,10 @@ export function getStockAlerts() {
|
|||
return http.get('/admin/stock-alerts')
|
||||
}
|
||||
|
||||
export function getSpecDataCount() {
|
||||
return http.get('/admin/spec-data-count')
|
||||
}
|
||||
|
||||
export function exportSpecData(productId: number) {
|
||||
return http.get(`/admin/products/${productId}/spec-data/export`, { responseType: 'blob' } as any)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,74 +32,38 @@
|
|||
<el-col :span="6">
|
||||
<div class="stat-card stat-card--orange">
|
||||
<div class="stat-card__info">
|
||||
<div class="stat-card__value">{{ stats.alertCount }}</div>
|
||||
<div class="stat-card__label">库存预警</div>
|
||||
<div class="stat-card__value">{{ stats.specCount }}</div>
|
||||
<div class="stat-card__label">规格总数</div>
|
||||
</div>
|
||||
<el-icon class="stat-card__icon"><WarningFilled /></el-icon>
|
||||
<el-icon class="stat-card__icon"><Collection /></el-icon>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 库存预警 -->
|
||||
<el-card class="dashboard-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-header__title">
|
||||
<el-icon style="color: #e6a23c; margin-right: 8px"><WarningFilled /></el-icon>
|
||||
库存预警
|
||||
</span>
|
||||
<el-tag type="danger" size="small" v-if="alerts.length">{{ alerts.length }} 项</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="alerts" v-loading="loading" empty-text="暂无库存预警" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="商品名称" min-width="150" />
|
||||
<el-table-column prop="style_no" label="款号" width="120" />
|
||||
<el-table-column prop="stock" label="库存" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.stock <= 5 ? 'danger' : 'warning'" size="small">{{ row.stock }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="$router.push(`/products/${row.id}/edit`)">
|
||||
补货
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getStockAlerts, getProducts, getCategories } from '../api/product'
|
||||
import { getProducts, getCategories, getSpecDataCount } from '../api/product'
|
||||
import { getOrders } from '../api/order'
|
||||
|
||||
const alerts = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const stats = reactive({ productCount: 0, orderCount: 0, categoryCount: 0, alertCount: 0 })
|
||||
const stats = reactive({ productCount: 0, orderCount: 0, categoryCount: 0, specCount: 0 })
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [alertRes, productRes, orderRes, catRes]: any[] = await Promise.all([
|
||||
getStockAlerts(),
|
||||
const [productRes, orderRes, catRes, specRes]: any[] = await Promise.all([
|
||||
getProducts({ page: 1, pageSize: 1 }),
|
||||
getOrders({ page: 1, pageSize: 1 }),
|
||||
getCategories(),
|
||||
getSpecDataCount(),
|
||||
])
|
||||
alerts.value = alertRes.data || []
|
||||
stats.alertCount = alerts.value.length
|
||||
stats.productCount = productRes.data?.total || 0
|
||||
stats.orderCount = orderRes.data?.total || 0
|
||||
stats.categoryCount = (catRes.data || []).length
|
||||
stats.specCount = specRes.data || 0
|
||||
} catch {
|
||||
ElMessage.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,8 +71,6 @@ onMounted(fetchData)
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { }
|
||||
|
||||
.stat-row { margin-bottom: 20px; }
|
||||
|
||||
.stat-card {
|
||||
|
|
@ -128,25 +90,4 @@ onMounted(fetchData)
|
|||
.stat-card__value { font-size: 32px; font-weight: 700; line-height: 1.2; }
|
||||
.stat-card__label { font-size: 14px; opacity: 0.85; margin-top: 4px; }
|
||||
.stat-card__icon { font-size: 48px; opacity: 0.3; }
|
||||
|
||||
.dashboard-card {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
.dashboard-card :deep(.el-card__header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.card-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
<el-option label="待收货" value="shipped" />
|
||||
<el-option label="已收货" value="received" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
<el-option label="部分退货" value="partial_returned" />
|
||||
<el-option label="已退货" value="returned" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
|
@ -45,6 +46,22 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 发货信息 -->
|
||||
<div v-if="row.shipping_company || row.shipping_no" style="margin-bottom:16px;padding:12px 16px;background:#f0f9ff;border-radius:8px">
|
||||
<div style="font-size:14px;font-weight:600;color:#409eff;margin-bottom:8px">发货信息</div>
|
||||
<div style="display:flex;align-items:flex-start;gap:24px;flex-wrap:wrap;font-size:13px;color:#666">
|
||||
<div v-if="row.shipping_company"><span style="color:#999">物流公司:</span>{{ row.shipping_company }}</div>
|
||||
<div v-if="row.shipping_no"><span style="color:#999">物流单号:</span>{{ row.shipping_no }}</div>
|
||||
<div v-if="row.shipped_at"><span style="color:#999">发货时间:</span>{{ formatTime(row.shipped_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 收货信息 -->
|
||||
<div v-if="row.received_at" style="margin-bottom:16px;padding:12px 16px;background:#f0f9eb;border-radius:8px">
|
||||
<div style="font-size:14px;font-weight:600;color:#67c23a;margin-bottom:8px">收货信息</div>
|
||||
<div style="font-size:13px;color:#666">
|
||||
<span style="color:#999">收货时间:</span>{{ formatTime(row.received_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 取消/退款信息 -->
|
||||
<div v-if="row.cancel_reason || row.refund_proof || row.refund_time" style="margin-bottom:16px;padding:12px 16px;background:#fff2f0;border-radius:8px">
|
||||
<div style="font-size:14px;font-weight:600;color:#e4393c;margin-bottom:8px">取消/退款信息</div>
|
||||
|
|
@ -68,6 +85,10 @@
|
|||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:14px;font-weight:600;color:#333">{{ item.product_name }}</div>
|
||||
<div style="font-size:12px;color:#999;margin-top:4px">
|
||||
<span v-if="item.style_no" style="margin-right:12px">款号:{{ item.style_no }}</span>
|
||||
<span v-if="item.barcode" style="margin-right:12px">条形码:{{ item.barcode }}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#999;margin-top:2px">
|
||||
<span v-if="item.model_name">型号:{{ item.model_name }}</span>
|
||||
<span v-if="item.fineness" style="margin-left:12px">成色:{{ item.fineness }}</span>
|
||||
<span v-if="item.main_stone" style="margin-left:12px">主石:{{ item.main_stone }}</span>
|
||||
|
|
@ -77,7 +98,6 @@
|
|||
</div>
|
||||
<div style="text-align:right;flex-shrink:0">
|
||||
<div style="color:#e4393c;font-weight:600">¥{{ Number(item.unit_price).toFixed(2) }}</div>
|
||||
<div style="font-size:12px;color:#999">×{{ item.quantity }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,13 +164,13 @@
|
|||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 'pending' || row.status === 'paid'" text type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 'pending'" text type="danger" size="small" @click="handlePayment(row)">确认支付</el-button>
|
||||
<el-button v-if="row.status === 'paid'" text type="warning" size="small" @click="handleShip(row)">确认发货</el-button>
|
||||
<el-button v-if="row.status === 'paid'" text type="danger" size="small" @click="handleCancel(row)">取消订单</el-button>
|
||||
<el-button v-if="row.status === 'shipped'" text type="success" size="small" @click="handleReceive(row)">确认收货</el-button>
|
||||
<el-button v-if="row.status === 'received'" text type="warning" size="small" @click="handleReturn(row)">退货</el-button>
|
||||
<el-button v-if="row.status === 'received' || row.status === 'returned'" text type="info" size="small" @click="handleViewReturns(row)">退货记录</el-button>
|
||||
<el-button v-if="row.status === 'received' || row.status === 'partial_returned'" text type="warning" size="small" @click="handleReturn(row)">退货</el-button>
|
||||
<el-button v-if="row.status === 'received' || row.status === 'returned' || row.status === 'partial_returned'" text type="info" size="small" @click="handleViewReturns(row)">退货记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
|
@ -212,6 +232,7 @@
|
|||
<el-button type="primary" plain @click="handleAddBarcode" :disabled="!manualBarcode.trim()">添加</el-button>
|
||||
</div>
|
||||
<div class="create-toolbar__right">
|
||||
<el-button type="info" plain @click="downloadCsvTemplate"><el-icon><Download /></el-icon> 下载模板</el-button>
|
||||
<el-upload :show-file-list="false" accept=".csv,.txt" :before-upload="handleCsvImport">
|
||||
<el-button type="success" plain><el-icon><Upload /></el-icon> 导入 CSV</el-button>
|
||||
</el-upload>
|
||||
|
|
@ -242,11 +263,6 @@
|
|||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.quantity" :min="1" size="small" controls-position="right" style="width:80px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalPrice" label="单价" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color:#e4393c;font-weight:600">¥{{ Number(row.totalPrice).toFixed(2) }}</span>
|
||||
|
|
@ -265,7 +281,7 @@
|
|||
<div v-if="previewItems.length" style="text-align:right;margin-top:12px;font-size:14px">
|
||||
共 <b>{{ previewItems.length }}</b> 件商品,合计
|
||||
<span style="color:#e4393c;font-weight:700;font-size:16px">
|
||||
¥{{ previewItems.reduce((s, i) => s + Number(i.totalPrice) * i.quantity, 0).toFixed(2) }}
|
||||
¥{{ previewItems.reduce((s, i) => s + Number(i.totalPrice), 0).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -296,7 +312,6 @@
|
|||
<el-select v-model="item.specDataId" filterable placeholder="选择规格" style="width:260px" :disabled="!item.productId">
|
||||
<el-option v-for="s in (item._specList || [])" :key="s.id" :label="formatSpecLabel(s)" :value="s.id" />
|
||||
</el-select>
|
||||
<el-input-number v-model="item.quantity" :min="1" style="width:100px" />
|
||||
<el-button text type="danger" @click="editForm.items.splice(idx, 1)" :disabled="editForm.items.length <= 1">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -422,25 +437,12 @@
|
|||
<span style="color:#e4393c">¥{{ Number(row.unitPrice).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="购买数量" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.quantity }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已退" width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.returnedQty > 0 ? '#e4393c' : '#ccc' }">{{ row.returnedQty }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退货数量" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.returnQty" :min="0" :max="row.maxReturnQty" :disabled="!row.checked || row.maxReturnQty <= 0" size="small" controls-position="right" style="width:90px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="returnCheckedItems.length" style="text-align:right;margin-top:8px;font-size:13px;color:#666">
|
||||
退货 <b>{{ returnCheckedItems.reduce((s, i) => s + i.returnQty, 0) }}</b> 件,退款金额
|
||||
退货 <b>{{ returnCheckedItems.length }}</b> 件,退款金额
|
||||
<span style="color:#e4393c;font-weight:700;font-size:15px">
|
||||
¥{{ returnCheckedItems.reduce((s, i) => s + i.returnQty * Number(i.unitPrice), 0).toFixed(2) }}
|
||||
¥{{ returnCheckedItems.reduce((s, i) => s + Number(i.unitPrice), 0).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -510,7 +512,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Upload, Delete, Plus, Search } from '@element-plus/icons-vue'
|
||||
import { Upload, Delete, Plus, Search, Download } from '@element-plus/icons-vue'
|
||||
import { getOrders, getOrderDetail, createOrder, updateOrder, updateOrderStatus, returnOrder, getOrderReturns } from '../../api/order'
|
||||
import { getProducts, getSpecDataList, lookupBarcodes } from '../../api/product'
|
||||
import { getUploadUrl } from '../../api/request'
|
||||
|
|
@ -617,12 +619,12 @@ function sourceTagType(s: string) {
|
|||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
const map: Record<string, string> = { pending: '未付款', paid: '待发货', shipped: '待收货', received: '已收货', cancelled: '已取消', returned: '已退货' }
|
||||
const map: Record<string, string> = { pending: '未付款', paid: '待发货', shipped: '待收货', received: '已收货', cancelled: '已取消', returned: '已退货', partial_returned: '部分退货' }
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
function statusTagType(s: string) {
|
||||
const map: Record<string, string> = { pending: 'danger', paid: 'warning', shipped: '', received: 'success', cancelled: 'info', returned: 'danger' }
|
||||
const map: Record<string, string> = { pending: 'danger', paid: 'warning', shipped: '', received: 'success', cancelled: 'info', returned: 'danger', partial_returned: 'warning' }
|
||||
return map[s] || ''
|
||||
}
|
||||
|
||||
|
|
@ -698,6 +700,18 @@ async function handleAddBarcode() {
|
|||
manualBarcode.value = ''
|
||||
}
|
||||
|
||||
function downloadCsvTemplate() {
|
||||
const header = '\uFEFF条形码\n'
|
||||
const example = 'ABC123456\nDEF789012\n'
|
||||
const blob = new Blob([header + example], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '订单导入模板.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function handleCsvImport(file: File) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (e) => {
|
||||
|
|
@ -735,7 +749,7 @@ async function handleCreate() {
|
|||
try {
|
||||
const payload = {
|
||||
...createForm.value,
|
||||
items: previewItems.value.map((i: any) => ({ barcode: i.barcode, quantity: i.quantity })),
|
||||
items: previewItems.value.map((i: any) => ({ barcode: i.barcode })),
|
||||
}
|
||||
await createOrder(payload)
|
||||
ElMessage.success('订单创建成功')
|
||||
|
|
@ -937,7 +951,7 @@ const submittingReturn = ref(false)
|
|||
const returnOrder_ = ref<any>(null)
|
||||
const returnItems = ref<any[]>([])
|
||||
const returnForm = ref({ returnReason: '', refundProof: '', refundTime: '' })
|
||||
const returnCheckedItems = computed(() => returnItems.value.filter(i => i.checked && i.returnQty > 0))
|
||||
const returnCheckedItems = computed(() => returnItems.value.filter(i => i.checked))
|
||||
|
||||
// Return records dialog
|
||||
const showReturnRecordsDialog = ref(false)
|
||||
|
|
@ -1010,7 +1024,7 @@ async function confirmReturn() {
|
|||
returnReason: returnForm.value.returnReason.trim(),
|
||||
refundProof: returnForm.value.refundProof || undefined,
|
||||
refundTime: returnForm.value.refundTime ? new Date(returnForm.value.refundTime).toISOString() : undefined,
|
||||
items: checkedItems.map(i => ({ orderItemId: i.orderItemId, quantity: i.returnQty })),
|
||||
items: checkedItems.map(i => ({ orderItemId: i.orderItemId, quantity: 1 })),
|
||||
})
|
||||
ElMessage.success(res.message || '退货成功')
|
||||
showReturnDialog.value = false
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@
|
|||
:cell-style="{ padding: '8px 0', fontSize: '13px' }">
|
||||
<el-table-column type="index" label="#" width="50" fixed align="center" />
|
||||
<el-table-column prop="modelName" label="规格名称" min-width="120" fixed show-overflow-tooltip />
|
||||
<el-table-column prop="barcode" label="条型码" width="130" fixed show-overflow-tooltip />
|
||||
<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" />
|
||||
|
|
@ -279,7 +279,7 @@
|
|||
<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-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">
|
||||
|
|
|
|||
27
server/migrations/005_spec_snapshot.sql
Normal file
27
server/migrations/005_spec_snapshot.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- 给 order_items 添加 spec_snapshot 字段,用于保存付款时的规格数据快照
|
||||
ALTER TABLE order_items ADD COLUMN spec_snapshot JSON DEFAULT NULL;
|
||||
|
||||
-- 去掉 order_items 的 spec_data_id 外键约束(避免删除 spec_data 时级联删除订单商品)
|
||||
-- 先查找并删除外键
|
||||
SET @fk_name = (
|
||||
SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order_items'
|
||||
AND COLUMN_NAME = 'spec_data_id' AND REFERENCED_TABLE_NAME = 'spec_data'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @sql = IF(@fk_name IS NOT NULL, CONCAT('ALTER TABLE order_items DROP FOREIGN KEY ', @fk_name), 'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 去掉 cart_items 的 spec_data_id 外键约束
|
||||
SET @fk_name2 = (
|
||||
SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cart_items'
|
||||
AND COLUMN_NAME = 'spec_data_id' AND REFERENCED_TABLE_NAME = 'spec_data'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @sql2 = IF(@fk_name2 IS NOT NULL, CONCAT('ALTER TABLE cart_items DROP FOREIGN KEY ', @fk_name2), 'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2;
|
||||
EXECUTE stmt2;
|
||||
DEALLOCATE PREPARE stmt2;
|
||||
1
server/migrations/006_shipped_at.sql
Normal file
1
server/migrations/006_shipped_at.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE orders ADD COLUMN shipped_at DATETIME DEFAULT NULL;
|
||||
|
|
@ -52,7 +52,7 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
|
|||
const kw = `%${keyword.trim()}%`
|
||||
params.push(kw, kw)
|
||||
}
|
||||
if (status && ['pending', 'paid', 'shipped', 'received', 'cancelled', 'returned'].includes(status)) {
|
||||
if (status && ['pending', 'paid', 'shipped', 'received', 'cancelled', 'returned', 'partial_returned'].includes(status)) {
|
||||
where += ' AND o.status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
|
|
@ -66,8 +66,8 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
|
|||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
`SELECT o.id, o.order_no, o.user_id, u.nickname as user_nickname, u.uid as user_uid, o.status,
|
||||
o.total_price, o.receiver_name, o.receiver_phone, o.receiver_address,
|
||||
o.payment_time, o.payment_proof, o.shipping_company, o.shipping_no,
|
||||
o.cancel_reason, o.refund_proof, o.refund_time,
|
||||
o.payment_time, o.payment_proof, o.shipping_company, o.shipping_no, o.shipped_at,
|
||||
o.cancel_reason, o.refund_proof, o.refund_time, o.received_at,
|
||||
o.source, o.created_at, o.updated_at
|
||||
FROM orders o
|
||||
LEFT JOIN users u ON o.user_id = u.id
|
||||
|
|
@ -82,8 +82,8 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
|
|||
const placeholders = orderIds.map(() => '?').join(',')
|
||||
const [itemRows] = await pool.execute<RowDataPacket[]>(
|
||||
`SELECT oi.order_id, oi.id, oi.product_id, oi.spec_data_id, oi.quantity, oi.unit_price,
|
||||
p.name as product_name, p.thumb, p.banner_images,
|
||||
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size
|
||||
p.name as product_name, p.thumb, p.banner_images, p.style_no,
|
||||
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size, sd.barcode
|
||||
FROM order_items oi
|
||||
LEFT JOIN products p ON oi.product_id = p.id
|
||||
LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id
|
||||
|
|
@ -127,8 +127,8 @@ export async function adminGetOrderDetail(req: Request, res: Response): Promise<
|
|||
|
||||
const [itemRows] = await pool.execute<RowDataPacket[]>(
|
||||
`SELECT oi.id, oi.product_id, oi.spec_data_id, oi.quantity, oi.unit_price,
|
||||
p.name as product_name, p.banner_images,
|
||||
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size, sd.total_price as spec_total_price
|
||||
p.name as product_name, p.banner_images, p.style_no,
|
||||
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size, sd.barcode, sd.total_price as spec_total_price
|
||||
FROM order_items oi
|
||||
LEFT JOIN products p ON oi.product_id = p.id
|
||||
LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id
|
||||
|
|
@ -336,8 +336,8 @@ export async function adminReturnOrder(req: Request, res: Response): Promise<voi
|
|||
res.status(404).json({ code: 404, message: '订单不存在' })
|
||||
return
|
||||
}
|
||||
if (orderRows[0].status !== 'received') {
|
||||
res.status(400).json({ code: 400, message: '只有已收货的订单才能退货' })
|
||||
if (orderRows[0].status !== 'received' && orderRows[0].status !== 'partial_returned') {
|
||||
res.status(400).json({ code: 400, message: '只有已收货或部分退货的订单才能退货' })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -395,6 +395,34 @@ export async function adminReturnOrder(req: Request, res: Response): Promise<voi
|
|||
'INSERT INTO order_return_items (return_id, order_item_id, quantity, unit_price) VALUES (?, ?, ?, ?)',
|
||||
[returnId, ri.orderItemId, ri.quantity, ri.unitPrice]
|
||||
)
|
||||
|
||||
// 退货时从快照恢复 spec_data
|
||||
const [snapRows] = await conn.execute<RowDataPacket[]>(
|
||||
'SELECT spec_data_id, spec_snapshot FROM order_items WHERE id = ?',
|
||||
[ri.orderItemId]
|
||||
)
|
||||
if (snapRows.length > 0 && snapRows[0].spec_snapshot) {
|
||||
const snap = typeof snapRows[0].spec_snapshot === 'string' ? JSON.parse(snapRows[0].spec_snapshot) : snapRows[0].spec_snapshot
|
||||
const specId = snap.id || snapRows[0].spec_data_id
|
||||
// 检查是否已存在
|
||||
const [existing] = await conn.execute<RowDataPacket[]>('SELECT id FROM spec_data WHERE id = ?', [specId])
|
||||
if (existing.length === 0) {
|
||||
await conn.execute(
|
||||
`INSERT INTO spec_data (id, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[specId, snap.product_id, snap.model_name, snap.barcode || null, snap.fineness, snap.main_stone, snap.sub_stone || '',
|
||||
snap.ring_size, snap.gold_total_weight, snap.gold_net_weight, snap.loss, snap.gold_loss,
|
||||
snap.gold_price, snap.gold_value, snap.main_stone_count, snap.main_stone_weight,
|
||||
snap.main_stone_unit_price, snap.main_stone_amount, snap.side_stone_count, snap.side_stone_weight,
|
||||
snap.side_stone_unit_price, snap.side_stone_amount, snap.accessory_amount, snap.processing_fee,
|
||||
snap.setting_fee, snap.total_labor_cost, snap.total_price]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否全部退完 → 更新订单状态
|
||||
|
|
@ -409,6 +437,8 @@ export async function adminReturnOrder(req: Request, res: Response): Promise<voi
|
|||
const allReturned = checkRows.every((r: any) => Number(r.returned_qty) >= r.quantity)
|
||||
if (allReturned) {
|
||||
await conn.execute("UPDATE orders SET status = 'returned' WHERE id = ?", [id])
|
||||
} else {
|
||||
await conn.execute("UPDATE orders SET status = 'partial_returned' WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
|
|
@ -465,11 +495,12 @@ export async function adminGetOrderReturns(req: Request, res: Response): Promise
|
|||
|
||||
// PUT /api/admin/orders/:id/status - 更新订单状态
|
||||
export async function adminUpdateOrderStatus(req: Request, res: Response): Promise<void> {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { status, paymentTime, paymentProof, shippingCompany, shippingNo, receivedAt, cancelReason, refundProof, refundTime } = req.body
|
||||
|
||||
const [orderRows] = await pool.execute<RowDataPacket[]>(
|
||||
const [orderRows] = await conn.execute<RowDataPacket[]>(
|
||||
'SELECT id, status FROM orders WHERE id = ?',
|
||||
[id]
|
||||
)
|
||||
|
|
@ -509,8 +540,8 @@ export async function adminUpdateOrderStatus(req: Request, res: Response): Promi
|
|||
res.status(400).json({ code: 400, message: '请填写物流公司和物流单号' })
|
||||
return
|
||||
}
|
||||
updates.push('shipping_company = ?', 'shipping_no = ?')
|
||||
params.push(shippingCompany, shippingNo)
|
||||
updates.push('shipping_company = ?', 'shipping_no = ?', 'shipped_at = ?')
|
||||
params.push(shippingCompany, shippingNo, toMySQLDatetime(new Date()))
|
||||
}
|
||||
|
||||
if (status === 'received') {
|
||||
|
|
@ -537,12 +568,71 @@ export async function adminUpdateOrderStatus(req: Request, res: Response): Promi
|
|||
}
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
await pool.execute(`UPDATE orders SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
await conn.beginTransaction()
|
||||
|
||||
params.push(id)
|
||||
await conn.execute(`UPDATE orders SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
|
||||
// 付款时:保存规格快照 → 删除 spec_data → 清理购物车
|
||||
if (status === 'paid') {
|
||||
const [items] = await conn.execute<RowDataPacket[]>(
|
||||
'SELECT oi.id as oi_id, oi.spec_data_id, sd.* FROM order_items oi LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id WHERE oi.order_id = ?',
|
||||
[id]
|
||||
)
|
||||
for (const item of items) {
|
||||
if (item.spec_data_id) {
|
||||
// 保存快照(排除 oi_id 和 spec_data_id 这两个非 spec_data 字段)
|
||||
const { oi_id, spec_data_id, ...specFields } = item
|
||||
await conn.execute(
|
||||
'UPDATE order_items SET spec_snapshot = ? WHERE id = ?',
|
||||
[JSON.stringify(specFields), oi_id]
|
||||
)
|
||||
// 清理购物车中引用该 spec_data 的记录
|
||||
await conn.execute('DELETE FROM cart_items WHERE spec_data_id = ?', [spec_data_id])
|
||||
// 删除 spec_data
|
||||
await conn.execute('DELETE FROM spec_data WHERE id = ?', [spec_data_id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 取消已付款订单时:从快照恢复 spec_data
|
||||
if (status === 'cancelled' && currentStatus === 'paid') {
|
||||
const [items] = await conn.execute<RowDataPacket[]>(
|
||||
'SELECT id, spec_data_id, spec_snapshot FROM order_items WHERE order_id = ?',
|
||||
[id]
|
||||
)
|
||||
for (const item of items) {
|
||||
if (item.spec_snapshot) {
|
||||
const snap = typeof item.spec_snapshot === 'string' ? JSON.parse(item.spec_snapshot) : item.spec_snapshot
|
||||
// 检查是否已存在(避免重复恢复)
|
||||
const [existing] = await conn.execute<RowDataPacket[]>('SELECT id FROM spec_data WHERE id = ?', [snap.id])
|
||||
if (existing.length === 0) {
|
||||
await conn.execute(
|
||||
`INSERT INTO spec_data (id, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[snap.id, snap.product_id, snap.model_name, snap.barcode || null, snap.fineness, snap.main_stone, snap.sub_stone || '',
|
||||
snap.ring_size, snap.gold_total_weight, snap.gold_net_weight, snap.loss, snap.gold_loss,
|
||||
snap.gold_price, snap.gold_value, snap.main_stone_count, snap.main_stone_weight,
|
||||
snap.main_stone_unit_price, snap.main_stone_amount, snap.side_stone_count, snap.side_stone_weight,
|
||||
snap.side_stone_unit_price, snap.side_stone_amount, snap.accessory_amount, snap.processing_fee,
|
||||
snap.setting_fee, snap.total_labor_cost, snap.total_price]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
res.json({ code: 0, message: '状态更新成功' })
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
console.error('adminUpdateOrderStatus error:', err)
|
||||
res.status(500).json({ code: 500, message: '更新订单状态失败' })
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Router } from 'express'
|
||||
import multer from 'multer'
|
||||
import { RowDataPacket } from 'mysql2'
|
||||
import pool from '../utils/db'
|
||||
import { upload, uploadFile } from '../controllers/upload'
|
||||
import { adminLogin, verifyAdmin } from '../controllers/adminAuth'
|
||||
import {
|
||||
|
|
@ -71,6 +73,16 @@ adminRoutes.post('/spec-data/lookup', lookupByBarcodes)
|
|||
// Stock alerts
|
||||
adminRoutes.get('/stock-alerts', getStockAlerts)
|
||||
|
||||
// Spec data count
|
||||
adminRoutes.get('/spec-data-count', async (_req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute<RowDataPacket[]>('SELECT COUNT(*) as total FROM spec_data')
|
||||
res.json({ code: 0, data: (rows as any[])[0].total })
|
||||
} catch {
|
||||
res.status(500).json({ code: 500, message: '获取规格总数失败' })
|
||||
}
|
||||
})
|
||||
|
||||
// Order management
|
||||
adminRoutes.get('/orders', adminGetOrders)
|
||||
adminRoutes.get('/orders/:id', adminGetOrderDetail)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user