提交
This commit is contained in:
1 parent
ebf006eeff
commit
16986f443e
30 files changed
+2319
No files matched your search
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<section v-if="items.length" class="pl-archive-brand-view">
|
||||
<h4 class="pl-archive-brand-view__title">品牌归档明细</h4>
|
||||
<div v-for="item in items" :key="item.key" class="pl-archive-brand-product">
|
||||
<div class="pl-archive-brand-product__head">
|
||||
<span class="pl-archive-brand-product__name">{{ item.productName }}</span>
|
||||
<span class="pl-archive-brand-product__plan">
|
||||
计划 {{ item.planQty ?? '—' }} {{ unitLabel(item.unit) }}
|
||||
</span>
|
||||
<span class="pl-archive-brand-product__segment-total">
|
||||
本段合计 {{ formatQty(item.segmentQty) }} {{ unitLabel(item.unit) }}
|
||||
</span>
|
||||
<el-tag v-if="item.brandMode === 'required'" size="small" type="warning">指定品牌</el-tag>
|
||||
<el-tag v-else-if="item.brandMode === 'any'" size="small" type="info">不限品牌</el-tag>
|
||||
</div>
|
||||
<div class="pl-archive-brand-table-wrap">
|
||||
<table class="pl-archive-brand-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品牌</th>
|
||||
<th class="is-num">实际数量</th>
|
||||
<th class="is-num">单价(元)</th>
|
||||
<th class="is-num">金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="brandRow in item.rows" :key="brandRow.brandId">
|
||||
<td>{{ brandRow.brandName || '—' }}</td>
|
||||
<td class="is-num">{{ formatQty(brandRow.qty) }}</td>
|
||||
<td class="is-num">{{ formatPrice(brandRow.price) }}</td>
|
||||
<td class="is-num pl-archive-brand-table__amount">
|
||||
{{ formatArchiveBrandAmount(brandRow.qty, brandRow.price) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot v-if="item.rows.length > 1">
|
||||
<tr>
|
||||
<td>合计</td>
|
||||
<td class="is-num">{{ formatQty(sumQty(item.rows)) }}</td>
|
||||
<td class="is-num">—</td>
|
||||
<td class="is-num">{{ formatAmountTotal(item.rows) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ArchiveBrandViewItem,
|
||||
type ArchiveBrandViewRow,
|
||||
formatArchiveBrandAmount
|
||||
} from '@/utils/pl-plan-archive-brand-view'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
|
||||
defineProps<{
|
||||
items: ArchiveBrandViewItem[]
|
||||
}>()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
function unitLabel(unit?: string) {
|
||||
const code = (unit || '').trim()
|
||||
return dictStore.getLabel('crm_product_unit', code) || code || '吨'
|
||||
}
|
||||
|
||||
function formatQty(value?: number | null) {
|
||||
if (value == null || !Number.isFinite(Number(value))) return '—'
|
||||
return Number(value).toFixed(3)
|
||||
}
|
||||
|
||||
function formatPrice(value?: number | null) {
|
||||
if (value == null || !Number.isFinite(Number(value))) return '—'
|
||||
return Number(value).toFixed(2)
|
||||
}
|
||||
|
||||
function sumQty(rows: ArchiveBrandViewRow[]) {
|
||||
return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
|
||||
}
|
||||
|
||||
function formatAmountTotal(rows: ArchiveBrandViewRow[]) {
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.qty || 0) * Number(row.price || 0), 0)
|
||||
return Number.isFinite(total) ? total.toFixed(2) : '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pl-archive-brand-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter, #e2e8f0);
|
||||
}
|
||||
|
||||
.pl-archive-brand-view__title {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--section-bar-title-color, var(--primary-dark, #059669));
|
||||
border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pl-archive-brand-product {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--mint-bg-soft, #f8fafc);
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__plan {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__segment-total {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.pl-archive-brand-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pl-archive-brand-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.is-num {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-archive-brand-table__amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #334155;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="pl-plan-view-stop-block pl-plan-archive-segment-block">
|
||||
<div class="pl-plan-view-stop-meta">
|
||||
<div class="pl-plan-view-party-field">
|
||||
<span class="pl-plan-view-party-field__label">{{ customerLabel }}</span>
|
||||
<span class="pl-plan-view-party-field__value">{{ meta.customerName || '—' }}</span>
|
||||
</div>
|
||||
<div class="pl-plan-view-party-field">
|
||||
<span class="pl-plan-view-party-field__label">{{ timeLabel }}</span>
|
||||
<span class="pl-plan-view-party-field__value">{{ formatViewDate(meta.time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlPlanContractMultiSelect
|
||||
:model-value="meta.contracts || []"
|
||||
:customer-id="meta.customerId"
|
||||
readonly
|
||||
compact-summary
|
||||
/>
|
||||
|
||||
<PlPlanStopProductTable
|
||||
v-if="viewProducts.length"
|
||||
:products="viewProducts"
|
||||
compact
|
||||
show-stop-actual-qty
|
||||
/>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无产品</p>
|
||||
|
||||
<PlPlanStopFeedbackPanel
|
||||
v-if="showStopFeedback"
|
||||
:stop-label="feedbackStopLabel"
|
||||
:customer-name="meta.customerName"
|
||||
:generate-dibang="generateDibang"
|
||||
:dibang-rows="dibangRows"
|
||||
:driver-feedback="driverFeedback"
|
||||
:site-feedback="siteFeedback"
|
||||
:fallback-remark="fallbackRemark"
|
||||
/>
|
||||
|
||||
<section class="pl-archive-segment-form">
|
||||
<h4 class="pl-archive-segment-form__title">归档填写</h4>
|
||||
|
||||
<PlPlanFormProductTable
|
||||
v-if="products.length"
|
||||
:products="products"
|
||||
archive-compact
|
||||
show-archive-io-fields
|
||||
:archive-io-inbound-only="archiveMode === 'inbound'"
|
||||
:archive-io-outbound-only="archiveMode === 'outbound'"
|
||||
archive-segment-labels
|
||||
editable-archive-io-fields
|
||||
/>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无可归档产品</p>
|
||||
|
||||
<div
|
||||
v-for="row in brandProducts"
|
||||
:key="`brand-${row.id || row.productId}`"
|
||||
class="pl-archive-brand-product"
|
||||
>
|
||||
<div class="pl-archive-brand-product__head">
|
||||
<span class="pl-archive-brand-product__name">{{
|
||||
row.standardProductName || row.productName
|
||||
}}</span>
|
||||
<span class="pl-archive-brand-product__plan"
|
||||
>计划 {{ row.quantity ?? '—' }} {{ unitLabel(row.unit) }}</span
|
||||
>
|
||||
<span class="pl-archive-brand-product__segment-total">
|
||||
本段合计 {{ formatSegmentActualQty(row) }} {{ unitLabel(row.unit) }}
|
||||
</span>
|
||||
<el-tag v-if="row.brandMode === 'required'" size="small" type="warning">指定品牌</el-tag>
|
||||
<el-tag v-else size="small" type="info">不限品牌</el-tag>
|
||||
</div>
|
||||
<div class="pl-archive-brand-table-wrap">
|
||||
<table class="pl-archive-brand-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品牌</th>
|
||||
<th class="is-num">实际数量</th>
|
||||
<th class="is-num">单价(元)</th>
|
||||
<th class="is-num">金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="brandRow in activeBrandRows(row)" :key="brandRow.brandId">
|
||||
<td>{{ brandRow.brandName }}</td>
|
||||
<td class="is-num">
|
||||
<el-input-number
|
||||
v-model="brandRow.qty"
|
||||
:min="0"
|
||||
:precision="3"
|
||||
:controls="false"
|
||||
@change="() => syncProductFromBrandRows(row)"
|
||||
/>
|
||||
</td>
|
||||
<td class="is-num">
|
||||
<el-input-number
|
||||
v-model="brandRow.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
@change="() => syncProductFromBrandRows(row)"
|
||||
/>
|
||||
</td>
|
||||
<td class="is-num pl-archive-brand-table__amount">
|
||||
{{ formatBrandRowAmount(brandRow) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot v-if="activeBrandRows(row).length > 1">
|
||||
<tr>
|
||||
<td>合计</td>
|
||||
<td class="is-num">{{ formatBrandQtyTotal(row) }}</td>
|
||||
<td class="is-num">—</td>
|
||||
<td class="is-num">{{ formatBrandAmountTotal(row) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<BrandQtyChipsField
|
||||
v-if="row.brandMode === 'any' && (row.brandOptions || []).length > 1"
|
||||
v-model="row.brandQtyRows"
|
||||
brand-mode="any"
|
||||
:brand-options="row.brandOptions || []"
|
||||
:unit="unitLabel(row.unit)"
|
||||
variant="admin"
|
||||
@update:model-value="() => syncProductFromBrandRows(row)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TransportStopFeedback } from '@/api/pl'
|
||||
import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
|
||||
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
|
||||
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
|
||||
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
|
||||
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
|
||||
import type { PlPlanTransportDibangRow } from '@/composables/usePlPlanViewDetail'
|
||||
import { type PlanFormProductRow } from '@/composables/usePlContractToPlan'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import type { PlanStopProductFormRow } from '@/utils/pl-plan-stop-form'
|
||||
|
||||
export interface ArchiveSegmentMeta {
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
time?: string
|
||||
contracts?: Array<{ contractId: string; contractNo?: string }>
|
||||
}
|
||||
|
||||
export interface ArchiveBrandQtyRow {
|
||||
brandId: string
|
||||
brandName: string
|
||||
qty: number
|
||||
price?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
stopType: 'load' | 'delivery'
|
||||
meta: ArchiveSegmentMeta
|
||||
viewProducts: PlanStopProductFormRow[]
|
||||
products: PlanFormProductRow[]
|
||||
archiveMode: 'inbound' | 'outbound'
|
||||
showStopFeedback?: boolean
|
||||
feedbackStopLabel?: string
|
||||
generateDibang?: number
|
||||
dibangRows?: PlPlanTransportDibangRow[]
|
||||
driverFeedback?: TransportStopFeedback | null
|
||||
siteFeedback?: TransportStopFeedback | null
|
||||
fallbackRemark?: string | null
|
||||
}>()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const customerLabel = computed(() => (props.stopType === 'load' ? '装货客户' : '送货客户'))
|
||||
const timeLabel = computed(() => (props.stopType === 'load' ? '装货时间' : '送货时间'))
|
||||
const feedbackStopLabel = computed(
|
||||
() => props.feedbackStopLabel || (props.stopType === 'load' ? '装货' : '送货')
|
||||
)
|
||||
|
||||
const brandProducts = computed(() =>
|
||||
(props.products || []).filter((row) => row.brandMode === 'any' || row.brandMode === 'required')
|
||||
)
|
||||
|
||||
function formatSegmentActualQty(row: PlanFormProductRow) {
|
||||
const qty =
|
||||
props.archiveMode === 'inbound'
|
||||
? row.inboundQty != null
|
||||
? Number(row.inboundQty)
|
||||
: null
|
||||
: row.outboundQty != null
|
||||
? Number(row.outboundQty)
|
||||
: null
|
||||
return qty != null && Number.isFinite(qty) ? qty.toFixed(3) : '—'
|
||||
}
|
||||
|
||||
function formatViewDate(value?: string | null) {
|
||||
const text = (value || '').trim()
|
||||
if (!text) return '—'
|
||||
return text.length >= 10 ? text.slice(0, 10) : text
|
||||
}
|
||||
|
||||
function unitLabel(unit?: string) {
|
||||
const code = (unit || '').trim()
|
||||
return dictStore.getLabel('crm_product_unit', code) || code || '吨'
|
||||
}
|
||||
|
||||
function activeBrandRows(row: PlanFormProductRow): ArchiveBrandQtyRow[] {
|
||||
return (row.brandQtyRows || []) as ArchiveBrandQtyRow[]
|
||||
}
|
||||
|
||||
function formatBrandRowAmount(brandRow: ArchiveBrandQtyRow) {
|
||||
if (brandRow.qty == null || brandRow.price == null) return '—'
|
||||
const amount = Number(brandRow.qty) * Number(brandRow.price)
|
||||
return Number.isFinite(amount) ? amount.toFixed(2) : '—'
|
||||
}
|
||||
|
||||
function formatBrandQtyTotal(row: PlanFormProductRow) {
|
||||
const total = activeBrandRows(row).reduce((sum, item) => sum + Number(item.qty || 0), 0)
|
||||
return total.toFixed(3)
|
||||
}
|
||||
|
||||
function formatBrandAmountTotal(row: PlanFormProductRow) {
|
||||
const total = activeBrandRows(row).reduce(
|
||||
(sum, item) => sum + Number(item.qty || 0) * Number(item.price || 0),
|
||||
0
|
||||
)
|
||||
return total.toFixed(2)
|
||||
}
|
||||
|
||||
function syncProductFromBrandRows(row: PlanFormProductRow) {
|
||||
const rows = activeBrandRows(row)
|
||||
if (!rows.length) return
|
||||
const totalQty = rows.reduce((sum, item) => sum + Number(item.qty || 0), 0)
|
||||
const totalAmount = rows.reduce(
|
||||
(sum, item) => sum + Number(item.qty || 0) * Number(item.price || 0),
|
||||
0
|
||||
)
|
||||
const avgPrice = totalQty > 0 ? totalAmount / totalQty : undefined
|
||||
if (props.archiveMode === 'inbound') {
|
||||
row.inboundQty = totalQty
|
||||
row.inboundPrice = avgPrice
|
||||
} else {
|
||||
row.outboundQty = totalQty
|
||||
row.outboundPrice = avgPrice
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pl-plan-archive-segment-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-view-stop-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 16px;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field__label {
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field__value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pl-plan-form-empty-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-archive-segment-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--el-border-color-lighter, #e2e8f0);
|
||||
}
|
||||
|
||||
.pl-archive-segment-form__title {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--section-bar-title-color, var(--primary-dark, #059669));
|
||||
border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pl-archive-brand-product {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--mint-bg-soft, #f8fafc);
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__plan {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-archive-brand-product__segment-total {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.pl-archive-brand-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pl-archive-brand-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.is-num {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 120px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input__inner) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-archive-brand-table__amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #334155;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="pl-plan-contract-cell">
|
||||
<div
|
||||
v-for="(line, index) in lines"
|
||||
:key="`${line.side}-${line.contractId || line.no}-${index}`"
|
||||
class="pl-plan-contract-cell__row"
|
||||
>
|
||||
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
|
||||
<el-link
|
||||
v-if="isClickable(line)"
|
||||
type="primary"
|
||||
class="pl-plan-contract-cell__no pl-plan-contract-cell__link"
|
||||
@click.stop="emit('open', line.contractId!)"
|
||||
>
|
||||
{{ line.no }}
|
||||
</el-link>
|
||||
<span v-else class="pl-plan-contract-cell__no" :class="{ 'is-pending': line.pending }">
|
||||
{{ line.no }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PlanContractDisplayLine } from '@/utils/pl-plan-contract-display'
|
||||
|
||||
defineProps<{
|
||||
lines: PlanContractDisplayLine[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [contractId: string]
|
||||
}>()
|
||||
|
||||
function isClickable(line: PlanContractDisplayLine) {
|
||||
return !!line.contractId && !line.pending && line.no !== '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pl-plan-contract-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text, #1e293b);
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__side {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__no {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&.is-pending {
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-contract-cell__link {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineAsyncComponent, nextTick, ref, watch } from 'vue'
|
||||
|
||||
export const ContractDetailDialogAsync = defineAsyncComponent(
|
||||
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
|
||||
)
|
||||
|
||||
type ContractDetailDialogExpose = {
|
||||
open: (id: string | number) => Promise<void>
|
||||
}
|
||||
|
||||
export function useContractDetailDialog() {
|
||||
const contractDetailRef = ref<ContractDetailDialogExpose>()
|
||||
|
||||
async function waitForContractDetailDialog() {
|
||||
if (contractDetailRef.value) return contractDetailRef.value
|
||||
await nextTick()
|
||||
if (contractDetailRef.value) return contractDetailRef.value
|
||||
return new Promise<ContractDetailDialogExpose>((resolve) => {
|
||||
const stop = watch(
|
||||
contractDetailRef,
|
||||
(val) => {
|
||||
if (val) {
|
||||
stop()
|
||||
resolve(val)
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function openContractDetail(contractId: string | number | undefined | null) {
|
||||
const id = String(contractId ?? '').trim()
|
||||
if (!id) {
|
||||
ElMessage.warning('暂无关联合同')
|
||||
return
|
||||
}
|
||||
const dialog = await waitForContractDetailDialog()
|
||||
await dialog.open(id)
|
||||
}
|
||||
|
||||
return {
|
||||
ContractDetailDialog: ContractDetailDialogAsync,
|
||||
contractDetailRef,
|
||||
openContractDetail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
|
||||
/** 是否为可提交的完整非负小数(空字符串视为清空) */
|
||||
export function isCompleteDecimalInput(raw: string): boolean {
|
||||
const trimmed = raw.trim()
|
||||
return trimmed === '' || /^\d+(\.\d+)?$/.test(trimmed)
|
||||
}
|
||||
|
||||
export function parseDecimalInput(raw: string, precision?: number): number | null {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return null
|
||||
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
|
||||
const num = Number(trimmed)
|
||||
if (!Number.isFinite(num) || num < 0) return null
|
||||
if (precision == null) return num
|
||||
return Number(num.toFixed(precision))
|
||||
}
|
||||
|
||||
function formatDecimalDisplay(val: number, precision?: number): string {
|
||||
if (precision == null) return String(val)
|
||||
return Number(val.toFixed(precision)).toString()
|
||||
}
|
||||
|
||||
function sanitizeDecimalText(raw: string): string {
|
||||
let text = raw.replace(/[^\d.]/g, '')
|
||||
const dotIndex = text.indexOf('.')
|
||||
if (dotIndex >= 0) {
|
||||
text = text.slice(0, dotIndex + 1) + text.slice(dotIndex + 1).replace(/\./g, '')
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 number | null 与 el-input 文本双向绑定;
|
||||
* 仅在完整小数或清空时写入 source,避免打断输入,同时保证运费计算 watch 能触发。
|
||||
*/
|
||||
export function useDecimalInputBinding(
|
||||
source: Ref<number | null>,
|
||||
options?: { precision?: number; onCommit?: () => void }
|
||||
) {
|
||||
const text = ref('')
|
||||
|
||||
watch(
|
||||
source,
|
||||
(val) => {
|
||||
const next =
|
||||
val == null || Number.isNaN(Number(val))
|
||||
? ''
|
||||
: formatDecimalDisplay(Number(val), options?.precision)
|
||||
if (text.value !== next) text.value = next
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function commitFromText(raw: string, force = false) {
|
||||
const sanitized = sanitizeDecimalText(raw)
|
||||
text.value = sanitized
|
||||
if (!force && !isCompleteDecimalInput(sanitized)) return
|
||||
const parsed = parseDecimalInput(sanitized, options?.precision)
|
||||
const prev = source.value
|
||||
const changed =
|
||||
parsed == null ? prev != null : prev == null || Math.abs(Number(prev) - parsed) > 1e-9
|
||||
source.value = parsed
|
||||
if (parsed != null) {
|
||||
text.value = formatDecimalDisplay(parsed, options?.precision)
|
||||
} else if (force) {
|
||||
text.value = ''
|
||||
}
|
||||
if (changed) options?.onCommit?.()
|
||||
}
|
||||
|
||||
function handleInput(val: string) {
|
||||
commitFromText(val, false)
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
commitFromText(text.value, true)
|
||||
}
|
||||
|
||||
return { text, handleInput, handleBlur }
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
|
||||
import type { PlanStopProductFormRow } from '@/utils/pl-plan-stop-form'
|
||||
|
||||
export interface ArchiveBrandViewRow {
|
||||
brandId: string
|
||||
brandName: string
|
||||
qty: number
|
||||
price?: number
|
||||
amount?: number
|
||||
}
|
||||
|
||||
export interface ArchiveBrandViewItem {
|
||||
key: string
|
||||
productName: string
|
||||
unit?: string
|
||||
planQty?: number
|
||||
segmentQty: number
|
||||
brandMode?: string
|
||||
rows: ArchiveBrandViewRow[]
|
||||
}
|
||||
|
||||
function getSegmentQty(
|
||||
row: PlanStopProductFormRow,
|
||||
mode: 'inbound' | 'outbound',
|
||||
planRow?: PlanFormProductRow
|
||||
): number {
|
||||
const primary = mode === 'inbound' ? row.inboundQty : row.outboundQty
|
||||
if (primary != null && Number.isFinite(Number(primary))) return Number(primary)
|
||||
const fallback = mode === 'inbound' ? planRow?.inboundQty : planRow?.outboundQty
|
||||
if (fallback != null && Number.isFinite(Number(fallback))) return Number(fallback)
|
||||
return 0
|
||||
}
|
||||
|
||||
function getSegmentPrice(
|
||||
row: PlanStopProductFormRow,
|
||||
mode: 'inbound' | 'outbound',
|
||||
planRow?: PlanFormProductRow
|
||||
): number | undefined {
|
||||
const primary = mode === 'inbound' ? row.inboundPrice : row.outboundPrice
|
||||
if (primary != null && Number.isFinite(Number(primary))) return Number(primary)
|
||||
const fallback = mode === 'inbound' ? planRow?.inboundPrice : planRow?.outboundPrice
|
||||
if (fallback != null && Number.isFinite(Number(fallback))) return Number(fallback)
|
||||
if (planRow?.taxPrice != null && Number.isFinite(Number(planRow.taxPrice))) {
|
||||
return Number(planRow.taxPrice)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getPlanBrandRows(planRow?: PlanFormProductRow) {
|
||||
return (planRow?.brandQtyRows || []).filter((row) => Number(row.qty) > 0)
|
||||
}
|
||||
|
||||
function getStopBrandRows(stopRow: PlanStopProductFormRow) {
|
||||
return (stopRow.brandQtys || []).filter((row) => Number(row.qty) > 0)
|
||||
}
|
||||
|
||||
function getAllocationMap(
|
||||
allocations: Map<string, Map<string, number>>,
|
||||
planProductId: string
|
||||
): Map<string, number> {
|
||||
let map = allocations.get(planProductId)
|
||||
if (!map) {
|
||||
map = new Map()
|
||||
allocations.set(planProductId, map)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 按段顺序贪心分配计划级品牌量(旧数据无段级品牌明细时回退) */
|
||||
function allocateBrandRows(
|
||||
brandRows: Array<{ brandId: string; brandName: string; qty: number }>,
|
||||
segmentQty: number,
|
||||
price: number | undefined,
|
||||
allocated: Map<string, number>
|
||||
): ArchiveBrandViewRow[] {
|
||||
if (segmentQty <= 0 || !brandRows.length) return []
|
||||
let remaining = segmentQty
|
||||
const result: ArchiveBrandViewRow[] = []
|
||||
for (const brand of brandRows) {
|
||||
const total = Number(brand.qty || 0)
|
||||
if (total <= 0) continue
|
||||
const used = allocated.get(brand.brandId) || 0
|
||||
const available = total - used
|
||||
if (available <= 0.0005) continue
|
||||
const take = Math.min(available, remaining)
|
||||
if (take <= 0.0005) continue
|
||||
allocated.set(brand.brandId, used + take)
|
||||
remaining -= take
|
||||
result.push({
|
||||
brandId: brand.brandId,
|
||||
brandName: brand.brandName,
|
||||
qty: take,
|
||||
price,
|
||||
amount: price != null ? take * price : undefined
|
||||
})
|
||||
if (remaining <= 0.0005) break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildRowsFromStopBrandQtys(
|
||||
stopRow: PlanStopProductFormRow,
|
||||
fallbackPrice: number | undefined
|
||||
): ArchiveBrandViewRow[] {
|
||||
return getStopBrandRows(stopRow).map((brand) => {
|
||||
const qty = Number(brand.qty || 0)
|
||||
const price =
|
||||
brand.unitPrice != null && Number.isFinite(Number(brand.unitPrice))
|
||||
? Number(brand.unitPrice)
|
||||
: fallbackPrice
|
||||
return {
|
||||
brandId: String(brand.brandId || ''),
|
||||
brandName: String(brand.brandName || ''),
|
||||
qty,
|
||||
price,
|
||||
amount: price != null ? qty * price : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildArchiveBrandViewItems(
|
||||
stopProducts: PlanStopProductFormRow[],
|
||||
planProducts: PlanFormProductRow[],
|
||||
mode: 'inbound' | 'outbound',
|
||||
allocations: Map<string, Map<string, number>>
|
||||
): ArchiveBrandViewItem[] {
|
||||
const planById = new Map(planProducts.filter((row) => row.id).map((row) => [String(row.id), row]))
|
||||
const planByProductId = new Map(planProducts.map((row) => [String(row.productId), row]))
|
||||
const items: ArchiveBrandViewItem[] = []
|
||||
|
||||
for (const stopRow of stopProducts) {
|
||||
const planRow =
|
||||
(stopRow.planProductId ? planById.get(String(stopRow.planProductId)) : undefined) ||
|
||||
planByProductId.get(String(stopRow.productId))
|
||||
const brandMode = stopRow.brandMode || planRow?.brandMode
|
||||
if (brandMode !== 'any' && brandMode !== 'required') continue
|
||||
|
||||
const planProductId = String(stopRow.planProductId || planRow?.id || '').trim()
|
||||
if (!planProductId) continue
|
||||
|
||||
const segmentQty = getSegmentQty(stopRow, mode, planRow)
|
||||
if (segmentQty <= 0) continue
|
||||
const price = getSegmentPrice(stopRow, mode, planRow)
|
||||
|
||||
// 优先使用段级归档时填写的品牌数量
|
||||
let rows = buildRowsFromStopBrandQtys(stopRow, price)
|
||||
if (!rows.length) {
|
||||
const brandRows = getPlanBrandRows(planRow)
|
||||
if (!brandRows.length) continue
|
||||
const allocated = getAllocationMap(allocations, planProductId)
|
||||
rows = allocateBrandRows(brandRows, segmentQty, price, allocated)
|
||||
}
|
||||
if (!rows.length) continue
|
||||
|
||||
items.push({
|
||||
key: `${planProductId}-${stopRow.id || stopRow.productId}`,
|
||||
productName:
|
||||
stopRow.standardProductName ||
|
||||
stopRow.productName ||
|
||||
planRow?.standardProductName ||
|
||||
planRow?.productName ||
|
||||
'—',
|
||||
unit: stopRow.unit || planRow?.unit,
|
||||
planQty: stopRow.planQty ?? planRow?.quantity,
|
||||
segmentQty,
|
||||
brandMode,
|
||||
rows
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildArchiveBrandViewsBySegment(input: {
|
||||
loadProducts: PlanStopProductFormRow[]
|
||||
deliveries: Array<{ key: string; products: PlanStopProductFormRow[] }>
|
||||
planProducts: PlanFormProductRow[]
|
||||
}): Map<string, ArchiveBrandViewItem[]> {
|
||||
const result = new Map<string, ArchiveBrandViewItem[]>()
|
||||
// 装货(入库)与送货(出库)各自独立分配池,互不占位(仅旧数据回退贪心时使用)
|
||||
const inboundAllocations = new Map<string, Map<string, number>>()
|
||||
const outboundAllocations = new Map<string, Map<string, number>>()
|
||||
|
||||
result.set(
|
||||
'load',
|
||||
buildArchiveBrandViewItems(
|
||||
input.loadProducts,
|
||||
input.planProducts,
|
||||
'inbound',
|
||||
inboundAllocations
|
||||
)
|
||||
)
|
||||
|
||||
for (const delivery of input.deliveries) {
|
||||
result.set(
|
||||
delivery.key,
|
||||
buildArchiveBrandViewItems(
|
||||
delivery.products,
|
||||
input.planProducts,
|
||||
'outbound',
|
||||
outboundAllocations
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatArchiveBrandAmount(qty?: number, price?: number): string {
|
||||
if (qty == null || price == null) return '—'
|
||||
const amount = Number(qty) * Number(price)
|
||||
return Number.isFinite(amount) ? amount.toFixed(2) : '—'
|
||||
}
|
||||
Reference in new issue
Block a user