Files
shanxia2.0/web-admin/src/components/business/pl-plan-load-section/index.vue
T
wuzilongandCursor 522b4be705 feat(pl): 装送货段增加同市标记,并同步额度证件与计划表单。
同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:34:58 +08:00

601 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<MobilePlanLoadSection v-if="mobileSheet" :controller="mobileController" />
<div v-else class="pl-plan-load-section">
<PlPlanContractMultiSelect
v-model="contractsModel"
:customer-id="modelValue.customerId"
:trading-entity-name="tradingEntityName"
:disabled="contractSelectDisabled"
:required="!!modelValue.customerId && !isInternal && !contractSelectDisabled"
:mobile-sheet="mobileSheet"
compact-summary
>
<template #toolbar="{ open, disabled, hint, hintWarn }">
<div class="pl-plan-party-select-row">
<el-form-item label="装货客户" required class="pl-plan-party-select-row__customer">
<div class="customer-select-row">
<el-select
:model-value="modelValue.customerId"
class="customer-select-row__select"
filterable
remote
clearable
:remote-method="searchCustomerOptions"
:loading="customerOptionsLoading"
placeholder="客户名称/拼音首字母"
@focus="handleCustomerSelectFocus"
@update:model-value="handleCustomerChange"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.customerName"
:value="item.id"
>
<div class="customer-option-row">
<span>{{ item.customerName }}</span>
<span v-if="item.customerNo" class="customer-option-row__meta">{{
item.customerNo
}}</span>
</div>
</el-option>
</el-select>
<el-button
text
class="customer-select-row__search"
@click="emit('open-customer-picker')"
>
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item
v-if="!isInternal"
label="装货合同"
class="pl-plan-party-select-row__contract"
>
<div class="pl-plan-contract-trigger">
<el-button class="btn-ghost" :disabled="disabled" @click="open">
<el-icon><Search /></el-icon>选择合同
</el-button>
<span
v-if="hint"
class="pl-plan-contract-trigger__hint"
:class="{ 'is-warn': hintWarn }"
>
{{ hint }}
</span>
</div>
</el-form-item>
</div>
</template>
</PlPlanContractMultiSelect>
<div class="pl-plan-stop-pair-row">
<el-form-item label="装货时间" required>
<el-date-picker
:model-value="modelValue.loadTime"
type="date"
placeholder="选择装货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
@update:model-value="patch({ loadTime: $event || '' })"
/>
</el-form-item>
<el-form-item label="联系方式">
<el-input
:model-value="modelValue.contact"
placeholder="联系方式"
@update:model-value="patch({ contact: $event })"
/>
</el-form-item>
</div>
<div class="form-grid-2 pl-plan-load-section__grid">
<el-form-item label="装货地址" class="form-col-span-2">
<PlAddressWithLibrary
:model-value="modelValue.address"
:lng="modelValue.lng"
:lat="modelValue.lat"
:contact="modelValue.contact"
:mobile-sheet="mobileSheet"
placeholder="装货地址"
@apply="patchAddress($event)"
/>
</el-form-item>
<el-form-item label="注意事项" class="form-col-span-2">
<el-input
:model-value="modelValue.notes"
placeholder="装货注意事项"
@update:model-value="patch({ notes: $event })"
/>
</el-form-item>
<el-form-item label="是否同市" class="form-col-span-2">
<el-checkbox
:model-value="modelValue.sameCity === 1"
:disabled="readonly"
@update:model-value="patch({ sameCity: $event ? 1 : 0 })"
>
同市只需购买证
</el-checkbox>
</el-form-item>
<el-form-item label="地磅单" class="form-col-span-2">
<div class="pl-plan-dibang-row">
<el-checkbox
:model-value="modelValue.generateDibang === 1"
:disabled="readonly || !canManualDibang"
@update:model-value="handleDibangToggle"
>
生成地磅单({{ dibangDirectionLabel }})
</el-checkbox>
<span v-if="canManualDibang" class="pl-plan-dibang-row__hint">
该车辆地磅单可选,请自行勾选
</span>
<span v-else-if="isInternal" class="pl-plan-dibang-row__hint">本段客户为内部客户时默认勾选</span>
<span v-else-if="modelValue.customerId" class="pl-plan-dibang-row__hint">
仅安庆山峡/安徽云栋可生成地磅单
</span>
</div>
</el-form-item>
</div>
<div class="pl-plan-stop-products-head">
<span class="pl-plan-stop-products-head__title">装货产品</span>
<span
v-if="!readonly"
class="pl-plan-stop-products-head__add-wrap"
@click="handleAddProductsClick"
>
<el-button
class="btn-main"
type="primary"
size="small"
:disabled="addProductsDisabled"
>
<el-icon><Plus /></el-icon>添加产品
</el-button>
</span>
</div>
<p v-if="!modelValue.products.length" class="pl-plan-load-section__empty-hint">
请添加至少一条装货产品
</p>
<PlPlanStopProductTable
:products="modelValue.products"
:editable="!readonly"
:compact="compactProducts"
:contract-id="stopContractId"
:contracts="modelValue.contracts"
:exclude-plan-id="excludePlanId"
:contract-product-rows="contractProductRows"
@remove="removeStopProduct"
/>
</div>
</template>
<script setup lang="ts">
import MobilePlanLoadSection from '@/views/m/pl/plan/MobilePlanLoadSection.vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { fetchCustomerOptions } from '@/api/crm/customer'
import { customerStopPrefApi, type CustomerStopPref } from '@/api/pl/index'
import PlAddressWithLibrary, {
type PlAddressApplyPayload
} from '@/components/business/pl-address-with-library/index.vue'
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
import { resolveAddressNotesFill } from '@/utils/pl-address-notes-fill'
import {
collectStopContractIds,
type ContractProductRowLite
} from '@/utils/pl-plan-contract-product'
import {
canManualToggleStopDibang,
resolveStopDibangDefaults,
type PlanLoadForm
} from '@/utils/pl-plan-stop-form'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import { tradingEntityNameForBelongsCompany } from '@/utils/pl-belongs-company'
import { pickBxData } from '@/utils/http/bx'
const props = withDefaults(
defineProps<{
modelValue: PlanLoadForm
readonly?: boolean
compactProducts?: boolean
/** 移动端地址库 Sheet */
mobileSheet?: boolean
/** 编辑计划时排除本计划占用 */
excludePlanId?: string
/** 当前计划车牌(主牌),用于地磅可选车例外 */
vehiclePlate?: string
/** 装货合同产品行摘要(包装下拉) */
contractProductRows?: ContractProductRowLite[]
/** 归属公司。不传则合同不按交易主体过滤。 */
belongsCompany?: string
}>(),
{
readonly: false,
compactProducts: true,
excludePlanId: '',
vehiclePlate: '',
contractProductRows: () => []
}
)
const emit = defineEmits<{
'update:modelValue': [value: PlanLoadForm]
'open-customer-picker': []
'add-products': []
'customer-conflict': [message: string]
}>()
interface CustomerOptionRow {
id: string
customerName: string
customerNo?: string
}
const customerOptions = ref<CustomerOptionRow[]>([])
const customerOptionsLoading = ref(false)
const isInternal = computed(() => isPlInternalCustomerName(props.modelValue.customerName))
const tradingEntityName = computed(() =>
props.belongsCompany === undefined
? undefined
: tradingEntityNameForBelongsCompany(props.belongsCompany)
)
const contractSelectDisabled = computed(
() =>
!props.modelValue.customerId ||
isInternal.value ||
(tradingEntityName.value !== undefined && !tradingEntityName.value)
)
const canManualDibang = computed(() =>
canManualToggleStopDibang(props.modelValue.customerName, props.vehiclePlate)
)
const stopContractId = computed(
() => collectStopContractIds(props.modelValue.contracts)[0] || ''
)
/** 非内部客户须先选装货合同才能添加产品;内部客户不限制 */
const addProductsDisabled = computed(() => {
if (isInternal.value) return false
return !(props.modelValue.contracts || []).some((c) => String(c.contractId || '').trim())
})
function handleAddProductsClick() {
if (addProductsDisabled.value) {
ElMessage.warning('请先选择装货合同后再添加产品')
return
}
emit('add-products')
}
const dibangDirectionLabel = computed(() => {
if (props.modelValue.dibangDirection === 'outbound') return '出库'
if (props.modelValue.dibangDirection === 'inbound') return '入库'
return '无'
})
const contractsModel = computed({
get: () => props.modelValue.contracts,
set: (value) => patch({ contracts: value })
})
function patch(partial: Partial<PlanLoadForm>) {
emit('update:modelValue', { ...props.modelValue, ...partial })
}
function patchAddress(fields: PlAddressApplyPayload) {
const partial: Partial<PlanLoadForm> = {
address: fields.address,
lng: fields.lng,
lat: fields.lat
}
if (fields.contact !== undefined) {
partial.contact = fields.contact
}
const nextNotes = resolveAddressNotesFill(props.modelValue.notes, fields.notes)
if (nextNotes !== undefined) {
partial.notes = nextNotes
}
patch(partial)
}
function removeStopProduct(index: number) {
patch({
products: props.modelValue.products.filter((_, i) => i !== index)
})
}
function extractCustomerOptions(res: unknown): CustomerOptionRow[] {
const unwrapped = (res as { data?: unknown })?.data ?? res
if (Array.isArray(unwrapped)) return unwrapped as CustomerOptionRow[]
const payload = (unwrapped as { list?: CustomerOptionRow[]; rows?: CustomerOptionRow[] }) || {}
return payload.list || payload.rows || []
}
function ensureCustomerOption(customer?: Partial<CustomerOptionRow> | null) {
if (!customer?.id) return
const id = String(customer.id)
const exists = customerOptions.value.some((item) => String(item.id) === id)
if (exists) {
if (!customer.customerName) return
customerOptions.value = customerOptions.value.map((item) =>
String(item.id) === id
? {
...item,
customerName: customer.customerName || item.customerName,
customerNo: customer.customerNo || item.customerNo
}
: item
)
return
}
customerOptions.value = [
{
id,
customerName: customer.customerName || '',
customerNo: customer.customerNo
},
...customerOptions.value
]
}
watch(
() => [props.modelValue.customerId, props.modelValue.customerName] as const,
([customerId, customerName]) => {
if (!customerId) return
ensureCustomerOption({
id: String(customerId),
customerName: customerName || ''
})
},
{ immediate: true }
)
async function loadDefaultCustomerOptions() {
customerOptionsLoading.value = true
try {
const res = await fetchCustomerOptions({ limit: 20 })
customerOptions.value = extractCustomerOptions(res)
ensureCustomerOption({
id: props.modelValue.customerId,
customerName: props.modelValue.customerName
})
} finally {
customerOptionsLoading.value = false
}
}
async function searchCustomerOptions(query: string) {
const keyword = query?.trim()
if (!keyword) {
await loadDefaultCustomerOptions()
return
}
customerOptionsLoading.value = true
try {
const res = await fetchCustomerOptions({ keyword, limit: 50 })
customerOptions.value = extractCustomerOptions(res)
ensureCustomerOption({
id: props.modelValue.customerId,
customerName: props.modelValue.customerName
})
} finally {
customerOptionsLoading.value = false
}
}
function handleCustomerSelectFocus() {
if (!customerOptions.value.length) {
loadDefaultCustomerOptions()
}
}
function handleCustomerChange(customerId?: string) {
if (!customerId) {
patch({
customerId: '',
customerName: '',
contracts: [],
products: [],
generateDibang: 0,
dibangDirection: 'none',
address: '',
lng: null,
lat: null,
contact: '',
notes: ''
})
return
}
if (String(props.modelValue.customerId) === String(customerId)) {
ensureCustomerOption({
id: customerId,
customerName: props.modelValue.customerName
})
return
}
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
const customerName = customer?.customerName || ''
const defaults = resolveStopDibangDefaults(customerName, 'load', props.vehiclePlate)
patch({
customerId: String(customerId),
customerName,
contracts: [],
products: [],
generateDibang: defaults.generateDibang,
dibangDirection: defaults.dibangDirection,
address: '',
lng: null,
lat: null,
contact: '',
notes: ''
})
void applyStopPrefFromCustomer(String(customerId))
}
async function applyStopPrefFromCustomer(customerId: string) {
try {
const res = await customerStopPrefApi.get({ customerId, stopType: 'load' })
const pref = pickBxData<CustomerStopPref | null>(res)
if (!pref) return
if (String(props.modelValue.customerId) !== String(customerId)) return
const partial: Partial<PlanLoadForm> = {}
const prefAddress = (pref.address || '').trim()
if (prefAddress) partial.address = prefAddress
if (pref.lng != null) partial.lng = pref.lng
if (pref.lat != null) partial.lat = pref.lat
const prefContact = (pref.contact || '').trim()
if (prefContact) partial.contact = prefContact
const prefNotes = (pref.notes || '').trim()
if (prefNotes) partial.notes = prefNotes
if (Object.keys(partial).length) patch(partial)
} catch {
// 静默:不阻断选客户
}
}
function handleDibangToggle(checked: boolean) {
// 非可手动场景:外部客户强制不勾;内部非可选车由默认值锁定,忽略手动切换
if (!canManualDibang.value) {
if (!isInternal.value) {
patch({ generateDibang: 0, dibangDirection: 'none' })
}
return
}
patch({
generateDibang: checked ? 1 : 0,
dibangDirection: checked ? 'outbound' : 'none'
})
}
function applyCustomerFromPicker(customer: { id: string | number; customerName?: string }) {
ensureCustomerOption({
id: String(customer.id),
customerName: customer.customerName
})
handleCustomerChange(String(customer.id))
}
defineExpose({ applyCustomerFromPicker, ensureCustomerOption, searchCustomerOptions })
const mobileController = reactive({props,patch,patchAddress,applyCustomerFromPicker,isInternal,canManualDibang,handleDibangToggle,dibangDirectionLabel,handleAddProductsClick,removeStopProduct,stopContractId})
</script>
<style scoped lang="scss">
.pl-plan-stop-pair-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
margin-top: 4px;
}
.pl-plan-load-section__grid {
margin-top: 4px;
}
.pl-plan-stop-products-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 8px 0 0;
}
.pl-plan-stop-products-head__title {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.pl-plan-stop-products-head__add-wrap {
display: inline-flex;
cursor: pointer;
:deep(.el-button.is-disabled) {
pointer-events: none;
}
}
.pl-plan-load-section__empty-hint {
margin: 8px 0 0;
font-size: 12px;
color: #94a3b8;
}
.pl-plan-dibang-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.pl-plan-dibang-row__hint {
font-size: 12px;
color: #64748b;
}
.customer-select-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.customer-select-row__select {
flex: 1 1 auto;
min-width: 0;
}
.pl-plan-party-select-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.pl-plan-party-select-row__customer {
flex: 1.4 1 0;
min-width: 0;
margin-bottom: 0 !important;
}
.pl-plan-party-select-row__contract {
flex: 1 1 0;
min-width: 120px;
margin-bottom: 0 !important;
}
.pl-plan-contract-trigger {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-height: 32px;
}
.pl-plan-contract-trigger__hint {
font-size: 12px;
color: #64748b;
&.is-warn {
color: #d97706;
}
}
.customer-option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.customer-option-row__meta {
font-size: 12px;
color: #94a3b8;
}
</style>