2026-10-15 17:11:03 +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-15 17:11:03 +08:00
import { computed , onMounted , reactive , ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getAppointmentBatch , repartitionAppointments , allocation , allocationKey , mergeBatchSources , splitBatch , type FbaAppointmentBatchContext , type RepartitionFbaAppointmentsRequest , type RepartitionFbaAppointmentsResponse } from './batchApi'
import { getWorkItems , type WorkPage } from './workApi'
import { rejectedCommand , useWorkDraft } from './useWorkDraft'
import CargoVerificationForm from './CargoVerificationForm.vue'
import type { CargoVerificationSource } from './cargoVerificationApi'
const props = defineProps < { batchKey : string } > ()
const emit = defineEmits < { updated : []; appointment : [ appointmentNo : string ] } > ()
const access = useAccessPreviewStore (), activeKey = ref ( props . batchKey ), context = ref < FbaAppointmentBatchContext | null >( null ), other = ref < FbaAppointmentBatchContext | null >( null )
const mode = ref < 'split' | 'merge' > ( 'split' ), loading = ref ( false ), saving = ref ( false ), ready = ref ( false )
const error = ref < ApiErrorDetails | null >( null ), saveError = ref < ApiErrorDetails | null >( null ), pending = ref < RepartitionFbaAppointmentsRequest | null >( null ), result = ref < RepartitionFbaAppointmentsResponse | null >( null )
const form = reactive ({ firstDate : '' , nextDate : '' , reason : '' , remark : '' , moved : {} as Record < string , number | string > })
const baseline = ref ( JSON . stringify ( form )), verification = ref < CargoVerificationSource | null >( null )
const canLeave = useWorkDraft ( computed (() => baseline . value !== JSON . stringify ( form )), computed (() => saving . value || !! pending . value ))
const candidates = ref < WorkPage | null >( null ), candidatePage = ref ( 1 ), keyword = ref ( '' ), candidateLoading = ref ( false ), candidateError = ref < ApiErrorDetails | null >( null )
let candidateRequest = 0
2026-10-16 15:41:56 +08:00
let initializedKey : string | null = null
2026-10-15 17:11:03 +08:00
const canWrite = computed (() => access . can ( 'platform:overseas-warehouse:inbound-order:fba-appointment' ))
2026-10-16 15:41:56 +08:00
const replaced = computed (() => !! context . value ? . appointment . replacementBatchKeys ? . length || !! context . value ? . appointment . replacementAppointmentNos ? . length )
const hasCurrentDestinations = computed (() => replaced . value || context . value ? . currentBatches . some ( batch => batch . batchKey !== context . value ? . appointment . batchKey ))
const currentDestinations = computed (() => {
if ( ! context . value ) return []
const sourceIdentity = ( item : FbaAppointmentBatchContext [ 'appointment' ][ 'items' ][ number ]) => JSON . stringify ([ item . customerId , item . sourceKey , item . destinationKey ])
const originalSources = new Set ( context . value . originalSourceRefs . map ( allocationKey ))
return context . value . currentBatches . map ( appointment => ({ appointment , items : appointment . items . filter ( item => item . destinationKey && originalSources . has ( sourceIdentity ( item )) && ( item . packageCount == null || item . packageCount > 0 )) })). filter ( row => row . items . length )
})
function bookingStatus ( status : string ) { return ({ PENDING : '待预约' , SUCCESS : '已确认预约' , FAILED : '未约成' , CANCELLED : '已取消,分配保留' } as Record < string , string >)[ status ] || '预约状态待核实' }
async function openAppointment ( number : string ) { if ( await canLeave ()) emit ( 'appointment' , number ) }
2026-10-15 17:11:03 +08:00
const canSubmit = computed (() => canWrite . value && ready . value && ! loading . value && ! error . value && !! context . value ? . allowedActions . includes ( mode . value === 'split' ? 'SPLIT' : 'MERGE' ) && ( mode . value !== 'merge' || !! other . value ? . allowedActions . includes ( 'MERGE' )))
async function load ( preserve = false ) {
loading . value = true ; ready . value = false ; error . value = null
try {
context . value = await getAppointmentBatch ( activeKey . value )
if ( other . value ? . appointment . batchKey ) other . value = await getAppointmentBatch ( other . value . appointment . batchKey )
2026-10-16 15:41:56 +08:00
if ( ! preserve || initializedKey !== activeKey . value ) {
2026-10-15 17:11:03 +08:00
Object . assign ( form , { firstDate : context . value . appointment . plannedDate || '' , nextDate : '' , reason : '' , remark : '' , moved : Object . fromEntries ( context . value . sources . map ( source => [ allocationKey ( source ), 0 ])) })
baseline . value = JSON . stringify ( form )
2026-10-16 15:41:56 +08:00
initializedKey = activeKey . value
2026-10-15 17:11:03 +08:00
}
ready . value = true
} catch ( cause ) { error . value = getApiErrorDetails ( cause , '批次资料读取失败' ) }
finally { loading . value = false }
}
async function searchCandidates ( page = 1 ) {
const request = ++ candidateRequest ; candidatePage . value = page ; candidates . value = null ; candidateError . value = null ; candidateLoading . value = true
try { const rows = await getWorkItems ({ view : 'ALL' , types : [ 'DELIVERY' ], keyword : keyword . value . trim () || undefined , page , size : 8 }); if ( request === candidateRequest ) candidates . value = rows }
catch ( cause ) { if ( request === candidateRequest ) candidateError . value = getApiErrorDetails ( cause , '可合并工作事项读取失败' ) }
finally { if ( request === candidateRequest ) candidateLoading . value = false }
}
async function chooseOther ( key : string ) {
if ( ! context . value || pending . value || saving . value ) return
candidateLoading . value = true ; candidateError . value = null
try {
const selected = await getAppointmentBatch ( key )
if ( ! selected . allowedActions . includes ( 'MERGE' )) throw new Error ( selected . blockReasons . join ( '; ' ) || '该批次当前不允许合并' )
mergeBatchSources ([ context . value , selected ]); other . value = selected
} catch ( cause ) { candidateError . value = getApiErrorDetails ( cause , '批次不能合并' ) }
finally { candidateLoading . value = false }
}
async function changeMode ( next : 'split' | 'merge' ) {
if ( ! await canLeave ()) return
mode . value = next ; other . value = null ; result . value = null ; await load ()
if ( next === 'merge' ) await searchCandidates ()
}
function sourceLabel ( source : CargoVerificationSource ) {
const item = context . value ? . appointment . items . find ( item => item . customerId === source . customerId && item . sourceKey === source . sourceKey )
return item ? . inboundCode || '原票业务号未提供'
}
async function verify ( source : CargoVerificationSource ) { if ( await canLeave ()) { baseline . value = JSON . stringify ( form ); verification . value = source } }
async function verified () { verification . value = null ; await load (); emit ( 'updated' ) }
2026-10-16 15:41:56 +08:00
async function openBatch ( key : string ) { if ( ! await canLeave ()) return ; activeKey . value = key ; context . value = null ; result . value = null ; other . value = null ; mode . value = 'split' ; await load () }
2026-10-15 17:11:03 +08:00
async function save () {
if ( ! context . value || ! canSubmit . value || saving . value ) return
if ( ! pending . value ) {
try {
if ( ! form . reason . trim ()) { ElMessage . warning ( '请填写分批或合并原因' ); return }
const contexts = mode . value === 'merge' && other . value ? [ context . value , other . value ] : [ context . value ]
const allocations = mode . value === 'split' ? splitBatch ( context . value , form . moved ) : [ mergeBatchSources ( contexts ). map ( source => allocation ( source , Number ( source . packageCount )))]
pending . value = { originals : contexts . map ( row => ({ appointmentNo : row . appointment . appointmentNo , version : row . appointment . version })), batches : allocations . map (( rows , index ) => ({ plannedDate : ( index === 0 ? form . firstDate : form . nextDate ) || null , remark : form . remark . trim () || null , allocations : rows })), reason : form . reason . trim (), idempotencyKey : crypto . randomUUID () }
} catch ( cause ) { saveError . value = getApiErrorDetails ( cause , '请核对逐票分配数量' ); return }
}
saving . value = true ; saveError . value = null
try {
result . value = await repartitionAppointments ( pending . value ); pending . value = null ; baseline . value = JSON . stringify ( form )
ElMessage . success ( mode . value === 'split' ? '已分为两个待安排批次' : '已合并为一个待安排批次' ); emit ( 'updated' )
} catch ( cause ) { saveError . value = getApiErrorDetails ( cause , '调整结果待确认,请重试本次提交' ); if ( rejectedCommand ( cause )) { pending . value = null ; await load ( true ) } }
finally { saving . value = false }
}
onMounted (() => load ())
</ script >
< template >
< section class = "work-form" aria -label =" 预约分批合并 " >
< CargoVerificationForm v-if = "verification" :key="`${verification.customerId}:${verification.sourceKey}`" :source="verification" @saved="verified" />
< template v-else >
< h3 >{{ context ? . appointment . appointmentNo || '预约批次' }} · 分批与合并 </ h3 >
< LoadErrorState v-if = "error" title="批次资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState >
< p v-if = "loading" > 正在核对批次 、 来源和核实版本 … </ p >
< template v-if = "result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)" > 继续分批 </ el -button ></ div ></ template >
< template v-else-if = "context" >
< p >{{ context . appointment . fbaWarehouseCode }} · {{ context . appointment . items . length }} 条货物来源 ; 客户与原来源逐票保留 。 </ p >
2026-10-16 15:41:56 +08:00
< template v-if = "hasCurrentDestinations" >
< p v-if = "replaced" > 该批次已被替代 , 原货物快照仅供追溯 。 {{ context.appointment.repartitionReason }}</ p >
< p v-else > 本批仍有保留货物 , 以下同时列出服务端返回的当前后送去向 。 </ p >
< section aria -label =" 当前货物去向 " >
< h4 > 当前货物去向 </ h4 >< p > 以下仅展示原批次客户 、 来源及目的仓对应的当前分配 ; 合并批次可能还含其他货物 , 进入完整批次后可查看 。 </ p >
< article v-for = "destination in currentDestinations" :key="destination.appointment.batchKey" class="allocation-row" >
< strong >{{ destination . appointment . appointmentNo }}</ strong >< p >{{ bookingStatus ( destination . appointment . appointmentStatus ) }} · {{ destination . appointment . plannedDate || '日期未定' }}</ p >
< div v-for = "item in destination.items" :key="item.id" >< p >{{ item . inboundCode || '原票业务号未提供' }} · {{ item . fbaWarehouseCode }} · {{ item . packageCount == null ? '箱数待核实' : ` ${ item . packageCount } 箱` }}</ p >< p > 货件 : {{ item . shipmentReferences ? . join ( '、' ) || '待核实' }}</ p ></ div >
< el-button : disabled = "loading || !!error || saving || !!pending" @click ="openAppointment(destination.appointment.appointmentNo)" > 查看本批预约 </ el -button >
< el-button v-if = "destination.appointment.batchKey" :disabled="loading || !!error || saving || !!pending" @click="openBatch(destination.appointment.batchKey)" > 查看完整批次 </ el -button >
</ article >
< p v-if = "!currentDestinations.length && !loading && !error" > 当前没有与原货物范围匹配的分配 ; 原快照不代表当前待办 。 </ p >
</ section >
</ template >
2026-10-15 17:11:03 +08:00
< p v-for = "reason in context.blockReasons" :key="reason" role="status" >{{ reason }}</ p >
< div v-if = "!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null" > 箱 </ span ></ p >< p > 货件 : {{ item . shipmentReferences ? . join ( '、' ) || '待核实' }}</ p ></ div ></ div >
< div class = "batch-modes" >< el-button : disabled = "saving || !!pending || !context.allowedActions.includes('SPLIT')" @click ="changeMode('split')" > 分成两次送仓 </ el -button >< el-button : disabled = "saving || !!pending || !context.allowedActions.includes('MERGE')" @click ="changeMode('merge')" > 同仓合并安排 </ el -button ></ div >
2026-10-21 07:40:08 +08:00
< form class = "density-form appointment-native-form" @submit.prevent ="save" >< fieldset : disabled = "saving || !!pending || loading || !canWrite" >
< div v-for = "(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`" class="workbench-input"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)" > 核实本票资料 </ el -button ></ div >
< template v-if = "mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()" class="workbench-input"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other" > 已核对 : {{ other.appointment.appointmentNo }} , {{ other.sources.length }} 条来源 ; 提交时再次逐来源校验 。 </ p ></ template >
< label >{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}< el-date-picker v-model = "form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" class="workbench-input" /></ label >
</ fieldset >< LoadErrorState v-if = "saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result" >{{ pending ? ' 重试本次调整 ' : mode = == ' split ' ? ' 拆分为两批预约安排 ' : ' 合并为一批继续预约 ' }} </ el -button ></ div ></ form >
2026-10-15 17:11:03 +08:00
</ template >
</ template >
</ section >
</ template >
2026-10-20 23:34:11 +08:00
< style scoped >. allocation - row , . batch - result , . batch - candidate { padding : var ( -- admin - space - 3 ) 0 ; border - bottom : 1 px solid var ( -- workbench - border ); overflow - wrap : anywhere ; }. batch - modes , . batch - result , . batch - candidate { display : flex ; align - items : center ; flex - wrap : wrap ; gap : var ( -- admin - space - 3 ); }. batch - candidate > span { flex : 1 ; }</ style >