同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
601 lines
18 KiB
Vue
601 lines
18 KiB
Vue
<!-- 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>
|