This commit is contained in:
zlwu committed 2026-08-27 19:38:12 +08:00
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) : '—'
}