feat(pl): show available contract qty before plan qty in stop products

Add contract-product-arrange-qty API and form UI with green available qty,
red plan qty over-hint, and click-through dialog for usage and plan list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-30 17:41:36 +08:00
1 parent 3011cb9dae
commit 81b1cb9b1d
10 files changed
+1227 -11

No files matched your search

@@ -0,0 +1,183 @@
<template>
<el-dialog
:model-value="modelValue"
class="dialog-form-layout pl-contract-product-arrange-qty-dialog"
:title="dialogTitle"
width="640px"
align-center
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="arrange-qty-body">
<p v-if="subtitle" class="arrange-qty-sub">{{ subtitle }}</p>
<div class="arrange-qty-stats">
<div class="arrange-qty-stat">
<div class="k">合同数量</div>
<div class="v">{{ contractQtyText }}</div>
</div>
<div class="arrange-qty-stat">
<div class="k">已安排</div>
<div class="v">{{ formatQty(detail?.arrangedQty) }}</div>
</div>
<div class="arrange-qty-stat">
<div class="k">可安排</div>
<div class="v is-avail">{{ availableQtyText }}</div>
</div>
</div>
<div class="arrange-qty-list-title">已安排本产品的运输计划</div>
<el-table
v-if="(detail?.plans || []).length"
:data="detail?.plans || []"
class="list-table"
stripe
size="small"
max-height="280"
>
<el-table-column prop="planNo" label="计划编号" min-width="140" show-overflow-tooltip />
<el-table-column prop="planDate" label="计划日期" min-width="110" />
<el-table-column label="计划数量" min-width="100" align="right">
<template #default="{ row }">{{ formatQty(row.planQty) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="100">
<template #default="{ row }">{{ statusLabel(row) }}</template>
</el-table-column>
</el-table>
<p v-else class="arrange-qty-empty">暂无已安排计划</p>
</div>
<template #footer>
<el-button type="primary" @click="emit('update:modelValue', false)">
<el-icon><Check /></el-icon>知道了
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check } from '@element-plus/icons-vue'
import { planApi, type ContractProductArrangeQtyDetail } from '@/api/pl'
import { pickBxData } from '@/utils/http/bx'
const props = defineProps<{
modelValue: boolean
contractId?: string
productId?: string
productName?: string
excludePlanId?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
const loading = ref(false)
const detail = ref<ContractProductArrangeQtyDetail | null>(null)
const dialogTitle = computed(
() => `合同用量 · ${props.productName || detail.value?.productName || '产品'}`
)
const subtitle = computed(() => {
const d = detail.value
if (!d) return ''
const parts = [d.customerName, d.contractNo].filter(Boolean)
return parts.join(' - ')
})
const contractQtyText = computed(() => {
if (!detail.value) return '—'
if (!detail.value.hasFixedQty) return '按客户需求'
return formatQty(detail.value.contractQty)
})
const availableQtyText = computed(() => {
if (!detail.value) return '—'
if (!detail.value.hasFixedQty) return '不限额'
return formatQty(detail.value.availableQty)
})
function formatQty(n: number | null | undefined) {
if (n == null || Number.isNaN(Number(n))) return '—'
return Number(n).toFixed(3)
}
function statusLabel(row: { archiveStatus?: string; followStatus?: string; transportStatus?: string }) {
const archive = (row.archiveStatus || '').trim()
if (archive === 'archived') return '已归档'
const follow = (row.followStatus || '').trim()
if (follow === 'completed') return '已完成'
if (follow === 'arranged' || row.transportStatus === 'arranged') return '已安排'
return follow || row.transportStatus || '—'
}
async function loadDetail() {
const contractId = String(props.contractId || '').trim()
const productId = String(props.productId || '').trim()
if (!contractId || !productId) {
detail.value = null
return
}
loading.value = true
try {
const res = await planApi.getContractProductArrangeQty({
contractId,
productId,
excludePlanId: props.excludePlanId || undefined
})
detail.value = pickBxData<ContractProductArrangeQtyDetail>(res) || null
} catch {
detail.value = null
} finally {
loading.value = false
}
}
watch(
() => [props.modelValue, props.contractId, props.productId, props.excludePlanId] as const,
([visible]) => {
if (visible) void loadDetail()
}
)
</script>
<style scoped lang="scss">
.arrange-qty-body {
min-height: 120px;
}
.arrange-qty-sub {
margin: 0 0 12px;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.arrange-qty-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 14px;
}
.arrange-qty-stat {
padding: 10px;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter, #e2e8f0);
background: #f8fafc;
.k {
font-size: 11px;
color: var(--mint-text-muted, #64748b);
}
.v {
margin-top: 2px;
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
&.is-avail {
color: #059669;
}
}
}
.arrange-qty-list-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.arrange-qty-empty {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
</style>
@@ -201,6 +201,8 @@
:compact="compactProducts"
:allow-remove="!readonly"
:shared-readonly="true"
:contract-id="deliveryContractId(delivery)"
:exclude-plan-id="excludePlanId"
@remove="(productIndex) => removeStopProduct(index, productIndex)"
/>
</article>
@@ -234,13 +236,15 @@
qtyUnitLabel?: string
/** 多送货段时提示拆分数量 */
showSplitHint?: boolean
excludePlanId?: string
}>(),
{
tabMode: true,
readonly: false,
compactProducts: true,
qtyUnitLabel: '',
showSplitHint: false
showSplitHint: false,
excludePlanId: ''
}
)
@@ -259,6 +263,10 @@
}
const activeIndex = ref(0)
function deliveryContractId(delivery: PlanDeliveryForm) {
return String(delivery.contracts?.[0]?.contractId || '').trim()
}
const customerOptionsByIndex = ref<Record<number, CustomerOptionRow[]>>({})
const customerOptionsLoading = ref<Record<number, boolean>>({})
@@ -191,6 +191,7 @@
ref="loadSectionRef"
v-model="loadForm"
compact-products
:exclude-plan-id="planId || ''"
@open-customer-picker="openCustomerSelector('load')"
@add-products="productSelectorVisible = true"
/>
@@ -230,6 +231,7 @@
compact-products
:qty-unit-label="planQtyUnitLabel"
:show-split-hint="showDeliverySplitHint"
:exclude-plan-id="planId || ''"
@active-index-change="deliveryActiveIndex = $event"
@open-customer-picker="openCustomerSelector('delivery', $event)"
@sync-products="syncDeliveryProductsFromLoadManual"
@@ -145,6 +145,8 @@
:products="modelValue.products"
:editable="!readonly"
:compact="compactProducts"
:contract-id="stopContractId"
:exclude-plan-id="excludePlanId"
@remove="removeStopProduct"
/>
</div>
@@ -168,10 +170,13 @@
modelValue: PlanLoadForm
readonly?: boolean
compactProducts?: boolean
/** 编辑计划时排除本计划占用 */
excludePlanId?: string
}>(),
{
readonly: false,
compactProducts: true
compactProducts: true,
excludePlanId: ''
}
)
@@ -193,6 +198,11 @@
const isInternal = computed(() => isPlInternalCustomerName(props.modelValue.customerName))
const stopContractId = computed(() => {
const id = String(props.modelValue.contracts?.[0]?.contractId || '').trim()
return id
})
/** 非内部客户须先选装货合同才能添加产品;内部客户不限制 */
const addProductsDisabled = computed(() => {
if (isInternal.value) return false
@@ -111,6 +111,21 @@
</div>
</div>
<div class="pl-stop-product-dual-item__row2">
<div class="pl-stop-product-dual-item__field">
<span class="pl-stop-product-dual-item__label">可安排数量</span>
<div class="pl-stop-avail-wrap">
<span class="pl-stop-avail-num">{{ formatAvailableDisplay(row) }}</span>
<el-button
class="pl-stop-avail-help"
:disabled="!canOpenArrangeQty(row)"
circle
size="small"
@click="openArrangeQtyDialog(row)"
>
?
</el-button>
</div>
</div>
<div class="pl-stop-product-dual-item__field">
<span class="pl-stop-product-dual-item__label">计划数量</span>
<el-input-number
@@ -120,13 +135,17 @@
:precision="3"
:controls="false"
class="pl-plan-product-cell-control pl-stop-product-dual-qty"
:class="{ 'is-over-available': isPlanQtyOverAvailable(row) }"
/>
<span
v-else
class="pl-stop-product-dual-item__readonly pl-stop-product-dual-item__qty-text"
class="pl-stop-product-dual-item__readonly pl-stop-product-dual-item__qty-text pl-stop-plan-qty"
>
{{ row.planQty ?? '—' }}
</span>
<p v-if="editable && isPlanQtyOverAvailable(row)" class="pl-stop-over-tip">
已超过可安排数量,安排时将被拦截
</p>
</div>
<div class="pl-stop-product-dual-item__field">
<span class="pl-stop-product-dual-item__label">包装规格</span>
@@ -246,6 +265,22 @@
</template>
</template>
</el-table-column>
<el-table-column label="可安排数量" min-width="120" align="right">
<template #default="{ row }">
<div class="pl-stop-avail-wrap pl-stop-avail-wrap--table">
<span class="pl-stop-avail-num">{{ formatAvailableDisplay(row) }}</span>
<el-button
class="pl-stop-avail-help"
:disabled="!canOpenArrangeQty(row)"
circle
size="small"
@click="openArrangeQtyDialog(row)"
>
?
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="计划数量" min-width="110" align="right">
<template #default="{ row }">
<el-input-number
@@ -255,8 +290,12 @@
:precision="3"
:controls="false"
style="width: 100%"
:class="{ 'is-over-available': isPlanQtyOverAvailable(row) }"
/>
<span v-else>{{ row.planQty ?? '—' }}</span>
<span v-else class="pl-stop-plan-qty">{{ row.planQty ?? '—' }}</span>
<p v-if="editable && isPlanQtyOverAvailable(row)" class="pl-stop-over-tip">
已超过可安排数量
</p>
</template>
</el-table-column>
<el-table-column
@@ -358,11 +397,20 @@
:mode="aliasBrandManageMode"
@saved="handleAliasBrandManaged"
/>
<PlContractProductArrangeQtyDialog
v-model="arrangeQtyDialogVisible"
:contract-id="arrangeQtyContractId"
:product-id="arrangeQtyProductId"
:product-name="arrangeQtyProductName"
:exclude-plan-id="excludePlanId"
/>
</div>
</template>
<script setup lang="ts">
import { Delete, Plus } from '@element-plus/icons-vue'
import { planApi, type ContractProductArrangeQtyDetail } from '@/api/pl'
import type { PlanStopProductFormRow } from '@/utils/pl-plan-stop-form'
import {
formatStopArchiveAmount,
@@ -378,9 +426,11 @@
} from '@/utils/crm-product-brand'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import PlContractProductArrangeQtyDialog from '@/components/business/pl-contract-product-arrange-qty-dialog/index.vue'
import DictTag from '@/components/DictTag/index.vue'
import DictSelect from '@/components/DictSelect/index.vue'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData } from '@/utils/http/bx'
const props = withDefaults(
defineProps<{
@@ -398,6 +448,10 @@
showStopArchivePrice?: boolean
/** 与 showStopActualQty / showStopArchivePrice 配合 */
stopArchiveMode?: 'load' | 'delivery'
/** 本段合同 id(无则展示 —) */
contractId?: string
/** 当前编辑计划 id,计算可安排时排除 */
excludePlanId?: string
}>(),
{
compact: false,
@@ -405,7 +459,9 @@
sharedReadonly: false,
showStopActualQty: false,
showStopArchivePrice: false,
stopArchiveMode: 'load'
stopArchiveMode: 'load',
contractId: '',
excludePlanId: ''
}
)
@@ -425,6 +481,94 @@
const aliasBrandManageProductName = ref('')
const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
const arrangeQtyCache = ref<Record<string, ContractProductArrangeQtyDetail | null>>({})
const arrangeQtyDialogVisible = ref(false)
const arrangeQtyContractId = ref('')
const arrangeQtyProductId = ref('')
const arrangeQtyProductName = ref('')
function cacheKey(productId: string) {
return `${props.contractId || ''}::${productId}::${props.excludePlanId || ''}`
}
async function ensureArrangeQty(productId: string) {
const cid = String(props.contractId || '').trim()
const pid = String(productId || '').trim()
if (!cid || !pid) return null
const key = cacheKey(pid)
if (key in arrangeQtyCache.value) return arrangeQtyCache.value[key]
try {
const res = await planApi.getContractProductArrangeQty({
contractId: cid,
productId: pid,
excludePlanId: props.excludePlanId || undefined
})
const data = pickBxData<ContractProductArrangeQtyDetail>(res) || null
arrangeQtyCache.value = { ...arrangeQtyCache.value, [key]: data }
return data
} catch {
arrangeQtyCache.value = { ...arrangeQtyCache.value, [key]: null }
return null
}
}
function formatAvailableDisplay(row: PlanStopProductFormRow) {
const cid = String(props.contractId || '').trim()
if (!cid) return '—'
const pid = String(row.productId || '').trim()
if (!pid) return '—'
const data = arrangeQtyCache.value[cacheKey(pid)]
if (!data) return '…'
if (!data.hasFixedQty) return '—'
if (data.availableQty == null) return '—'
return Number(data.availableQty).toFixed(3)
}
function isPlanQtyOverAvailable(row: PlanStopProductFormRow) {
const cid = String(props.contractId || '').trim()
const pid = String(row.productId || '').trim()
if (!cid || !pid) return false
const data = arrangeQtyCache.value[cacheKey(pid)]
if (!data?.hasFixedQty || data.availableQty == null) return false
const planQty = Number(row.planQty)
if (!Number.isFinite(planQty)) return false
return planQty > Number(data.availableQty) + 1e-9
}
function canOpenArrangeQty(row: PlanStopProductFormRow) {
return Boolean(String(props.contractId || '').trim() && String(row.productId || '').trim())
}
function openArrangeQtyDialog(row: PlanStopProductFormRow) {
if (!String(props.contractId || '').trim()) {
ElMessage.warning('请先选择合同')
return
}
if (!row.productId) return
arrangeQtyContractId.value = String(props.contractId)
arrangeQtyProductId.value = String(row.productId)
arrangeQtyProductName.value = String(row.productName || row.standardProductName || '')
arrangeQtyDialogVisible.value = true
}
watch(
() =>
[
props.contractId,
props.excludePlanId,
(props.products || []).map((p) => p.productId).join(',')
] as const,
() => {
const cid = String(props.contractId || '').trim()
if (!cid) return
for (const row of props.products || []) {
const pid = String(row.productId || '').trim()
if (pid) void ensureArrangeQty(pid)
}
},
{ immediate: true }
)
function handleRemove(index: number) {
emit('remove', index)
}
@@ -497,8 +641,63 @@
color: #64748b;
}
.pl-plan-product-cell-control {
width: 100%;
.pl-stop-avail-wrap {
display: flex;
align-items: center;
gap: 6px;
min-height: 36px;
}
.pl-stop-avail-wrap--table {
justify-content: flex-end;
}
.pl-stop-avail-num {
font-size: 14px;
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
}
.pl-stop-avail-help {
width: 22px !important;
height: 22px !important;
min-height: 22px !important;
padding: 0 !important;
font-size: 12px;
font-weight: 700;
color: #059669;
border-color: #a7f3d0;
background: #ecfdf5;
}
.pl-stop-plan-qty {
color: #dc2626;
font-weight: 600;
}
.pl-stop-over-tip {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.3;
color: #dc2626;
}
:deep(.pl-stop-product-dual-qty.is-over-available),
:deep(.el-input-number.is-over-available) {
.el-input__wrapper {
box-shadow: 0 0 0 1px #dc2626 inset;
background: #fff5f5;
}
.el-input__inner {
color: #dc2626;
font-weight: 600;
}
}
:deep(.pl-stop-product-dual-qty .el-input__inner) {
color: #dc2626;
font-weight: 600;
}
.pl-stop-product-dual-list {
@@ -571,12 +770,13 @@
.pl-stop-product-dual-item__label {
font-size: 10px;
font-weight: 600;
color: #64748b;
color: #000;
line-height: 1.2;
}
.pl-stop-product-dual-item__readonly {
font-size: 12px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
line-height: 1.4;
@@ -610,7 +810,7 @@
:deep(.el-input__inner) {
text-align: left;
font-weight: 600;
font-weight: 600 !important;
}
}
@@ -622,6 +822,21 @@
}
}
/* 产品区可编辑控件:字段值加粗(覆盖 dialog-form-layout 默认 400) */
.pl-stop-product-dual-item__field {
:deep(.el-input__inner),
:deep(.el-textarea__inner),
:deep(.el-select__selected-item),
:deep(.el-select__placeholder:not(.is-transparent)),
:deep(.el-select__wrapper) {
font-weight: 600 !important;
}
:deep(.pl-product-name-hazard__name) {
font-weight: 600;
}
}
.line-meta-cell {
display: flex;
align-items: center;