feat(web-admin): enter archive inbound/outbound qty by brand

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-28 09:35:05 +08:00
1 parent cc2edfc61f
commit 38b1613fee
3 files changed
+179 -1

No files matched your search

@@ -23,6 +23,43 @@
show-actual-qty
editable-actual-qty-only
/>
<div
v-for="row in brandedProductRows"
:key="row.id || row.productId"
class="pl-archive-brand-qty-block"
>
<div class="pl-archive-brand-qty-head">
<span class="pl-archive-brand-qty-title">{{ row.productName }} · 按品牌填量</span>
<el-tag v-if="row.brandMode === 'required'" size="small" type="warning">指定品牌</el-tag>
<el-tag v-else size="small" type="info">不限品牌</el-tag>
<span
v-if="!isBrandQtySumMatch(row)"
class="pl-archive-brand-qty-warn"
>
各品牌合计须等于实际数量({{ Number(row.actualQty ?? 0) }})
</span>
</div>
<el-table :data="row.brandQtyRows || []" stripe size="small" class="list-table">
<el-table-column label="品牌" min-width="140" show-overflow-tooltip>
<template #default="{ row: brandRow }">
{{ brandRow.brandName || '—' }}
</template>
</el-table-column>
<el-table-column label="数量" min-width="140" align="right">
<template #default="{ row: brandRow }">
<el-input-number
v-model="brandRow.qty"
:min="0"
:precision="3"
:controls="false"
:disabled="row.brandMode === 'required'"
style="width: 100%"
@change="() => onBrandQtyChange(row)"
/>
</template>
</el-table-column>
</el-table>
</div>
</section>
<section class="pl-plan-form-section pl-plan-freight-section">
@@ -114,6 +151,7 @@
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { fetchProductDetail, type ProductDetail } from '@/api/crm/product'
import {
inboundOrderApi,
outboundOrderApi,
@@ -193,12 +231,50 @@ const dialogTitle = computed(() => {
const submitLabel = computed(() => (isAdjustMode.value ? '确认保存' : '确认归档'))
const brandedProductRows = computed(() =>
productRows.value.filter(row => row.brandMode === 'required' || row.brandMode === 'any')
)
function brandQtySum(row: PlanFormProductRow): number {
return (row.brandQtyRows || []).reduce((sum, item) => sum + Number(item.qty || 0), 0)
}
function isBrandQtySumMatch(row: PlanFormProductRow): boolean {
if (!row.brandMode) return true
return Math.abs(brandQtySum(row) - Number(row.actualQty ?? 0)) < 0.0005
}
function onBrandQtyChange(row: PlanFormProductRow) {
if (row.brandMode === 'any') {
row.actualQty = Number(brandQtySum(row).toFixed(3))
}
}
function syncRequiredBrandQty(row: PlanFormProductRow) {
if (row.brandMode !== 'required' || !row.brandQtyRows?.length) return
row.brandQtyRows[0].qty = Number(row.actualQty ?? 0)
}
watch(
productRows,
rows => {
rows.forEach(row => {
if (row.brandMode === 'required') syncRequiredBrandQty(row)
})
},
{ deep: true }
)
function mapPlanProducts(products: PlanProduct[]): PlanFormProductRow[] {
return products.map((item, index) => ({
id: item.id,
productId: String(item.productId || index),
productNo: item.productNo || String(item.productId || index),
productName: item.productName || '',
brandMode: item.brandMode,
brandId: item.brandId,
brandName: item.brandName,
brandQtyRows: [],
model: item.model,
categoryName: item.categoryName,
unit: item.unit || '吨',
@@ -215,6 +291,55 @@ function mapPlanProducts(products: PlanProduct[]): PlanFormProductRow[] {
}))
}
async function initBrandQtyRows(rows: PlanFormProductRow[]) {
const needDetailIds = [
...new Set(
rows
.filter(row => row.brandMode === 'any' && row.productId)
.map(row => row.productId)
)
]
const detailMap = new Map<string, ProductDetail>()
await Promise.all(
needDetailIds.map(async productId => {
try {
const res = await fetchProductDetail({ id: productId })
const detail = pickBxData<ProductDetail>(res)
if (detail?.id) detailMap.set(productId, detail)
} catch {
// 品牌列表加载失败时保留空行,提交时由后端校验
}
})
)
rows.forEach(row => {
const actual = Number(row.actualQty ?? 0)
if (row.brandMode === 'required') {
row.brandQtyRows = [
{
brandId: row.brandId || '',
brandName: row.brandName || '指定品牌',
qty: actual
}
]
return
}
if (row.brandMode === 'any') {
const brands = (detailMap.get(row.productId)?.brands || []).filter(
b => (b.status || 'on') === 'on'
)
row.brandQtyRows = brands.map(b => ({
brandId: b.id || '',
brandName: b.brandName,
qty: 0
}))
if (row.brandQtyRows.length === 1) {
row.brandQtyRows[0].qty = actual
}
}
})
}
function resetState() {
plan.value = {}
productRows.value = []
@@ -295,6 +420,7 @@ async function handleOpened() {
// 无入库/出库单时保持计划原实际数量
}
}
await initBrandQtyRows(productRows.value)
await applyFreightPrefill(data)
} catch (error) {
innerVisible.value = false
@@ -322,6 +448,11 @@ async function handleSubmit() {
ElMessage.warning('请填写有效的实际数量')
return
}
const brandMismatch = brandedProductRows.value.find(row => !isBrandQtySumMatch(row))
if (brandMismatch) {
ElMessage.warning(`「${brandMismatch.productName}」各品牌数量合计须等于实际数量`)
return
}
if (
formData.hasFreight === 1 &&
formData.freightType === 'by_weight' &&
@@ -350,7 +481,16 @@ async function handleSubmit() {
const body: PlanArchiveBody = {
products: productRows.value.map(row => ({
id: row.id!,
actualQty: Number(row.actualQty ?? 0)
actualQty: Number(row.actualQty ?? 0),
brandQtys: row.brandMode
? (row.brandQtyRows || [])
.filter(r => Number(r.qty) > 0)
.map(r => ({
brandId: r.brandId,
brandName: r.brandName,
qty: Number(r.qty)
}))
: undefined
})),
freight: {
hasFreight: formData.hasFreight,
@@ -432,6 +572,33 @@ async function handleSubmit() {
color: #475569;
}
.pl-archive-brand-qty-block {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--el-border-color-lighter, #e2e8f0);
border-radius: 8px;
background: var(--mint-bg-soft, #f8fafc);
}
.pl-archive-brand-qty-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.pl-archive-brand-qty-title {
font-size: 13px;
font-weight: 600;
color: #334155;
}
.pl-archive-brand-qty-warn {
font-size: 12px;
color: var(--el-color-danger, #ef4444);
}
.pl-plan-freight-section {
.pl-plan-subtitle {
margin-top: 4px;