2026-10-16 15:41:56 +08:00
< script setup lang = "ts" >
2026-10-21 07:40:08 +08:00
import './appointmentNativeForms.css'
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-10-20 15:48:27 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
2026-10-16 15:41:56 +08:00
import { computed , onBeforeUnmount , onMounted , reactive , ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createAppointmentForm , appointmentPayload , appointmentFormError , FbaAppointmentFields } from '@/modules/overseas-warehouse'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getCargoVerification , type CargoVerificationContext } from './cargoVerificationApi'
import ReceivingScope from './ReceivingScope.vue'
import { rejectedCommand , useWorkDraft } from './useWorkDraft'
import { refreshReceivingCargo , type ReceivingCargoRefresh , getReceivingExecutionScope , type ReceivingExecutionScope , saveReceivingPlan , type ReceivingPlan , getReceivingContext , getReceivingGroup , proposeReceivingChange , resolveReceivingChange , bookReceiving , changeReceivingGroup , type ReceivingContext , type ReceivingGroup , type ReceivingMember , type ReceivingPending , type ReceivingResult , type ReceivingPropose , type ReceivingResolve , type ReceivingBook , type ReceivingGroupChange } from './receivingApi'
const props = defineProps < { batchKey : string ; canWrite : boolean ; initialCustomerId ?: string ; owners ?: Array < { id : string ; name : string } > } > ()
const emit = defineEmits < { updated : []; appointment : [ number : string ]; batch : [ key : string ]; external : [ path : string ] } > ()
const auth = useAuthStore ()
const owners = computed (() => [... new Map ([...( props . owners || []), ...( auth . user ? [{ id : auth . user . id , name : auth . user . name }] : [])]. filter ( person => person . id ). map ( person => [ person . id , person ])). values ()])
const access = useAccessPreviewStore (), context = ref < ReceivingContext | null >( null ), group = ref < ReceivingGroup | null >( null )
const loading = ref ( false ), saving = ref ( false ), error = ref < ApiErrorDetails | null >( null ), saveError = ref < ApiErrorDetails | null >( null ), result = ref < ReceivingResult | null >( null )
const confirming = ref ( false )
const mode = ref < 'refresh' | 'plan' | 'overview' | 'customer' | 'resolve' | 'book' | 'reschedule' | 'cancel' > ( 'overview' )
const customer = ref ( '' ), change = ref < ReceivingPending | null >( null ), groupKey = ref < string | null >( null )
const adjustment = reactive ({ retained : {} as Record < string , number | string >, laterDate : '' , ownerId : '' , followup : '' , reason : '' })
const evidence = ref ( '' ), separate = ref ( false ), confirmedScope = ref ( false )
const bookingForms = ref < Array < ReturnType < typeof createAppointmentForm > >> ([]), bookingEvidence = ref < string [] >([])
const bookingMemberKeys = ref < string [] >([]), selectedMemberKeys = ref < string [] >([])
const selectedExecution = ref < ReceivingExecutionScope | null >( null ), scopeLoading = ref ( false ), scopeError = ref < ApiErrorDetails | null >( null )
const cargoContexts = ref < CargoVerificationContext [] >([]), cargoLoading = ref ( false ), cargoError = ref < ApiErrorDetails | null >( null )
let cargoGeneration = 0
let scopeGeneration = 0
let initializedGroup : string | null = null
const planForm = reactive ( createAppointmentForm ())
const groupForm = reactive ( createAppointmentForm ())
const state = () => JSON . stringify ({ selectedMemberKeys : selectedMemberKeys . value , planForm , adjustment , evidence : evidence . value , separate : separate . value , bookingForms : bookingForms . value , bookingEvidence : bookingEvidence . value , groupForm })
const baseline = ref ( state ())
type Command = { kind : 'refresh' ; request : ReceivingCargoRefresh } | { kind : 'plan' ; request : ReceivingPlan } | { kind : 'propose' ; request : ReceivingPropose } | { kind : 'resolve' ; key : string ; request : ReceivingResolve } | { kind : 'book' ; request : ReceivingBook } | { kind : 'group' ; key : string ; request : ReceivingGroupChange }
const pending = ref < Command | null >( null )
const canLeave = useWorkDraft ( computed (() => state () !== baseline . value ), computed (() => saving . value || confirming . value || !! pending . value ))
const readable = computed (() => access . can ( 'platform:appointment:list' ))
const edit = computed (() => props . canWrite && access . can ( 'platform:overseas-warehouse:inbound-order:fba-appointment' ))
const remove = computed (() => props . canWrite && access . can ( 'platform:appointment:delete' ))
const formWritable = computed (() => mode . value === 'cancel' ? remove . value : [ 'customer' , 'resolve' ]. includes ( mode . value ) ? edit . value && remove . value : edit . value )
const locked = computed (() => loading . value || saving . value || confirming . value || !! error . value || !! pending . value )
const sourceKey = ( source : { sourceKey : string ; destinationKey : string }) => JSON . stringify ([ source . sourceKey , source . destinationKey ])
const customerItems = computed (() => context . value ? . batch . items . filter ( item => item . customerId === customer . value ) || [])
const customers = computed (() => [... new Set ( context . value ? . batch . items . map ( item => item . customerId ) || [])])
function customerName ( id : string ) { return context . value ? . agreements . flatMap ( row => row . members ). find ( member => member . customerId === id ) ? . customerName || context . value ? . pendingChanges . flatMap ( row => row . originalMembers ). find ( member => member . customerId === id ) ? . customerName || null }
const eligibleCustomers = computed (() => customers . value . filter ( id => context . value ? . executionScope . members . some ( row => row . member . batchKey === context . value ? . batch . batchKey && row . member . customerId === id && row . canRebook && ! row . completed )))
const uncoveredCustomers = computed (() => eligibleCustomers . value . filter ( id => ! context . value ! . agreements . some ( a => a . status === 'ACTIVE' && a . members . some ( m => m . batchKey === props . batchKey && m . customerId === id )) && ! context . value ! . pendingChanges . some ( c => c . status === 'WAITING' && c . intent . batchKey === props . batchKey && c . intent . customerId === id )))
const refreshReadable = computed (() => readable . value && access . can ( 'platform:overseas-warehouse:last-mile-order:list' ))
const refreshEligible = ( id : string ) => refreshReadable . value && !! context . value ? . allowedActions . includes ( 'REFRESH_CARGO' ) && uncoveredCustomers . value . includes ( id )
const planEligible = ( id : string ) => !! context . value ? . allowedActions . includes ( 'PLAN_RECEIVING' ) && uncoveredCustomers . value . includes ( id )
const customerPlan = ( id : string ) => context . value ? . plans . find ( plan => plan . batchKey === props . batchKey && plan . customerId === id )
const planTimezone = computed (() => { const saved = customerPlan ( customer . value ); return saved ? saved . timezone : context . value ? . batch . timezone })
const cargoIncomplete = computed (() => !! context . value ? . batch . items . filter ( item => eligibleCustomers . value . includes ( item . customerId )). some ( item => item . packageCount == null || ! item . destinationKey || ! item . sourceVersion ))
const bookMembers = computed < ReceivingMember [] >(() => {
if ( group . value ) return group . value . eligibleMembers
const batch = context . value ? . batch
if ( ! batch ? . batchKey || cargoIncomplete . value ) return []
return eligibleCustomers . value . filter ( id => ! context . value ! . agreements . some ( a => a . status === 'ACTIVE' && a . members . some ( m => m . batchKey === batch . batchKey && m . customerId === id )) && ! context . value ! . pendingChanges . some ( c => c . status === 'WAITING' && c . intent . customerId === id )). map ( id => ({ batchKey : batch . batchKey ! , appointmentNo : batch . appointmentNo , batchVersion : batch . version , customerId : id , customerName : customerName ( id ), sources : batch . items . filter ( item => item . customerId === id ). map ( item => ({ sourceKey : item . sourceKey , inboundCode : item . inboundCode || null , destinationKey : item . destinationKey ! , packageCount : item . packageCount ! , sourceVersion : item . sourceVersion ! , verificationVersion : item . cargoVerificationVersion ?? null })) }))
})
const cargoProblems = computed (() => {
const problems : string [] = []
if ( ! customerItems . value . length || customerItems . value . length > 100 || cargoContexts . value . length !== customerItems . value . length ) return [ '本客户来源范围未完整读取' ]
if ( new Set ( customerItems . value . map ( item => JSON . stringify ([ item . sourceKey , item . destinationKey ]))). size !== customerItems . value . length ) problems . push ( '本客户存在重复来源,请核对原批次' )
customerItems . value . forEach (( item , index ) => {
const row = cargoContexts . value [ index ] ! , source = row . source , record = row . verification
if ( source . sourceKey !== item . sourceKey || source . customerId !== customer . value || source . destinationKey !== item . destinationKey || source . originWarehouseId !== context . value ? . batch . originWarehouseId || record && record . destinationKey !== item . destinationKey ) problems . push ( '货物身份、起运仓或目的地与本批不一致' )
if ( row . sourceChanged || record && record . sourceVersion !== source . sourceVersion ) problems . push ( '原货源已变化,请先重新核实资料' )
if ( ! source . sourceVersion || record && ( ! Number . isSafeInteger ( record . version ) || record . version < 1 )) problems . push ( '最新货物资料版本不完整' )
if ( item . packageCount == null || item . packageCount <= 0 || row . effectivePackages == null || ! Number . isSafeInteger ( row . effectivePackages ) || row . effectivePackages <= 0 || row . availablePackages == null || row . availablePackages < 0 || row . unknownAllocationCount !== 0 || ! Number . isSafeInteger ( row . allocatedPackages ) || row . allocatedPackages < 0 ) problems . push ( '本批箱数、最新核实量或分配量待核实' )
if ( row . effectivePackages != null && ( row . allocatedPackages > row . effectivePackages || ( item . packageCount ?? 0 ) > row . effectivePackages )) problems . push ( '已分配量或本批箱数超过最新核实量' )
problems . push (... row . blockReasons , ... source . blockReasons )
})
return [... new Set ( problems )]
})
const memberKey = ( member : Pick < ReceivingMember , ' batchKey ' | ' customerId ' >) => JSON . stringify ([ member . batchKey , member . customerId ])
const selectedBookMembers = computed (() => bookMembers . value . filter ( member => selectedMemberKeys . value . includes ( memberKey ( member ))))
const bookingRows = computed (() => ! selectedBookMembers . value . length ? [] : separate . value ? selectedBookMembers . value . map ( member => ({ member , index : bookMembers . value . findIndex ( row => memberKey ( row ) === memberKey ( member )) })) : [{ member : null , index : 0 }])
const selectedExecutionAllowed = computed (() => !! selectedExecution . value ? . version && selectedBookMembers . value . every ( member => selectedExecution . value ! . members . some ( row => memberKey ( row . member ) === memberKey ( member ) && row . canRebook && ! row . completed )))
const bookingScopeChanged = computed (() => JSON . stringify ( bookingMemberKeys . value ) !== JSON . stringify ( bookMembers . value . map ( memberKey )))
const currentTrips = computed (() => [... new Set (( group . value ? . transport || context . value ? . transport ) ? . vehicles . map ( vehicle => vehicle . tripNo ). filter (( number ) : number is string => !! number ) || [])])
const laterBatch = computed (() => result . value ? . batches . find ( batch => result . value ? . steps . some ( step => step . step === 'ALLOCATE_CARGO' && step . status === 'SUCCESS' && step . targetKey === batch . batchKey )))
const canSubmit = computed (() => {
if ( loading . value || error . value || saving . value || confirming . value || result . value || ! context . value ) return false
if ( pending . value ) return pending . value . kind === 'group' && pending . value . request . action === 'CANCEL' ? remove . value : pending . value . kind === 'propose' || pending . value . kind === 'resolve' ? edit . value && remove . value : edit . value
if ( ! confirmedScope . value ) return false
if ( mode . value === 'refresh' ) return edit . value && refreshEligible ( customer . value ) && ! cargoLoading . value && ! cargoError . value && ! cargoProblems . value . length
if ( mode . value === 'plan' ) return edit . value && planEligible ( customer . value )
if ( mode . value === 'customer' ) return edit . value && remove . value && context . value . allowedActions . includes ( 'PROPOSE_CUSTOMER_CHANGE' )
if ( mode . value === 'resolve' ) return edit . value && remove . value && change . value ? . status === 'WAITING'
if ( mode . value === 'cancel' ) return remove . value && group . value ? . agreement . status === 'ACTIVE' && ! group . value . transport . blockReasons . length
if ( mode . value === 'reschedule' ) return edit . value && group . value ? . agreement . status === 'ACTIVE' && ! group . value . transport . blockReasons . length
return mode . value === 'book' && edit . value && !! selectedBookMembers . value . length && ! bookingScopeChanged . value && ! scopeLoading . value && ! scopeError . value && selectedExecutionAllowed . value && ( group . value ? group . value . agreement . status === 'CANCELLED' : context . value . allowedActions . includes ( 'BOOK_RECEIVING' ))
})
async function load () {
if ( ! readable . value ) return
loading . value = true ; error . value = null
try {
const next = await getReceivingContext ( props . batchKey )
const nextGroup = groupKey . value ? await getReceivingGroup ( groupKey . value ) : null
context . value = next ; group . value = nextGroup
if ( nextGroup && initializedGroup !== nextGroup . agreement . agreementKey ) {
Object . assign ( groupForm , createAppointmentForm (), Object . fromEntries ( Object . entries ( nextGroup . agreement . details ). map (([ key , value ]) => [ key , value ?? '' ])))
if ( mode . value === 'book' ) initializeBookings ()
initializedGroup = nextGroup . agreement . agreementKey ; baseline . value = state ()
}
if ( change . value ) change . value = next . pendingChanges . find ( row => row . changeKey === change . value ? . changeKey ) || null
if ( mode . value === 'book' && ! result . value ) await refreshSelectedScope ()
if ( mode . value === 'refresh' && ! result . value ) await loadCargo ()
} catch ( cause ) { error . value = getApiErrorDetails ( cause , '预约覆盖范围读取失败' ) }
finally { loading . value = false }
}
function resetForm () { ++ cargoGeneration ; cargoLoading . value = false ; cargoContexts . value = []; cargoError . value = null ; ++ scopeGeneration ; selectedExecution . value = null ; scopeLoading . value = false ; scopeError . value = null ; evidence . value = '' ; confirmedScope . value = false ; result . value = null ; saveError . value = null ; separate . value = false }
async function overview () { if ( ! await canLeave ()) return ; mode . value = 'overview' ; groupKey . value = null ; group . value = null ; change . value = null ; resetForm (); baseline . value = state (); await load () }
async function adjust ( id : string ) {
if ( locked . value || ! await canLeave ()) return
resetForm (); groupKey . value = null ; group . value = null ; customer . value = id ; mode . value = 'customer'
adjustment . retained = Object . fromEntries ( customerItems . value . filter ( item => item . destinationKey ). map ( item => [ sourceKey ({ sourceKey : item . sourceKey , destinationKey : item . destinationKey ! }), item . packageCount == null ? '' : item . packageCount ]))
adjustment . laterDate = '' ; adjustment . ownerId = owners . value [ 0 ] ? . id || '' ; adjustment . followup = '' ; adjustment . reason = '' ; baseline . value = state ()
}
async function openCargoRefresh ( id : string ) {
if ( locked . value || ! refreshEligible ( id ) || ! await canLeave ()) return
resetForm (); groupKey . value = null ; group . value = null ; customer . value = id ; mode . value = 'refresh' ; baseline . value = state (); await loadCargo ()
}
async function loadCargo () {
const generation = ++ cargoGeneration ; confirmedScope . value = false ; cargoContexts . value = []; cargoError . value = null ; cargoLoading . value = false
if ( ! refreshReadable . value || pending . value ) return
if ( ! customerItems . value . length || customerItems . value . length > 100 || customerItems . value . some ( item => ! item . destinationKey )) { cargoError . value = { message : '本客户来源或目的地不完整,不能采用资料' }; return }
cargoLoading . value = true
try {
const rows = await Promise . all ( customerItems . value . map ( item => getCargoVerification ({ sourceKey : item . sourceKey , customerId : customer . value , destinationKey : item . destinationKey ! })))
if ( generation === cargoGeneration ) cargoContexts . value = rows
} catch ( cause ) { if ( generation === cargoGeneration ) cargoError . value = getApiErrorDetails ( cause , '最新货物核实资料读取失败' ) }
finally { if ( generation === cargoGeneration ) cargoLoading . value = false }
}
async function openPlan ( id : string ) {
if ( locked . value || ! planEligible ( id ) || ! await canLeave ()) return
resetForm (); groupKey . value = null ; group . value = null ; customer . value = id ; mode . value = 'plan'
const saved = customerPlan ( id )
const header = context . value ! . batch
2026-10-21 23:20:10 +08:00
const inherited = ! context . value ! . agreements . some ( agreement => agreement . status === 'ACTIVE' ) && [ 'PENDING' , 'FAILED' ]. includes ( header . appointmentStatus || '' ) ? createAppointmentForm ( header ) : { ... createAppointmentForm (), appointmentStatus : 'PENDING' as const }
2026-10-16 15:41:56 +08:00
Object . assign ( planForm , createAppointmentForm (), saved ? Object . fromEntries ( Object . entries ( saved . details ). map (([ key , value ]) => [ key , value ?? '' ])) : { ... inherited , confirmationNo : '' , appointmentAt : '' , appointmentEndAt : '' , startOffset : '' , endOffset : '' })
baseline . value = state ()
}
async function resume ( pendingChange : ReceivingPending ) { if ( locked . value || ! await canLeave ()) return ; resetForm (); change . value = pendingChange ; mode . value = 'resolve' ; baseline . value = state () }
function initializeBookings () { bookingMemberKeys . value = bookMembers . value . map ( memberKey ); selectedMemberKeys . value = [... bookingMemberKeys . value ]; bookingForms . value = bookMembers . value . map (() => ({ ... createAppointmentForm (), appointmentStatus : 'SUCCESS' })); bookingEvidence . value = bookMembers . value . map (() => '' ); confirmedScope . value = false ; baseline . value = state () }
async function resetBookingScope () { if ( await canLeave ()) { initializeBookings (); await refreshSelectedScope () } }
async function openGroup ( key : string , next : 'book' | 'reschedule' | 'cancel' ) {
if ( locked . value || ! await canLeave ()) return
resetForm (); groupKey . value = key ; group . value = null ; initializedGroup = null ; mode . value = next ; await load ()
baseline . value = state ()
}
async function newBooking () { if ( locked . value || ! await canLeave ()) return ; resetForm (); groupKey . value = null ; group . value = null ; mode . value = 'book' ; initializeBookings (); await refreshSelectedScope () }
async function refreshSelectedScope () {
const generation = ++ scopeGeneration
confirmedScope . value = false ; selectedExecution . value = null ; scopeError . value = null ; scopeLoading . value = false
if ( mode . value !== 'book' || ! readable . value || ! selectedBookMembers . value . length || bookingScopeChanged . value ) return
const members = selectedBookMembers . value . map (({ batchKey , customerId }) => ({ batchKey , customerId }))
scopeLoading . value = true
try {
const scope = await getReceivingExecutionScope ( members )
if ( generation === scopeGeneration ) selectedExecution . value = scope
} catch ( cause ) { if ( generation === scopeGeneration ) scopeError . value = getApiErrorDetails ( cause , '所选客户执行资格读取失败' ) }
finally { if ( generation === scopeGeneration ) scopeLoading . value = false }
}
onBeforeUnmount (() => { ++ scopeGeneration ; ++ cargoGeneration })
function validation ( message : string ) : never { throw new Error ( message ) }
function confirmedDetails ( form : ReturnType < typeof createAppointmentForm >) { const invalid = appointmentFormError ( form ); if ( invalid ) validation ( invalid ); if ( form . appointmentStatus !== 'SUCCESS' ) validation ( '此处登记仓库正式确认;尚未确认请使用本客户的“保存目标 / 未约成”入口' ); return appointmentPayload ( form ) }
function buildCommand ( decision : 'CONFIRM' | 'WITHDRAW' ) : Command {
const current = context . value ! , idempotencyKey = crypto . randomUUID ()
if ( mode . value === 'refresh' ) {
return { kind : 'refresh' , request : { idempotencyKey , member : { batchKey : props . batchKey , batchVersion : current . batch . version , customerId : customer . value }, executionVersion : current . executionScope . version , sources : cargoContexts . value . map ( row => ({ sourceKey : row . source . sourceKey , destinationKey : row . source . destinationKey , expectedSourceVersion : row . source . sourceVersion ! , expectedVerificationVersion : row . verification ? . version ?? null })) } }
}
if ( mode . value === 'plan' ) {
if ( ! [ 'PENDING' , 'FAILED' ]. includes ( planForm . appointmentStatus )) validation ( '客户计划只能保存等待确认或未约成' )
if ( planForm . confirmationNo || planForm . appointmentAt || planForm . appointmentEndAt ) validation ( '客户计划不能写入正式确认' )
if (( planForm . plannedDate || planForm . plannedAt || planForm . plannedEndAt ) && ! planTimezone . value ) validation ( '目的仓时区待核实,请先补齐;日期未定时可保存联系结果' )
if ( planForm . plannedEndAt && ! planForm . plannedAt ) validation ( '填写结束时段前请填写计划开始时段' )
if ( planForm . startOffset && ! planForm . plannedAt || planForm . endOffset && ! planForm . plannedEndAt ) validation ( '仅填写对应计划时段时才可填写时差;仅日期或未定日期请清空时差' )
for ( const offset of [ planForm . startOffset , planForm . endOffset ]) if ( offset . trim () && ! /^[+-](?:0\d|1[0-7]):[0-5]\d$|^[+-]18:00$/ . test ( offset . trim ())) validation ( '计划时差请按 +08:00 或 -07:00 格式填写' )
const invalid = appointmentFormError ( planForm ); if ( invalid ) validation ( invalid )
return { kind : 'plan' , request : { idempotencyKey , member : { batchKey : props . batchKey , batchVersion : current . batch . version , customerId : customer . value }, details : appointmentPayload ( planForm ), executionVersion : current . executionScope . version } }
}
if ( mode . value === 'customer' ) {
if ( ! owners . value . some ( person => person . id === adjustment . ownerId ) || ! adjustment . reason . trim ()) validation ( '请选择有效负责人并填写调整原因' )
const quantities = customerItems . value . map ( item => {
if ( ! item . destinationKey || ! item . sourceVersion || item . packageCount == null ) validation ( '本票数量或来源版本待核实,请先核实货物' )
const raw = adjustment . retained [ sourceKey ({ sourceKey : item . sourceKey , destinationKey : item . destinationKey ! })]
if ( raw === '' || raw == null ) validation ( '请填写每票本次先送箱数,全部移出可填 0' )
const retainedPackages = Number ( raw )
if ( ! Number . isSafeInteger ( retainedPackages ) || retainedPackages < 0 || retainedPackages > item . packageCount ! ) validation ( '先送箱数须为 0 至本票当前箱数之间的整数' )
return { sourceKey : item . sourceKey , destinationKey : item . destinationKey ! , retainedPackages , expectedSourceVersion : item . sourceVersion ! , expectedVerificationVersion : item . cargoVerificationVersion ?? null }
})
if ( quantities . every (( q , index ) => q . retainedPackages === customerItems . value [ index ] ! . packageCount )) validation ( '本次先送量未改变,无需提交调整' )
return { kind : 'propose' , request : { idempotencyKey , intent : { batchKey : props . batchKey , batchVersion : current . batch . version , customerId : customer . value , quantities , laterDate : adjustment . laterDate || null , ownerId : adjustment . ownerId , nextFollowupDate : adjustment . followup || null , reason : adjustment . reason . trim (), transportVersion : current . transport . version } } }
}
if ( mode . value === 'resolve' ) {
if ( ! change . value ) validation ( '待确认记录已变化,请刷新后核对' )
if ( decision === 'CONFIRM' && ! evidence . value . trim ()) validation ( '请填写仓库同意本次保留量和移出量的回复依据' )
return { kind : 'resolve' , key : change . value ! . changeKey , request : { idempotencyKey , version : change . value ! . version , decision , warehouseEvidence : evidence . value . trim () || null , transportVersion : current . transport . version } }
}
if ( mode . value === 'book' ) {
const selected = selectedBookMembers . value
const groups = separate . value ? selected . map ( member => [ member ]) : [ selected ]
return { kind : 'book' , request : { idempotencyKey , cancelledAgreementKey : group . value ? . agreement . agreementKey || null , cancelledAgreementVersion : group . value ? . agreement . version ?? null , executionVersion : selectedExecution . value ! . version , bookings : groups . map (( members , index ) => {
const formIndex = bookingRows . value [ index ] ! . index
const note = bookingEvidence . value [ formIndex ] ? . trim (); if ( ! note ) validation ( '请逐份填写仓库确认覆盖客户和货量的依据' )
return { members : members . map (({ batchKey , batchVersion , customerId }) => ({ batchKey , batchVersion , customerId })), details : confirmedDetails ( bookingForms . value [ formIndex ] ! ), warehouseEvidence : note ! }
}) } }
}
if ( ! group . value || ! evidence . value . trim ()) validation ( '请核对共同预约范围并填写仓库回复依据' )
return { kind : 'group' , key : group . value ! . agreement . agreementKey , request : { idempotencyKey , version : group . value ! . agreement . version , action : mode . value === 'cancel' ? 'CANCEL' : 'RESCHEDULE' , details : mode . value === 'cancel' ? null : confirmedDetails ( groupForm ), warehouseEvidence : evidence . value . trim (), transportVersion : group . value ! . transport . version } }
}
async function save ( decision : 'CONFIRM' | 'WITHDRAW' = 'CONFIRM' ) {
if ( ! canSubmit . value ) return
if ( ! pending . value ) {
try {
const command = buildCommand ( decision )
if ( command . kind === 'group' && command . request . action === 'CANCEL' ) {
confirming . value = true
2026-10-20 23:34:11 +08:00
try { await ElMessageBox . confirm ( '确认取消当前展示的整份预约?货物需求与批次保留,车辆需重新核对。' , '取消整份预约' , { customClass : 'admin-density-confirm admin-workbench is-dialog' , confirmButtonText : '确认取消' , cancelButtonText : '返回核对' }) } catch { return } finally { confirming . value = false }
2026-10-16 15:41:56 +08:00
}
pending . value = command
} catch ( cause ) { saveError . value = { message : cause instanceof Error ? cause . message : '请核对本次范围' }; return }
}
saving . value = true ; saveError . value = null
try {
const command = pending . value
result . value = command . kind === 'refresh' ? await refreshReceivingCargo ( command . request ) : command . kind === 'plan' ? await saveReceivingPlan ( command . request ) : command . kind === 'propose' ? await proposeReceivingChange ( command . request ) : command . kind === 'resolve' ? await resolveReceivingChange ( command . key , command . request ) : command . kind === 'book' ? await bookReceiving ( command . request ) : await changeReceivingGroup ( command . key , command . request )
pending . value = null ; baseline . value = state (); confirmedScope . value = false ; emit ( 'updated' ); await load ()
} catch ( cause ) { saveError . value = getApiErrorDetails ( cause , '结果待确认,请重试本次提交' ); if ( rejectedCommand ( cause )) { pending . value = null ; confirmedScope . value = false ; await load () } }
finally { saving . value = false }
}
async function openBatch ( key : string ) { if ( await canLeave ()) emit ( 'batch' , key ) }
onMounted ( async () => { await load (); if ( props . initialCustomerId && customers . value . includes ( props . initialCustomerId )) await adjust ( props . initialCustomerId ) })
</ script >
< template >
< section class = "work-form" aria -label =" 客户调整与共同预约 " >
< h3 >{{ context ? . batch . appointmentNo || '预约批次' }} · 客户调整与共同预约 </ h3 >
< p v-if = "!readable" > 当前账号没有预约列表读取权限 。 </ p >
< LoadErrorState v-if = "error" title="预约覆盖范围读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState >
< p v-if = "loading" > 正在核对货物 、 预约覆盖与车辆 … </ p >
< template v-if = "context" >
< el-button v-if = "mode !== 'overview' || result" :disabled="locked" @click="overview" > 返回范围概览 </ el -button >
< p v-for = "reason in context.blockReasons" :key="reason" role="status" >{{ reason }}</ p >
< section v-if = "result" aria-label="本次处理结果" >
< h4 >{{ result . status === 'WAITING' ? '已保存待确认,原安排尚未改变' : result . status === 'UNCHANGED' ? '安排没有变化' : result . status === 'WITHDRAWN' ? '已撤回变化,原安排保留' : '本次处理已保存' }}</ h4 >
< p v-for = "(step, index) in result.steps" :key="index" >{{ step.message }}</ p >
< p v-if = "result.nextActions.includes('REVIEW_TRANSPORT')" > 下一步 : 核对当前收货时段及车辆安排 ; 本次保存不代表可以发车 。 </ p >
< el-button v-if = "result.nextActions.includes('BOOK_LATER_BATCH') && laterBatch" :type="result.nextActions[0] === 'BOOK_LATER_BATCH' ? 'primary' : 'default'" :disabled="locked" @click="emit('appointment', laterBatch.appointmentNo)" > 继续给后送批次约仓 </ el -button >
< template v-if = "result.nextActions.includes('REVIEW_TRANSPORT')"><el-button v-for="(number, index) in currentTrips" :key="number" :type="index === 0 && result.nextActions[0] === 'REVIEW_TRANSPORT' ? 'primary' : 'default'" :disabled="locked || !access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(number)}`)" > 核对本车发车安排 · {{ number }} </ el -button ></ template >
< el-button v-if = "result.status === 'WAITING' && context.pendingChanges.find(row => row.changeKey === result?.changeKey)" type="primary" :disabled="locked" @click="resume(context.pendingChanges.find(row => row.changeKey === result?.changeKey)!)" > 继续登记仓库回复 </ el -button >
< template v-if = "mode === 'refresh'"><p>本客户资料已处理,可继续保存本客户计划或登记正式预约。</p><el-button :disabled="locked || !planEligible(customer)" @click="openPlan(customer)">继续本客户目标 / 未约成</el-button><el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">核对未确认客户并登记正式预约</el-button></template><p v-if="mode === 'plan'">本客户计划已处理。可返回范围概览核对客户计划,或在本客户事项继续记录回复与跟进。</p><div v-for="batch in (['plan', 'refresh'].includes(mode) ? [] : result.batches).filter(row => row.items.some(item => item.packageCount == null || item.packageCount > 0))" :key="batch.batchKey"><p>{{ batch.appointmentNo }} · {{ batch.plannedDate || '日期未定' }}</p><el-button v-if="batch.batchKey" :disabled="locked" @click="openBatch(batch.batchKey)">查看本批当前去向</el-button><el-button :disabled="locked" @click="emit('appointment', batch.appointmentNo)" > 继续本批预约 </ el -button ></ div >
< el-button v-for = "(agreement, index) in result.agreements.filter(row => row.status === 'CANCELLED')" :key="agreement.agreementKey" :type="index === 0 && result.nextActions[0] === 'REBOOK_GROUP' ? 'primary' : 'default'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')" > 重新预约这组货物 </ el -button >
</ section >
< template v-else-if = "mode === 'overview'" >
< h4 > 各客户计划与货物调整 </ h4 >< p > 先保存待确认变化 , 收到仓库明确回复后再生效 ; 本次先送量允许为 0 。 </ p >
< div v-for = "(id, index) in customers" :key="id"><b>{{ customerName(id) || '—' }} · 客户范围 {{ index + 1 }}</b><p v-for="item in context.batch.items.filter(row => row.customerId === id)" :key="item.id">{{ item.inboundCode || '原票业务号未提供' }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p v-if="customerPlan(id)">本客户计划:{{ customerPlan(id)!.details.plannedDate || '日期未定' }} · {{ customerPlan(id)!.details.appointmentStatus === 'FAILED' ? '未约成' : '等待确认' }} · {{ customerPlan(id)!.details.appointmentRemark || '—' }}</p><el-button :disabled="locked || !edit || !refreshEligible(id)" @click="openCargoRefresh(id)">核对并采用本客户最新货物资料</el-button><el-button :disabled="locked || !edit || !planEligible(id)" @click="openPlan(id)">保存目标 / 未约成</el-button><el-button :disabled="locked || !edit || !remove || !context.allowedActions.includes('PROPOSE_CUSTOMER_CHANGE')" @click="adjust(id)" > 调整本客户先送 / 后送 </ el -button ></ div >
< h4 > 待仓库回复 </ h4 >< div v-for = "pendingChange in context.pendingChanges.filter(row => row.status === 'WAITING')" :key="pendingChange.changeKey"><p>{{ pendingChange.intent.reason }} · 负责人 {{ pendingChange.followup.ownerName || '—' }} · 跟进 {{ pendingChange.followup.nextFollowupDate || '待安排' }}</p><el-button :disabled="locked" @click="resume(pendingChange)" > 继续本次变化 </ el -button ></ div >
< h4 > 整份预约范围 </ h4 >< div v-for = "agreement in context.agreements" :key="agreement.agreementKey"><p>{{ agreement.details.confirmationNo || '约号未提供' }} · {{ agreement.status === 'ACTIVE' ? '有效' : agreement.status === 'CANCELLED' ? '已取消' : '历史' }} · {{ agreement.details.appointmentAt || '时段未定' }} → {{ agreement.details.appointmentEndAt || '结束未定' }} · {{ agreement.timezone || '时区待核实' }}</p><ReceivingScope :members="agreement.members" /><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'reschedule')">核对整份改期范围</el-button><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'cancel')">核对整份取消范围</el-button><el-button v-if="agreement.status === 'CANCELLED'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')" > 重新预约这组货物 </ el -button ></ div >
< ReceivingScope :members = "[]" :transport = "context.transport" />
< el-button : disabled = "locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click ="newBooking" > 登记当前未覆盖货物的正式预约 </ el -button >
</ template >
2026-10-21 07:40:08 +08:00
< form v-else class = "density-form appointment-native-form" @submit.prevent ="save()" >
2026-10-16 15:41:56 +08:00
< fieldset : disabled = "locked || !formWritable" >
< template v-if = "mode === 'refresh'" >
< h4 >{{ customerName ( customer ) || '—' }} · 核对本客户最新货物资料 </ h4 >< p > 采用后仅更新本客户的资料快照 , 本批箱数和其他客户的确认保持不变 ; 保存核实资料不会自动执行采用 。 </ p >
< p v-if = "cargoLoading" > 正在读取本客户各票最新核实资料 … </ p >
< LoadErrorState v-if = "cargoError" title="最新货物核实资料读取失败" :message="cargoError.message" :loading="cargoLoading" @retry="loadCargo" />
< div v-for = "(row, index) in cargoContexts" :key="index" >< b >{{ row . source . inboundCode || row . source . bookingNo || '—' }}</ b >< p > 本批 {{ customerItems [ index ] ? . packageCount ?? '待核实' }} 箱 · 最新核实 {{ row . verification ? . packageCount ?? '待核实' }} 箱 · 当前有效 {{ row . effectivePackages ?? '待核实' }} 箱 · 已分配 {{ row . allocatedPackages }} 箱 </ p >< p > 最新货件号 : {{ row . verification ? . shipmentReferences . join ( '、' ) || '—' }}</ p ></ div >
< p v-for = "problem in cargoProblems" :key="problem" role="status" >{{ problem }}</ p >
< el-button : disabled = "locked || cargoLoading" @click ="loadCargo" > 重新读取最新资料 </ el -button >
</ template >
< template v-if = "mode === 'plan'" >
2026-10-20 15:48:27 +08:00
< h4 >{{ customerName ( customer ) || '—' }} · 本客户普通计划 < InlineHelp title = "客户计划说明" >< p > 目标日期可留空 ; 清空日期和时段会清除本客户目标 , 不取消货物需求 。 负责人及下次跟进请在本客户事项的 “ 记录回复与跟进 ” 维护 。 </ p ></ InlineHelp ></ h4 >
2026-10-16 15:41:56 +08:00
< p v-for = "item in customerItems" :key="item.id" >{{ item.inboundCode || '—' }} · {{ item.packageCount ?? ' 箱数待核实 ' }}</ p >
< p >{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划 , 其他客户的确认保持不变 。 </ p >
< p v-if = "!planEligible(customer)" role="status" > 本客户资格已变化 , 暂不能保存普通计划 , 请返回范围概览核对 。 </ p >
2026-10-20 23:34:11 +08:00
< label > 目标送仓日期 < el-date-picker v-model = "planForm.plannedDate" type="date" aria-label="客户计划日期" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></ label >
< label > 计划开始时段 < el-date-picker v-model = "planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime" aria-label="客户计划开始" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></ label >
< label > 计划结束时段 < el-date-picker v-model = "planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime" aria-label="客户计划结束" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></ label >
2026-10-21 07:40:08 +08:00
< details >< summary > 计划时段的夏令时信息 </ summary >< p > 当地时钟回拨导致时刻重复时 , 请按仓库确认填写对应时差 。 跳时期间不存在的当地时刻不可用 , 请选择有效时间 。 </ p >< label > 计划开始时差 < input v-model = "planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00" class="workbench-input"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00" class="workbench-input" ></ label ></ details >
< label > 本次联系结果 < select v-model = "planForm.appointmentStatus" aria-label="客户计划状态" class="workbench-input"><option value="PENDING">等待对方确认</option><option value="FAILED" > 未约成 </ option ></ select ></ label >
< label >{{ planForm . appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}< textarea v-model = "planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" class="workbench-input" /></ label >
2026-10-20 15:48:27 +08:00
2026-10-16 15:41:56 +08:00
</ template >
2026-10-21 07:40:08 +08:00
< template v-if = "mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数" class="workbench-input"></label></div><label>后送目标日期<el-date-picker v-model="adjustment.laterDate" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人" class="workbench-input"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<el-date-picker v-model="adjustment.followup" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" class="workbench-input" /></ label >< p > 保存仅登记待确认变化 , 原箱数与原确认仍保留 , 相关发车暂受阻 。 </ p ></ template >
< template v-if = "mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" class="workbench-input" /></ label ></ template >
2026-10-16 15:41:56 +08:00
< template v-if = "group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status" > 排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }} : {{ excluded.reason }}</ div ></ template >
2026-10-21 07:40:08 +08:00
< template v-if = "mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}: {{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}: {{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" class="workbench-input" /></ label ></ template ></ template >
< template v-if = "mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" class="workbench-input" /></ label ></ template >
2026-10-16 15:41:56 +08:00
< label >< input v-model = "confirmedScope" type="checkbox" :disabled="mode === 'book' && (scopeLoading || !!scopeError || !selectedExecutionAllowed) || mode === 'refresh' && (cargoLoading || !!cargoError || !!cargoProblems.length)" aria-label="确认当前范围" > 已核对上述客户货物 、 当前预约与车辆范围 </ label >
</ fieldset >
2026-10-21 07:40:08 +08:00
< div class = "density-actions density-actions--footer appointment-form-actions" >< el-button type = "primary" native -type =" submit " :disabled = "!canSubmit" :loading = "saving" >{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</ el-button ></ div >
2026-10-16 15:41:56 +08:00
< el-button v-if = "mode === 'resolve' && !pending" :disabled="!canSubmit" @click="save('WITHDRAW')" > 撤回变化并保留原安排 </ el -button >
</ form >
< LoadErrorState v-if = "saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></ template ></ LoadErrorState >
</ template >
</ section >
</ template >