发布采购、拼车、预约及匹配端侧修复至灰度 #19

Merged
admin merged 55 commits from release/test into main 2026-10-12 10:20:54 +08:00
156 changed files with 7503 additions and 895 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+25 -11
View File
@@ -3,9 +3,9 @@
"testOnly": true,
"deployment": {
"admin": {
"build": 531,
"build": 536,
"result": "SUCCESS",
"commit": "2caeb12"
"commit": "73c29f5"
},
"firstleg": {
"build": 488,
@@ -40,7 +40,13 @@
"stale_update_conflict",
"stale_delete_conflict",
"delete_and_calendar_absence",
"rebook_after_delete"
"rebook_after_delete",
"two_new_customer_accounts_10_negative_isolation_checks",
"nonempty_customer_75_rows_all_owned",
"nonempty_customer_forged_filter_stays_scoped",
"own_plan_detail_visible_other_plan_hidden",
"existing_customer_platform_appointment_forbidden",
"browser_deleted_appointment_absence_http_confirmed"
],
"uiPassed": [
"1920x1080_month",
@@ -51,7 +57,11 @@
"two_customer_selection",
"cross_page_selection_retained",
"create_saved",
"final_month_top_alignment"
"final_month_top_alignment",
"final_week_top_alignment",
"delete_confirmation_and_active_dispatch_rejection",
"month_week_three_previews_233_total_title",
"browser_successful_delete_two_to_one"
],
"initialCalendarRequests": [
{
@@ -65,14 +75,18 @@
"durationMs": 106
}
],
"pending": [
"merchant_identity_isolation: configured login returned HTTP401 A0401",
"week_alignment_final_browser_recheck",
"browser_delete_confirmation_flow"
],
"pending": [],
"screenshots": [
"month-five-customers.png",
"week-five-customers.png",
"final-month.png"
]
"final-month.png",
"compact-month.png",
"compact-week.png",
"delete-success.png"
],
"scope": {
"admin": "appointment calendar and inbound appointment consumer",
"client": "no page or code change; merchant inbound API positive and negative isolation verified",
"remainingSample": "QA-UI-20261009-2CUSTOMERS"
}
}
@@ -0,0 +1 @@
{"repo": "html/admin-web", "number": 18, "url": "http://8.163.30.128:10082/html/admin-web/pulls/18", "head": "73c29f5264e46f648713c58b469a8c7bd335cd9c", "branch": "release/appointment-gray-20261009"}
@@ -0,0 +1 @@
f9dd30b8b9c6b756c4502e4efc33df4ddb59e566
@@ -0,0 +1 @@
{"repo": "java-project/baiyue-firstleg", "number": 25, "url": "http://8.163.30.128:10082/java-project/baiyue-firstleg/pulls/25", "head": "7fd7699f6e83e77e487aceff914d160cf42376a0", "branch": "release/appointment-gray-20261009"}
@@ -0,0 +1 @@
f60f44793f289f7612b561f1c470e093f67f4e32
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

@@ -0,0 +1,30 @@
[
{
"name": "permissions",
"http": 200,
"code": "200",
"count": 358,
"elapsedMs": 115
},
{
"name": "arrivals",
"http": 200,
"code": "200",
"count": 10,
"elapsedMs": 297
},
{
"name": "appointments",
"http": 200,
"code": "200",
"count": 0,
"elapsedMs": 287
},
{
"name": "inboundCandidates",
"http": 200,
"code": "200",
"count": 10,
"elapsedMs": 221
}
]
@@ -0,0 +1 @@
[{"job": "baiyue-firstleg-prod-lastleg-service", "queue": "https://jenkins.buggoglobal.com/queue/item/7294/", "number": 13, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "baiyue-firstleg-prod-firstleg", "queue": "https://jenkins.buggoglobal.com/queue/item/7295/", "number": 14, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "admin-web-prod", "queue": "https://jenkins.buggoglobal.com/queue/item/7300/", "number": 26, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}]
@@ -0,0 +1,32 @@
{
"scope": [
"admin-web",
"firstleg",
"lastleg-service"
],
"databaseAuthorized": true,
"schemaPreflight": {
"appointmentRows": 0,
"itemRows": 0,
"permissionConflicts": 0,
"adminRoleCount": 1,
"affectedAdminSubjects": 1
},
"targets": {
"backend": "7fd7699f6",
"admin": "73c29f5"
},
"sourceEquivalence": {
"backendMergeTreeMatchesTestedSource": true,
"adminMergeTreeMatchesTestedSource": true
},
"sqlExecuted": true,
"deployed": true,
"pending": null,
"releaseBranchesDeleted": [
"backend",
"admin"
],
"browserVerified": true,
"grayWriteCrudRetested": false
}
@@ -0,0 +1,35 @@
-- REVIEW ONLY: gray appointment schema and platform administrator permissions.
-- Execute only with explicit gray SQL authorization, after verifying both appointment tables remain empty.
-- No DROP TABLE, DELETE, TRUNCATE, or business-data backfill.
-- DDL auto-commits; stop immediately on any error. Do not use mysql --force.
USE baiyue_lastleg;
ALTER TABLE ll_inbound_fba_appointment
DROP INDEX uk_ll_fba_appointment_no,
DROP INDEX idx_ll_fba_appointment_list,
DROP COLUMN customer_id,
ADD COLUMN appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空' AFTER appointment_at,
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '并发版本' AFTER appointment_end_at,
ADD UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
ADD KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code);
ALTER TABLE ll_inbound_fba_appointment_item
ADD COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID' AFTER appointment_id,
ADD KEY idx_ll_fba_appointment_item_cargo (line_id, appointment_id);
USE baiyue_iam;
START TRANSACTION;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0),
(5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0);
INSERT INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r CROSS JOIN iam_permission p
WHERE r.id=4000001 AND r.tenant_id=0 AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
AND p.id IN (5107280,5107281);
INSERT INTO iam_security_subject_version (auth_user_id,permission_version,updated_at)
SELECT DISTINCT u.auth_user_id,1,NOW(3)
FROM iam_membership_role mr
JOIN iam_membership m ON m.id=mr.membership_id AND m.tenant_id=mr.tenant_id
JOIN iam_user u ON u.id=m.user_id
WHERE mr.tenant_id=0 AND mr.role_id=4000001 AND m.deleted=0 AND u.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1,updated_at=NOW(3);
COMMIT;
@@ -0,0 +1,5 @@
ll_inbound_fba_appointment 13
ll_inbound_fba_appointment_item 9
platform:appointment:list
platform:appointment:delete
2
+56
View File
@@ -242,6 +242,62 @@ describe('admin auth session', () => {
expect(localStorage.getItem('cc-admin-token')).toBeNull()
})
it('rejects an old refresh after another tab has switched to a different account', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'admin-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
let finishRefresh!: (value: ReturnType<typeof loginResponse>) => void
mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve }))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const listeners = vi.spyOn(window, 'addEventListener')
const auth = useAuthStore()
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
listeners.mockRestore()
const refreshing = auth.refreshSession()
await Promise.resolve()
const salesToken = token({ user_id: '2000002', username: 'sales' })
localStorage.setItem('cc-admin-token', salesToken)
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
expect(auth.user?.account).toBe('sales')
finishRefresh(loginResponse(token(), 'admin-rotated'))
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.user?.account).toBe('sales')
expect(auth.token).toBe(salesToken)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
})
it('discards late permission results after another tab switches accounts without clearing the new session', async () => {
mocks.post.mockResolvedValue(loginResponse())
let finishPermissions!: (value: string[]) => void
let permissionsStarted!: () => void
const started = new Promise<void>(resolve => { permissionsStarted = resolve })
mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => {
permissionsStarted()
return new Promise(resolve => { finishPermissions = resolve })
})
const listeners = vi.spyOn(window, 'addEventListener')
const auth = useAuthStore()
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
listeners.mockRestore()
const login = auth.login('platform_admin', 'password')
await started
const salesToken = token({ user_id: '2000002', username: 'sales' })
localStorage.setItem('cc-admin-token', salesToken)
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
finishPermissions(['platform:warehouse:list'])
await expect(login).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.user?.account).toBe('sales')
expect(auth.token).toBe(salesToken)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
expect(mocks.post).toHaveBeenCalledTimes(1)
})
it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => {
const previous = token()
const rotated = token({ token_version: 2 })
+4
View File
@@ -144,6 +144,10 @@ export const useAuthStore = defineStore('admin-auth', () => {
try {
const session = sessionFromToken(storedToken)
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
// A different account in another tab supersedes this tab's pending login/refresh responses.
if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) {
sessionGeneration += 1
}
token.value = storedToken
refreshToken.value = storedRefreshToken
user.value = session.user
+1 -1
View File
@@ -23,7 +23,7 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
+1 -1
View File
@@ -135,7 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
{ key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
{ key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
},
@@ -10,11 +10,50 @@ describe('appointment calendar interactions', () => {
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
})
it('opens a readable appointment without exposing write actions', async () => {
const wrapper = render()
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
await wrapper.setProps({events:[event]})
const actions=wrapper.get('.detail-event footer')
expect(actions.text()).toBe('查看预约')
await actions.get('button').trigger('click')
expect(wrapper.emitted('view')?.[0]).toEqual([event])
expect(wrapper.emitted('edit')).toBeUndefined()
wrapper.unmount()
})
it('hides optional references by default and counts only appointments after enabling them', async () => {
const wrapper = render()
await wrapper.setProps({ events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] },
{ key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] },
{ key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] },
] })
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
const switches = wrapper.get('[aria-label="参考图层"]').findAll('input')
await switches[0]!.setValue(true); await switches[1]!.setValue(true)
expect(wrapper.findAll('.detail-event')).toHaveLength(3)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约')
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('selects an already loaded date without requesting data again', async () => {
const wrapper = render()
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
expect(wrapper.emitted('range')).toBeUndefined()
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无到港或送仓预约')
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
})
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
const wrapper = render()
await wrapper.setProps({ events: [
{ key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] },
{ key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] },
] })
expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true)
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓')
expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('requests exactly one range for a view switch and navigates seven days', async () => {
@@ -31,6 +70,7 @@ describe('appointment calendar interactions', () => {
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
title: `船舶 ${index}`, fields: [],
})) })
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
for (const view of ['month', 'week']) {
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
@@ -46,8 +86,22 @@ describe('appointment calendar interactions', () => {
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === '删除')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
})
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
const wrapper = render()
await wrapper.setProps({ events: [{
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
}] })
const card = wrapper.get('.detail-event')
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
expect(card.get('.inbound-codes').text()).toContain('20 个')
expect(card.get('footer').text()).toContain('编辑')
})
})
@@ -1,47 +1,65 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, occursOnDate, type CalendarMode } from './calendar'
export interface CalendarEvent {
key: string
date: string
endAt?: string
time: string
type: 'arrival' | 'delivery'
type: 'arrival' | 'delivery' | 'pickup' | 'trip'
title: string
subtitle?: string
fields: Array<{ label: string; value?: string | null }>
inboundCodes?: Array<string | null | undefined>
canEdit?: boolean
canView?: boolean
canDelete?: boolean
sourcePath?: string
}
const props = withDefaults(defineProps<{
notices?: string[]
events: CalendarEvent[]
loading?: boolean
incomplete?: boolean
canCreate?: boolean
initialDate?: string
}>(), { loading: false, canCreate: false, initialDate: '' })
}>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
const emit = defineEmits<{
layers: [layers: { arrivals: boolean; trips: boolean }]
range: [range: { from: string; to: string }]
create: [date: string]
edit: [event: CalendarEvent]
view: [event: CalendarEvent]
delete: [event: CalendarEvent]
open: [event: CalendarEvent]
}>()
const showArrivals = ref(false), showTrips = ref(false)
function updateLayers() { emit('layers', { arrivals: showArrivals.value, trips: showTrips.value }) }
const visibleEvents = computed(() => props.events.filter(event => (event.type !== 'arrival' || showArrivals.value) && (event.type !== 'trip' || showTrips.value)))
const selectedDate = ref(props.initialDate || dateKey(new Date()))
const mode = ref<CalendarMode>('month')
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
const grouped = computed(() => {
const result = new Map<string, CalendarEvent[]>()
for (const event of props.events) {
const group = result.get(event.date) || []
group.push(event)
result.set(event.date, group)
for (const event of visibleEvents.value) {
for (const date of dates.value) {
if (!occursOnDate(event.date, event.endAt, date)) continue
const group = result.get(date) || []
group.push(event)
result.set(date, group)
}
}
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
return result
})
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
const appointmentCount = computed(() => selectedEvents.value.filter(event => event.type === 'delivery' || event.type === 'pickup').length)
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
const heading = computed(() => mode.value === 'month'
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
@@ -66,9 +84,9 @@ defineExpose({ selectedDate, mode })
<div class="calendar-label">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<strong>预约日历</strong>
<span class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span>
</div>
<div class="calendar-controls">
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
<div class="view-toggle" role="group" aria-label="预约视图">
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
@@ -80,14 +98,18 @@ defineExpose({ selectedDate, mode })
<el-button @click="select(dateKey(new Date()))">今天</el-button>
</div>
</header>
<div class="calendar-options">
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
<div class="calendar-layers" role="group" aria-label="参考图层"><label><input v-model="showArrivals" type="checkbox" @change="updateLayers">显示到港参考</label><label><input v-model="showTrips" type="checkbox" @change="updateLayers">显示用车计划</label></div>
</div>
<div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
<button v-for="date in dates" :key="date" type="button" class="calendar-day"
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
:title="`${date},共 ${(grouped.get(date) || []).length} 条安排`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排`" :aria-pressed="selectedDate === date" @click="select(date)">
:title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)">
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
<span class="day-events">
<span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天共 ${(grouped.get(date) || []).length} 条安排`">
<span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`">
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
</span>
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
@@ -96,16 +118,19 @@ defineExpose({ selectedDate, mode })
</div>
</div>
<aside class="calendar-detail" aria-label="当天安排">
<header><strong>{{ selectedDate }}</strong><p>当天共有 <b>{{ selectedEvents.length }}</b> 项安排</p></header>
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ appointmentCount }}</b> 项预约<span v-if="selectedEvents.length > appointmentCount">,{{ selectedEvents.length - appointmentCount }} 条参考计划</span><span v-if="incomplete">(当前范围不完整)</span></p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">当天暂无到港或送仓预约</p>
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ event.type === 'arrival' ? '船舶到港' : '预约送仓' }}</span><span>{{ event.time || '—' }}</span></div>
<div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<footer v-if="event.canEdit || event.canDelete" class="density-actions">
<dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
<footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">删除</el-button>
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
</footer>
</article>
</div>
@@ -114,50 +139,65 @@ defineExpose({ selectedDate, mode })
</template>
<style scoped>
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 2.5fr) minmax(320px, 1fr); gap: 16px; color: #344054; }
.calendar-data-notices { position: relative; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-data-notices summary { cursor: pointer; padding: var(--admin-space-2) var(--admin-space-3); }
.calendar-notice-content { position: absolute; top: 100%; right: 0; z-index: 5; width: 320px; padding: var(--admin-space-5); border: 1px solid var(--admin-table-border); border-radius: var(--admin-radius); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); }
.calendar-notice-content p { margin: 0; line-height: 1.6; }
.calendar-notice-content p + p { margin-top: var(--admin-space-3); }
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: #344054; }
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; padding: 16px; border-bottom: 1px solid #edf0f6; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
.calendar-label > svg { color: #617ce9; }
.calendar-label > strong { font-size: 16px; }
.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
.calendar-legend, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.calendar-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-1); cursor: pointer; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-layers input { margin: 0; accent-color: var(--el-color-primary); }
.legend { font-size: 12px; white-space: nowrap; }
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
.legend.delivery::before { background: #27a87a; }
.legend.pickup::before { background: #c17a20; }
.legend.trip::before { background: #8562b0; }
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-period { font-size: 14px; white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: 12px 14px; font-size: 13px; }
.calendar-grid { gap: 7px; padding: 12px; background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 128px; min-width: 0; padding: 8px; border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 112px; min-width: 0; padding: var(--admin-space-2); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
.calendar-day:hover { border-color: #bac8ed; background: #f8faff; }
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.day-number { display: block; font-size: 12px; font-weight: 600; }
.day-events { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
.event-chip { display: block; padding: 3px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
.event-chip { display: block; padding: 1px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
.trip { --event-bg: #f2edfa; --event-fg: #725199; }
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
.event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; }
.event-more { padding: 1px 6px; line-height: 16px; }
.event-more, .empty-day { color: #667085; font-size: 12px; }
.week-grid .calendar-day { height: 220px; }
.calendar-detail { align-self: start; }
.calendar-detail > header { padding: 18px; border-bottom: 1px solid #edf0f6; }
.calendar-detail > header > strong { font-size: 17px; }
.calendar-detail header p { margin: 10px 0 0; font-size: 13px; color: #8491a5; }
.calendar-detail header b { color: #617ce9; font-size: 17px; }
.detail-events { padding: 14px; max-height: 650px; overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: 16px; padding-top: 16px; }
.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
.calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
.detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
.detail-event h3 { font-size: 15px; margin: 14px 0 8px; overflow-wrap: anywhere; }
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 16px 0; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }
.detail-event .wide-field { grid-column: 1 / -1; }
.detail-event dt { font-size: 12px; color: #8491a5; }
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
@@ -1,48 +1,223 @@
import { inject, onBeforeUnmount } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
import { listArrivalCalendar } from './api'
import { createFbaAppointment, getFbaAppointment, updateFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
import { listArrivalCalendar, listPickupCalendar } from './api'
import { listTripCalendar } from './tripCalendar'
vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()}))
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi'
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true, canWrite: true, containerPending: false }))
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() }))
import { listPlatformWarehouses } from '@/modules/warehouses'
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), deleteAppointment: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/overseas-warehouse', () => ({
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'write' || routing.canWrite) && (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()),
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
overseasWarehousePermissions: { inboundOrderAppointment: 'write' },
overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' },
}))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const candidates = [
{ lineId: '9007199254740993', customerId: 'A', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
{ lineId: '9007199254740994', customerId: 'B', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
{ lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
{ lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
]
function render() {
return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
function render(openCalendar = true) {
const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
AppointmentCancellationDialog: {name:'AppointmentCancellationDialog',template:'<div>取消弹窗</div>'},
AppointmentCancellationHistory: {name:'AppointmentCancellationHistory',template:'<div>取消历史详情</div>'},
AppointmentCancellationRecords: {name:'AppointmentCancellationRecords',template:'<button data-history-view @click="$emit(\'view\',\'AP-CANCEL\')">查看取消记录</button>'},
AdminPage: { template: '<main><slot /></main>' },
AppointmentCalendarPanel: { template: '<button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button>' },
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /><button data-save @click="$emit(\'confirm\')">保存</button></div>' },
AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() {
const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')!
const unregister = register(() => !routing.containerPending)
onBeforeUnmount(unregister)
}, template: '<div>整柜跟进工作区</div>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '<div v-if="modelValue"><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { template: '<label><slot /></label>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElDatePicker: { props: ['modelValue'], template: '<input data-date :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElButton: { template: '<button><slot /></button>' },
ElSelect: true, ElOption: true,
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
LoadErrorState: true, ApiErrorMeta: true,
ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' },
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><slot v-for="row in data" name="actions" :row="row"/><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' },
} } })
if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click')
return wrapper
}
describe('appointment calendar data integration', () => {
beforeEach(() => {
vi.resetAllMocks()
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true; routing.canWrite = true; routing.containerPending = false
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 })
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
vi.mocked(listArrivalCalendar).mockResolvedValue([])
vi.mocked(listTripCalendar).mockResolvedValue({records:[],unresolvedRecords:[]})
vi.mocked(listPickupCalendar).mockResolvedValue({records: [], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', appointmentAt: '2026-10-09T09:00:00', expectedLoadingAt: '2026-10-09T07:00:00', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
})
it('opens a dated calendar appointment read-only for list-only users', async () => {
routing.canWrite=false
const record={version:0,id:'1',appointmentNo:'AP-DATED',plannedDate:'2026-10-15',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[]}
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([record])
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render();await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual(expect.arrayContaining([expect.objectContaining({key:'AP-DATED',canView:true,canEdit:false})]))
panel.vm.$emit('view',{key:'AP-DATED'});await flushPromises()
expect(getFbaAppointment).toHaveBeenCalledWith('AP-DATED')
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
expect(wrapper.find('[data-save]').exists()).toBe(false)
wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
})
it('uses the dedicated cancellation dialog for an undated appointment instead of editing its status', async () => {
const record = { version: 3, id: '1', appointmentNo: 'AP-UNDATED', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1})
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render(); await flushPromises()
await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises()
expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false)
await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises()
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).attributes('appointment-no')).toBe('AP-UNDATED')
expect(wrapper.find('[data-save]').exists()).toBe(false)
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => {
const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render();await flushPromises()
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')!
;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle')
await wrapper.get('[data-history-view]').trigger('click');await flushPromises()
expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL')
expect(wrapper.find('[data-save]').exists()).toBe(false)
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(false)
const history=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消历史')!
;(history.element as HTMLDetailsElement).open=true;await history.trigger('toggle')
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(true)
wrapper.findAllComponents({name:'AppDialog'}).at(-1)!.vm.$emit('confirm');await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
})
it('refreshes an expanded cancellation list after a successful cancellation', async () => {
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}])
const wrapper=render();await flushPromises()
const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')!
;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle')
const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm
wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises()
wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises()
expect(wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm).not.toBe(previous)
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).exists()).toBe(false)
wrapper.unmount()
})
it('keeps the container view mounted while a child has an unresolved command', async () => {
const wrapper = render(); await flushPromises()
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
await tabs[1]!.trigger('click'); await flushPromises()
routing.containerPending = true
await tabs[0]!.trigger('click')
expect(tabs[1]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(true)
routing.containerPending = false
await tabs[0]!.trigger('click')
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(false)
wrapper.unmount()
})
it('opens an unscheduled appointment read-only without allowing a save', async () => {
routing.canWrite = false
const record = { version: 0, id: '1', appointmentNo: 'AP-READ', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', items: [] }
vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 })
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises()
expect(wrapper.find('[data-save]').exists()).toBe(false)
const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
wrapper.unmount()
})
it('puts calendar first and opens it by default without reloading when switching back', async () => {
const wrapper = render(false); await flushPromises()
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
expect(tabs.map(button => button.text())).toEqual(['预约日历', '提柜与到仓', '送仓与排车'])
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.text()).not.toContain('美国预约总览')
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
await tabs[1]!.trigger('click'); await flushPromises()
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
await tabs[0]!.trigger('click'); await flushPromises()
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
})
it('distinguishes missing country data from truncation instead of suggesting a narrower date range', async () => {
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [], truncated: false, unclassifiedCount: 74, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('74 条提柜记录')
expect(wrapper.text()).not.toContain('缩小日期范围')
})
it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => {
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
const wrapper=render(); await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})])
expect(panel.props('incomplete')).toBe(true)
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('目的仓国家待核实')
expect(listPickupCalendar).toHaveBeenCalledTimes(1)
})
it('does not request pickup records without pickup list permission and discloses the omitted source', async () => {
routing.canPickup=false
const wrapper=render(); await flushPromises()
expect(listPickupCalendar).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无提柜查看权限')
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('incomplete')).toBe(true)
})
it('keeps successful arrival data visible when the pickup source fails', async () => {
vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable'))
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',bookingNo:'CB-1',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
const wrapper=render(); await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})])
expect(panel.props('incomplete')).toBe(true)
})
it('omits trip requests without trip list permission and keeps other sources usable', async () => {
routing.canTrips=false
const wrapper=render();await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无车次查看权限')
})
it('keeps unresolved trip origins outside daily slots and clearly labels their UTC source time', async () => {
vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]})
const wrapper=render();await flushPromises()
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
expect(wrapper.findAll('.appointment-extra summary').map(node => node.text())).not.toContain('待核实地点的车次(1)')
expect(wrapper.text()).not.toContain('原计划时间保留 UTC')
await wrapper.findAll('button').find(button => button.text() === '查看待核实车次(1)')!.trigger('click')
expect(wrapper.text()).toContain('原计划时间保留 UTC')
expect(listTripCalendar).toHaveBeenCalledTimes(1)
})
it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
const wrapper = render()
await flushPromises()
@@ -50,20 +225,167 @@ describe('appointment calendar data integration', () => {
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
expect(listInboundOrders).not.toHaveBeenCalled()
await wrapper.get('[data-create]').trigger('click')
await wrapper.get('input[placeholder="例如 ONT8"]').setValue('ONT8')
await wrapper.findAll('button').find(button => button.text() === '查询可预约货物')!.trigger('click')
await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises()
await wrapper.get('[data-select]').trigger('click')
await wrapper.get('input[placeholder="请输入预约编号"]').setValue('APT-AB')
await wrapper.findAll('[data-date]')[2]!.setValue('2026-10-09T07:00:00')
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
await wrapper.get('[data-save]').trigger('click')
await flushPromises()
expect(createFbaAppointment).toHaveBeenCalledTimes(1)
expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({
appointmentNo: 'APT-AB', lineIds: ['9007199254740993', '9007199254740994'],
originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['9007199254740993', '9007199254740994'], idempotencyKey: expect.any(String), details: expect.objectContaining({plannedDate: '2026-10-09', appointmentStatus: 'PENDING', appointmentAt: null, confirmationNo: null}),
}))
expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId')
expect(listInboundOrders).toHaveBeenCalledTimes(1)
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2)
})
it('only searches active FBA warehouses and loads cargo automatically after selection', async () => {
const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('查询可预约货物')
expect(wrapper.find('details').attributes('open')).toBeUndefined()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises()
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
})
it('keeps a pending unknown save retry on the same immutable command', async () => {
vi.mocked(createFbaAppointment).mockRejectedValueOnce(new Error('response lost'))
const wrapper = render(); await flushPromises()
await wrapper.get('[data-create]').trigger('click'); await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8'); await flushPromises()
await wrapper.get('[data-select]').trigger('click')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(createFbaAppointment).mock.calls[0]![0]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.find('[data-save]').exists()).toBe(true)
await wrapper.get('[data-create]').trigger('click')
expect((wrapper.get('select[aria-label="预约仓库"]').element as HTMLSelectElement).value).toBe('ONT8')
const done = vi.fn()
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
expect(done).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
expect(guard()).toBe(false)
}
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
expect(first.details.appointmentAt).toBeNull()
expect(first.details.confirmationNo).toBeNull()
expect(first.idempotencyKey).toBeTruthy()
})
it('keeps undated arrangements outside calendar time slots', async () => {
vi.mocked(listUnscheduledAppointments).mockResolvedValueOnce({ records: [{ id:'1', version:0, appointmentNo:'AP-UNSCHEDULED', originWarehouseId:'100', appointmentStatus:'PENDING', selectedCount:2, fbaWarehouseCode:'ONT8', items:[] }], total:1 })
const wrapper=render(); await flushPromises()
expect(wrapper.text()).toContain('未定时安排(1)')
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt')
})
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises()
await wrapper.get('[data-select]').trigger('click')
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
await wrapper.get('select[aria-label="预约仓库"]').setValue('')
await wrapper.get('[data-save]').trigger('click')
expect(createFbaAppointment).not.toHaveBeenCalled()
expect(listInboundOrders).toHaveBeenCalledTimes(1)
expect(wrapper.find('[data-select]').exists()).toBe(false)
})
it('discards cargo responses from the previously selected warehouse', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void
vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
vi.mocked(listInboundOrders).mockResolvedValueOnce({ records: [], total: 0, page: 1, size: 10, pages: 0 })
await wrapper.get('select[aria-label="预约仓库"]').setValue('LAX9')
await flushPromises()
resolveOld({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
})
it('searches by keyword and exposes warehouse failures without accepting free text', async () => {
const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click')
await flushPromises()
const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))!
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('仓库暂不可用'))
await select.props('remoteMethod')(' LAX ')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' }))
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('仓库加载失败')
expect(wrapper.findAll('select[aria-label="预约仓库"] option')).toHaveLength(1)
expect(listInboundOrders).not.toHaveBeenCalled()
})
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
const wrapper = render()
await flushPromises()
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('incomplete')).toBe(true)
expect(calendar.props('events')).toHaveLength(1)
expect(calendar.props('events')[0].type).toBe('arrival')
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('送仓预约加载失败')
})
it('keeps delivery appointments when the arrival source fails', async () => {
vi.mocked(listArrivalCalendar).mockRejectedValueOnce(new Error('到港暂不可用'))
vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }])
const wrapper = render()
await flushPromises()
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('incomplete')).toBe(true)
expect(calendar.props('events')[0].key).toBe('APT-AB')
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('船舶到港加载失败')
})
it('resolves an external booking deep link through the backend before opening followup', async () => {
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }
vi.mocked(resolvePlanningSource).mockResolvedValueOnce({ source: { sourceKey: 'BOOKING:B-1', customerId: 'A', orderId: null, inboundCode: null, bookingNo: 'B-1', containerNo: null, originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: 'BOOKING:1', canFollowup: true, blockReasons: [] }, coverage: { status: 'SOURCE_NOT_MATERIALIZED', appointments: [], missingFields: ['REAL_CARGO_MATERIALS'], changeReviewRequired: false } })
const wrapper = render(); await flushPromises()
expect(resolvePlanningSource).toHaveBeenCalledWith(routing.query)
const dialog = wrapper.findComponent({ name: 'AppointmentFollowupDialog' })
expect(dialog.props('source').bookingNo).toBe('B-1')
expect(dialog.props('source').orderId).toBeNull()
expect(dialog.props('coverage').status).toBe('SOURCE_NOT_MATERIALIZED')
expect(createFbaAppointment).not.toHaveBeenCalled(); expect(saveFollowup).not.toHaveBeenCalled()
expect(dialog.props('canArrange')).toBe(true)
dialog.vm.$emit('arrange');await flushPromises()
expect(wrapper.findComponent({name:'AppointmentFollowupDialog'}).exists()).toBe(false)
expect(wrapper.findComponent({name:'AppointmentCargoPanel'}).props('externalSource')).toMatchObject({sourceKey:'BOOKING:B-1',bookingNo:'B-1',orderId:null})
})
it('does not trust stale destination query data or open followup after a source error', async () => {
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:OLD' }
vi.mocked(resolvePlanningSource).mockRejectedValueOnce(new Error('目的地已变化'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('预约货源核对失败')
})
it('does not request deep-link cargo without the underlying source permission', async () => {
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }; routing.canSourceRead = false
const wrapper = render(); await flushPromises()
expect(resolvePlanningSource).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
})
})
@@ -1,7 +1,10 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -12,18 +15,69 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import {
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
overseasWarehousePermissions as permission,
type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto,
FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError,
type FbaAppointmentDto, type FbaAppointmentDetailsPayload, type InboundOrderSummaryDto,
} from '@/modules/overseas-warehouse'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import { calendarRange, dateKey } from './calendar'
import { deleteAppointment, listArrivalCalendar, type ArrivalCalendarEntry } from './api'
import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar'
const cancellationTarget = ref<FbaAppointmentDto | null>(null)
const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false)
const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment))
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const linkedSource = ref<ResolvedPlanningSource | null>(null)
const selectedSource = ref<PlanningSource | null>(null)
const linkError = ref<ApiErrorDetails | null>(null)
const linkLoading = ref(false)
let linkRequest = 0
const childLeaveGuards = new Set<() => boolean>()
provide('appointment-register-leave-guard', (guard: () => boolean) => {
childLeaveGuards.add(guard)
return () => { childLeaveGuards.delete(guard) }
})
function switchWorkView(next: 'calendar' | 'cargo' | 'containers') {
if (canLeave()) workView.value = next
}
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
const calendarVisited = ref(true)
const showTripLayer = ref(false)
const cargoVisited = ref(false)
watch(workView, value => {
if (value === 'cargo') cargoVisited.value = true
if (value === 'calendar' && !calendarVisited.value) { calendarVisited.value = true; void load(); void loadUnscheduled() }
})
const appointments = ref<FbaAppointmentDto[]>([])
const arrivals = ref<ArrivalCalendarEntry[]>([])
const pickups = ref<PickupCalendarEntry[]>([])
const pickupNotice = ref('')
const tripEvents = ref<CalendarEvent[]>([])
const unresolvedTrips = ref<TripCalendarEntry[]>([])
const tripNotice = ref('')
const unresolvedDialogOpen = ref(false)
const unresolvedPage = ref(1), unresolvedSize = ref(10)
const unresolvedRows = computed(() => unresolvedTrips.value.slice((unresolvedPage.value - 1) * unresolvedSize.value, unresolvedPage.value * unresolvedSize.value))
const unresolvedColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 110, fixed: 'left' },
{ prop: 'tripNo', label: '车次号', minWidth: 210 },
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 170 },
{ prop: 'plannedDepartureAt', label: '原计划出发(UTC)', minWidth: 225 },
{ key: 'issues', label: '待核实资料', slotName: 'issues', minWidth: 230 },
]
const range = ref(calendarRange(dateKey(new Date()), 'month'))
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const sourceErrors = ref<Array<{ title: string; details: ApiErrorDetails }>>([])
const errorTitle = ref('预约日历加载失败')
const dialogError = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
@@ -32,6 +86,12 @@ const detailLoading = ref(false)
const detailReady = ref(false)
const editing = ref<FbaAppointmentDto | null>(null)
const warehouse = ref('')
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const warehouseKeyword = ref('')
const warehouseTotal = ref(0)
let warehouseRequest = 0
const candidates = ref<InboundOrderSummaryDto[]>([])
const selected = ref<InboundOrderSummaryDto[]>([])
const candidateLoading = ref(false)
@@ -45,17 +105,27 @@ let calendarController: AbortController | undefined
let calendarRequest = 0
let candidateRequest = 0
let detailRequest = 0
const form = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'PENDING' as BatchFbaAppointmentPayload['appointmentStatus'], appointmentRemark: '' })
const statusLabels: Record<string, string> = { PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' }
const form = reactive(createAppointmentForm())
const pendingSave = ref<{ appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: FbaAppointmentDetailsPayload; idempotencyKey: string } | null>(null)
const unscheduled = ref<FbaAppointmentDto[]>([]), unscheduledTotal = ref(0), unscheduledPage = ref(1), unscheduledSize = ref(10)
const unscheduledError = ref<ApiErrorDetails | null>(null)
const unscheduledColumns: DataTableColumn[] = [{key:'actions',label:'操作',slotName:'actions',width:100,fixed:'left'},{prop:'appointmentNo',label:'系统预约单号',minWidth:200},{prop:'fbaWarehouseCode',label:'目的仓',width:100},{prop:'selectedCount',label:'票数',width:80}]
let unscheduledRequest = 0
async function loadUnscheduled() {
const current = ++unscheduledRequest; unscheduledError.value = null
try { const result = await listUnscheduledAppointments(unscheduledPage.value, unscheduledSize.value); if(current === unscheduledRequest) {unscheduled.value = result.records; unscheduledTotal.value = result.total} }
catch(cause) {if(current === unscheduledRequest) unscheduledError.value = getApiErrorDetails(cause, '未定时安排加载失败')}
}
const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' }
const columns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
{ prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ prop: 'customerName', label: '客户', minWidth: 150 },
{ prop: 'inboundCode', label: '入仓码', minWidth: 220, singleLine: false, showOverflowTooltip: false },
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 180, singleLine: false, showOverflowTooltip: false },
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150 },
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150, singleLine: false, showOverflowTooltip: false },
{ prop: 'packageCount', label: '箱数', width: 80 },
{ prop: 'palletCount', label: '托数', width: 80 },
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160 },
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160, singleLine: false, showOverflowTooltip: false },
]
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
@@ -68,43 +138,102 @@ const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival =>
{ label: '柜次号', value: arrival.bookingNo },
{ label: '货物去向', value: arrival.destinationWarehouseCode },
],
})), ...pickups.value.map(pickup => ({
key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const,
date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16),
title: `${pickup.containerNo || pickup.bookingNo || '未关联柜号'} 预约提柜`,
sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`,
subtitle: '美国目的仓相关提柜安排',
fields: [
{ label: '柜次号', value: pickup.bookingNo }, { label: '目的港', value: pickup.destinationPortName },
{ label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName },
{ label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' },
{ label: '时间口径', value: '原提柜业务时间,港口时区待核实' },
],
})), ...appointments.value.map(appointment => ({
key: appointment.appointmentNo, type: 'delivery' as const, date: appointment.appointmentAt.slice(0, 10),
time: appointment.appointmentAt.slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} 预约送仓`,
key: appointment.appointmentNo, type: 'delivery' as const, date: (appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '').slice(0, 10),
endAt: (appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) || undefined,
time: (appointment.appointmentAt || appointment.plannedAt || '').slice(11, 16) + ((appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) ? `–${(appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt)!.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} ${appointment.appointmentStatus === 'SUCCESS' ? '已确认送仓' : '计划送仓'}`,
fields: [
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
{ label: '预约编号', value: appointment.appointmentNo },
{ label: '系统单号', value: appointment.appointmentNo },
{ label: '外部约号', value: appointment.confirmationNo },
{ label: '地点时区', value: appointment.timezone || '待核实' },
{ label: '完整时段', value: `${appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '待定'}${(appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) ? ` — ${appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt}` : ''}` },
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
{ label: '入仓码', value: appointment.items.map(item => item.inboundCode).filter(Boolean).join('、') },
{ label: '预约备注', value: appointment.appointmentRemark },
],
].filter(field => field.value != null && field.value !== ''),
inboundCodes: appointment.items.map(item => item.inboundCode),
canEdit: access.can(permission.inboundOrderAppointment),
canView: access.can('platform:appointment:list'),
canDelete: access.can('platform:appointment:delete'),
}))])
})), ...tripEvents.value])
async function load(nextRange = range.value) {
unresolvedDialogOpen.value = false
range.value = nextRange
const request = ++calendarRequest
calendarController?.abort()
calendarController = new AbortController()
loading.value = true
loadError.value = null
sourceErrors.value = []
errorTitle.value = '预约日历加载失败'
appointments.value = []
arrivals.value = []
pickups.value = []
tripEvents.value = []; unresolvedTrips.value = []; unresolvedPage.value = 1
const canReadPickup = access.can('platform:firstleg:terminal-drayage:list')
const canReadTrips = access.can('platform:fleet:trip:list')
pickupNotice.value = canReadPickup ? '' : '无提柜查看权限,当前日历不包含提柜预约。'
tripNotice.value = canReadTrips ? '' : '无车次查看权限,当前日历不包含用车计划。'
try {
const [result, arrivalResult] = await Promise.all([
const [result, arrivalResult, pickupResult, tripResult] = await Promise.allSettled([
listFbaAppointmentCalendar(nextRange, calendarController.signal),
listArrivalCalendar(nextRange, calendarController.signal),
canReadPickup ? listPickupCalendar(nextRange, calendarController.signal) : Promise.resolve(null),
canReadTrips ? listTripCalendar(nextRange, calendarController.signal) : Promise.resolve(null),
])
if (request === calendarRequest) { appointments.value = result; arrivals.value = arrivalResult }
if (request !== calendarRequest) return
if (result.status === 'fulfilled') appointments.value = result.value
else sourceErrors.value.push({ title: '送仓预约加载失败', details: getApiErrorDetails(result.reason, '送仓预约加载失败') })
if (arrivalResult.status === 'fulfilled') arrivals.value = arrivalResult.value
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
if (pickupResult.status === 'fulfilled' && pickupResult.value) {
pickups.value = pickupResult.value.records
pickupNotice.value = [pickupResult.value.truncated ? '提柜安排超出读取上限,请缩小日期范围。' : '', Number(pickupResult.value.unclassifiedCount) > 0 ? `有 ${Number(pickupResult.value.unclassifiedCount)} 条提柜记录的目的仓国家待核实,请完善仓库资料后重新核对;这些记录未计入当前范围。` : ''].filter(Boolean).join(' ')
} else if (pickupResult.status === 'rejected') sourceErrors.value.push({ title: '提柜预约加载失败', details: getApiErrorDetails(pickupResult.reason, '提柜预约加载失败') })
if (tripResult.status === 'fulfilled' && tripResult.value) {
const projection = projectTripCalendar(tripResult.value, nextRange)
tripEvents.value = projection.events; unresolvedTrips.value = projection.unresolved
if (projection.unresolved.length) tripNotice.value = '部分车次无法确定起运地点或当地日期,已单列待核实,未计入每日安排。'
} else if (tripResult.status === 'rejected') sourceErrors.value.push({ title: '用车计划加载失败', details: getApiErrorDetails(tripResult.reason, '用车计划加载失败') })
} catch (error) {
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
} finally { if (request === calendarRequest) loading.value = false }
}
function openSource(event: CalendarEvent) {
if (event.sourcePath) void router.push(event.sourcePath)
}
function canLeave() {
if (saving.value || pendingSave.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return [...childLeaveGuards].every(guard => guard())
}
function beforeCloseAppointment(done: () => void) { if (canLeave()) done() }
function closeAppointment() { if (canLeave()) dialogOpen.value = false }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
function openCreate(date: string) {
if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return
forceReadOnly.value = false; cancellationHistoryOpen.value = false
++candidateRequest
++detailRequest
detailLoading.value = false
candidateLoading.value = false
candidatePage.value = 1
editing.value = null
warehouse.value = ''
searchedWarehouse.value = ''
@@ -114,16 +243,19 @@ function openCreate(date: string) {
candidateTotal.value = 0
candidateError.value = null
dialogError.value = null
Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' })
Object.assign(form, createAppointmentForm(), { plannedDate: date }); pendingSave.value = null
dialogOpen.value = true
detailReady.value = true
void searchWarehouses('')
}
async function openEdit(event: CalendarEvent) {
async function openEdit(event: Pick<CalendarEvent, 'key'>, readOnly = false) {
if (!canLeave()) return
forceReadOnly.value = readOnly; cancellationHistoryOpen.value = false
const request = ++detailRequest
dialogError.value = null
editing.value = appointments.value.find(item => item.appointmentNo === event.key) || null
if (!editing.value) return
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
pendingSave.value = null
dialogOpen.value = true
detailLoading.value = true
detailReady.value = false
@@ -133,22 +265,44 @@ async function openEdit(event: CalendarEvent) {
editing.value = detail
detailReady.value = true
warehouse.value = detail.fbaWarehouseCode
Object.assign(form, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt,
expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus,
appointmentRemark: detail.appointmentRemark || '' })
Object.assign(form, createAppointmentForm(detail))
} catch (error) {
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
} finally { if (request === detailRequest) detailLoading.value = false }
}
async function loadCandidates(search = false) {
if (search) {
searchedWarehouse.value = warehouse.value.trim().toUpperCase()
candidatePage.value = 1
selected.value = []
selectionEpoch.value++
}
if (!searchedWarehouse.value) { ElMessage.warning('请先输入目的仓编码'); return }
async function searchWarehouses(keyword: string) {
warehouseKeyword.value = keyword
const request = ++warehouseRequest
warehouseLoading.value = true
warehouseError.value = null
warehouseOptions.value = []
warehouseTotal.value = 0
try {
const result = await listPlatformWarehouses({ page: 1, size: 50, keyword: keyword.trim() || undefined, warehouseType: 'FBA', status: 'ACTIVE' })
if (request !== warehouseRequest || !dialogOpen.value) return
warehouseOptions.value = result.records.filter(item => item.warehouseType === 'FBA' && item.status === 'ACTIVE')
warehouseTotal.value = result.total
} catch (error) {
if (request === warehouseRequest) warehouseError.value = getApiErrorDetails(error, '仓库搜索失败,请重试')
} finally { if (request === warehouseRequest) warehouseLoading.value = false }
}
function changeWarehouse(value: string) {
++candidateRequest
candidateLoading.value = false
candidates.value = []
selected.value = []
candidateTotal.value = 0
candidateError.value = null
candidatePage.value = 1
selectionEpoch.value++
searchedWarehouse.value = value
if (value) void loadCandidates()
}
async function loadCandidates() {
if (!searchedWarehouse.value) return
candidateLoading.value = true
candidateError.value = null
const request = ++candidateRequest
@@ -166,12 +320,14 @@ async function loadCandidates(search = false) {
}
async function save() {
if (saving.value || detailLoading.value || !detailReady.value) return
if (!form.appointmentNo.trim() || !form.appointmentAt || !form.expectedLoadingAt) {
ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return
}
if (appointmentReadOnly.value || saving.value || detailLoading.value || !detailReady.value) return
const validation = appointmentFormError(form)
if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
if (!editing.value) {
if (!warehouse.value || warehouse.value !== searchedWarehouse.value || candidateLoading.value || candidateError.value) { ElMessage.warning('请先选择仓库并等待货物加载完成'); return }
if (selected.value.some(item => item.fbaWarehouseCode !== warehouse.value)) { ElMessage.warning('请重新选择当前仓库的货物'); return }
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
if (selected.value.some(item => !item.overseasWarehouseId) || new Set(selected.value.map(item => item.overseasWarehouseId)).size !== 1) { ElMessage.warning('请选择同一接仓点的货物'); return }
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
}
@@ -179,14 +335,15 @@ async function save() {
saving.value = true
dialogError.value = null
try {
const common = { appointmentEndAt: form.appointmentEndAt || null, appointmentAt: form.appointmentAt, expectedLoadingAt: form.expectedLoadingAt,
appointmentStatus: form.appointmentStatus, appointmentRemark: form.appointmentRemark.trim() || undefined }
if (editing.value) await updateFbaAppointment(editing.value.appointmentNo, { ...common, version: editing.value.version })
else await createFbaAppointment({ ...common, appointmentNo: form.appointmentNo.trim(), lineIds: selected.value.map(item => item.lineId) })
pendingSave.value ||= {appointmentNo: editing.value?.appointmentNo || null, version: editing.value?.version || 0, originWarehouseId: String(selected.value[0]?.overseasWarehouseId || ''), fbaWarehouseCode: searchedWarehouse.value, lineIds: selected.value.map(item => item.lineId), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID()}
const payload = pendingSave.value
if (payload.appointmentNo) await updateFbaAppointment(payload.appointmentNo, { details: payload.details, version: payload.version, idempotencyKey: payload.idempotencyKey })
else await createFbaAppointment({ originWarehouseId: payload.originWarehouseId, fbaWarehouseCode: payload.fbaWarehouseCode, details: payload.details, idempotencyKey: payload.idempotencyKey, lineIds: payload.lineIds })
pendingSave.value = null
dialogOpen.value = false
ElMessage.success('预约已保存')
await load()
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试') }
await Promise.all([load(), loadUnscheduled()])
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(dialogError.value.code || '')) pendingSave.value = null }
finally { saving.value = false }
}
@@ -194,59 +351,126 @@ function selectCandidates(rows: object[]) {
selected.value = rows as InboundOrderSummaryDto[]
}
async function remove(event: CalendarEvent) {
const appointment = appointments.value.find(item => item.appointmentNo === event.key)
if (!appointment || loading.value) return
try {
await ElMessageBox.confirm(`确认删除预约 ${appointment.appointmentNo}?该操作会解除全部 ${appointment.selectedCount} 票货物的预约关联。`, '删除预约',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
} catch { return }
loading.value = true
try {
await deleteAppointment(appointment.appointmentNo, appointment.version)
ElMessage.success('预约已删除')
await load()
} catch (error) {
errorTitle.value = '预约删除失败'
loadError.value = getApiErrorDetails(error, '预约删除失败')
} finally { loading.value = false }
function remove(event: CalendarEvent) {
if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
}
function cancelEditing() {
if (!editing.value || editing.value.appointmentStatus === 'CANCELLED' || !access.can('platform:appointment:delete') || !canLeave()) return
cancellationTarget.value = editing.value
dialogOpen.value = false
}
async function cancelled() {
cancellationTarget.value = null; ++cancellationsRevision.value
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
await Promise.all([load(),loadUnscheduled()])
}
onMounted(() => load())
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; calendarController?.abort() })
watch(dialogOpen, open => {
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
})
async function resolveLink() {
const request = ++linkRequest
linkedSource.value = null; linkError.value = null; linkLoading.value = false
const { sourceKey, customerId, destinationKey } = route.query
if (!sourceKey && !customerId && !destinationKey) return
if (typeof sourceKey !== 'string' || typeof customerId !== 'string' || typeof destinationKey !== 'string') {
linkError.value = { message: '货物链接不完整,请返回原登记重新打开' }; return
}
if (!access.can(permission.lastMileOrderList)) { linkError.value = { message: '当前账号没有货源读取权限,请联系预约负责人处理' }; return }
linkLoading.value = true
try {
const result = await resolvePlanningSource({ sourceKey, customerId, destinationKey })
if (request === linkRequest) linkedSource.value = result
} catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') }
finally { if (request === linkRequest) linkLoading.value = false }
}
function arrangeLinkedSource() {
if (!linkedSource.value || !access.can(permission.inboundOrderAppointment) || !linkedSource.value.source.canFollowup || linkedSource.value.coverage.appointments.length) return
selectedSource.value = linkedSource.value.source
linkedSource.value = null
workView.value = 'cargo'
}
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
onMounted(() => { void load(); void loadUnscheduled() })
onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
</script>
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density" spacing="compact">
<p v-if="linkLoading">正在核对预约货源…</p>
<LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" />
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(permission.inboundOrderAppointment)" @arrange="arrangeLinkedSource" @close="linkedSource = null" />
<div class="appointment-heading">
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="switchWorkView('calendar')">预约日历</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="switchWorkView('containers')">提柜与到仓</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="switchWorkView('cargo')">送仓与排车</button>
</div>
</div>
<div v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div>
<AppointmentContainerPanel v-if="workView === 'containers'" />
<div v-if="calendarVisited" v-show="workView === 'calendar'">
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<AppointmentCalendarPanel :events="events" :loading="loading" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" />
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="saving" :confirm-disabled="detailLoading" @confirm="save">
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
<details v-if="unscheduledTotal" class="appointment-extra"><summary>未定时安排({{ unscheduledTotal }})</summary><DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" row-key="appointmentNo" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()"><template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template></DataTable></details>
<details class="appointment-extra" @toggle="cancellationsOpen = ($event.target as HTMLDetailsElement).open"><summary>取消记录</summary><AppointmentCancellationRecords v-if="cancellationsOpen" :key="cancellationsRevision" @view="openEdit({key: $event}, true)" /></details>
<AppointmentCalendarPanel :notices="[pickupNotice, showTripLayer ? tripNotice : ''].filter(Boolean)" :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || (showTripLayer && !!tripNotice)" :can-create="access.can(permission.inboundOrderAppointment)" @layers="showTripLayer = $event.trips" @range="load" @create="openCreate" @view="openEdit($event, true)" @edit="openEdit" @delete="remove" @open="openSource">
<template #notice-actions><el-button v-if="showTripLayer && unresolvedTrips.length" link type="primary" @click="unresolvedDialogOpen = true">查看待核实车次({{ unresolvedTrips.length }})</el-button></template>
</AppointmentCalendarPanel>
</div>
<AppDialog v-if="unresolvedDialogOpen" v-model="unresolvedDialogOpen" :title="`待核实车次(${unresolvedTrips.length})`" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog">
<p>这些车次缺少地点或时区,暂未计入日历。原计划时间保留 UTC。</p>
<DataTable v-model:page="unresolvedPage" v-model:page-size="unresolvedSize" :data="unresolvedRows" :columns="unresolvedColumns" :total="unresolvedTrips.length" row-key="tripNo" @update:page-size="unresolvedPage = 1">
<template #actions="{ row }"><el-button class="density-action" @click="router.push(`/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`)">查看来源</el-button></template>
<template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template>
</DataTable>
</AppDialog>
<AppointmentCancellationDialog v-if="cancellationTarget" :appointment-no="cancellationTarget.appointmentNo" :version="cancellationTarget.version" :selected-count="cancellationTarget.selectedCount" @close="cancellationTarget = null" @cancelled="cancelled" />
<AppDialog :model-value="dialogOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :show-confirm="!appointmentReadOnly" class="admin-density-dialog" :title="appointmentReadOnly ? '查看预约' : editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<div v-loading="detailLoading">
<el-form label-position="top" class="calendar-form density-form">
<el-form-item label="预约送仓时间" required><el-date-picker v-model="form.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预约结束时间"><el-date-picker v-model="form.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="form.expectedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预约仓库" required><el-input v-model="warehouse" :readonly="!!editing" placeholder="例如 ONT8" maxlength="128" /></el-form-item>
<el-form-item label="预约编号" required><el-input v-model="form.appointmentNo" :readonly="!!editing" maxlength="64" placeholder="请输入预约编号" /></el-form-item>
<el-form-item label="预约状态" required><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="预约备注"><el-input v-model="form.appointmentRemark" maxlength="1000" placeholder="请输入预约备注(选填)" /></el-form-item>
<el-form-item label="预约仓库" required>
<el-input v-if="editing" :model-value="warehouse" readonly />
<el-select v-else v-model="warehouse" aria-label="预约仓库" filterable remote clearable :remote-method="searchWarehouses" :loading="warehouseLoading" placeholder="搜索仓库编码或名称" @change="changeWarehouse">
<el-option v-for="item in warehouseOptions" :key="String(item.id)" :value="item.warehouseCode" :label="item.warehouseName === item.warehouseCode ? item.warehouseCode : `${item.warehouseCode} · ${item.warehouseName}`" />
</el-select>
</el-form-item>
</el-form>
<template v-if="!editing">
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="appointmentReadOnly || saving || !!pendingSave" />
<el-button v-if="editing && editing.appointmentStatus !== 'CANCELLED' && access.can('platform:appointment:delete')" data-cancel-editing type="danger" plain :disabled="saving || !!pendingSave || !detailReady" @click="cancelEditing">取消预约</el-button>
<template v-if="!editing && warehouse">
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
<template #toolbar-actions><el-button type="primary" :loading="candidateLoading" @click="loadCandidates(true)">查询可预约货物</el-button></template>
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
<template #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
</DataTable>
</template>
<div v-else class="appointment-cargo">{{ editing.selectedCount }} 票货物 · 入仓码:{{ editing.items.map(item => item.inboundCode).join('、') || '—' }}</div>
<p v-else-if="!editing" class="appointment-hint">先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。</p>
<div v-else class="appointment-cargo">
<span>{{ editing.selectedCount }} 票货物</span>
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
<details :key="`cancellations-${editing.appointmentNo}`" @toggle="cancellationHistoryOpen = ($event.target as HTMLDetailsElement).open"><summary>取消历史</summary><AppointmentCancellationHistory v-if="cancellationHistoryOpen" :key="editing.appointmentNo" :appointment-no="editing.appointmentNo" /></details>
</div>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.calendar-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.calendar-form :deep(.el-date-editor) { width: 100%; }
.appointment-heading { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.appointment-heading .density-status-tabs { flex: 1; margin: 0; }
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
.appointment-extra { margin-bottom: var(--admin-space-5); }
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
</style>
@@ -0,0 +1,53 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
const access = vi.hoisted(() => ({ canCancel: true }))
vi.mock('@/core/access/accessPreview', () => ({useAccessPreviewStore: () => ({can: () => access.canCancel})}))
vi.mock('vue-router', () => ({onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn()}))
vi.mock('./api', () => ({deleteAppointment: vi.fn(),listAppointmentCancellations: vi.fn(),listAppointmentCancellationRecords: vi.fn()}))
const record = {appointmentNo:'AP-CANCEL',version:1,cancelReason:null,cancelledAt:'2026-10-11T01:00:00',snapshot:{fbaWarehouseCode:'ABE3',appointmentStatus:'CANCELLED',appointmentRemark:'原备注保留',plannedDate:null,plannedAt:null,plannedEndAt:null,confirmationNo:'EXT-1',appointmentAt:'2026-10-15T09:00:00',appointmentEndAt:null,timezone:'America/New_York',selectedCount:1}}
function dialog() {
return mount(AppointmentCancellationDialog,{props:{appointmentNo:'AP-CANCEL',version:0,selectedCount:1},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose','showConfirm'],template:'<section><slot/><button v-if="showConfirm" data-save @click="$emit(\'confirm\')">取消</button></section>'}}}})
}
describe('appointment cancellation',()=>{
beforeEach(()=>{vi.resetAllMocks();access.canCancel=true})
it('requires a reason and retries the same reason and version after an unknown result',async()=>{
vi.mocked(deleteAppointment).mockRejectedValueOnce(new Error('lost')).mockResolvedValueOnce(undefined)
const wrapper=dialog();await flushPromises()
await wrapper.get('[data-save]').trigger('click');expect(deleteAppointment).not.toHaveBeenCalled()
await wrapper.get('textarea').setValue(' 仓库取消时段 ')
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(deleteAppointment).toHaveBeenCalledWith('AP-CANCEL',0,'仓库取消时段')
const done=vi.fn();wrapper.getComponent({name:'AppDialog'}).props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(deleteAppointment).toHaveBeenNthCalledWith(2,'AP-CANCEL',0,'仓库取消时段')
expect(wrapper.emitted('cancelled')).toHaveLength(1);wrapper.unmount()
})
it('does not expose or invoke cancellation without its independent permission',async()=>{
access.canCancel=false;const wrapper=dialog();await flushPromises()
expect(wrapper.find('[data-save]').exists()).toBe(false)
wrapper.getComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
expect(deleteAppointment).not.toHaveBeenCalled();wrapper.unmount()
})
it('reads history once per page and keeps missing historical reason explicit',async()=>{
vi.mocked(listAppointmentCancellations).mockResolvedValue({records:[record],total:11})
const wrapper=mount(AppointmentCancellationHistory,{props:{appointmentNo:'AP-CANCEL'},global:{plugins:[ElementPlus]}});await flushPromises()
expect(listAppointmentCancellations).toHaveBeenCalledExactlyOnceWith('AP-CANCEL',1,10)
expect(wrapper.text()).toContain('历史原因未记录');expect(wrapper.text()).toContain('原备注保留')
wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2);await flushPromises()
expect(listAppointmentCancellations).toHaveBeenLastCalledWith('AP-CANCEL',2,10);wrapper.unmount()
})
it('discovers persisted cancellations with one server page and opens by business number',async()=>{
vi.mocked(listAppointmentCancellationRecords).mockResolvedValue({records:[{appointmentNo:'AP-CANCEL',version:1,cancelReason:'实际原因',cancelledAt:record.cancelledAt,fbaWarehouseCode:'ABE3',selectedCount:1}],total:1})
const wrapper=mount(AppointmentCancellationRecords,{global:{plugins:[ElementPlus]}});await flushPromises()
expect(listAppointmentCancellationRecords).toHaveBeenCalledExactlyOnceWith(1,10)
expect(listAppointmentCancellations).not.toHaveBeenCalled()
await wrapper.get('.el-table__body-wrapper .el-table__row').findAll('button').find(b=>b.text()==='查看')!.trigger('click')
expect(wrapper.emitted('view')).toEqual([['AP-CANCEL']]);wrapper.unmount()
})
})
@@ -0,0 +1,17 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
vi.mock('@/shared/api/http',()=>({http:{get:vi.fn(),delete:vi.fn()},unwrapApiResponse:(result:{data:unknown})=>result.data}))
beforeEach(()=>vi.resetAllMocks())
it('sends the required cancellation body without the old reasonless query shape',async()=>{
vi.mocked(http.delete).mockResolvedValue({data:undefined})
await deleteAppointment('AP-TEST',3,'仓库取消时段')
expect(http.delete).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP-TEST',{data:{version:3,cancelReason:'仓库取消时段'}})
})
it('uses paged cancellation reads and the original business number for detail history',async()=>{
vi.mocked(http.get).mockResolvedValue({data:{records:[],total:'12'}})
expect((await listAppointmentCancellationRecords(2,10)).total).toBe(12)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations',{params:{page:2,size:10}})
await listAppointmentCancellations('AP/TEST',1,10)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP%2FTEST/cancellations',{params:{page:1,size:10}})
})
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { inject, onBeforeUnmount, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { deleteAppointment } from './api'
const props = defineProps<{ appointmentNo: string; version: number; selectedCount: number }>()
const emit = defineEmits<{ close: []; cancelled: [] }>()
const access = useAccessPreviewStore()
const reason = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const pending = ref<{number: string; version: number; reason: string} | null>(null)
function canLeave() {
if (saving.value || pending.value) { ElMessage.warning('请先核实本次取消结果,再关闭或切换'); return false }
return true
}
function beforeClose(done: () => void) { if (canLeave()) done() }
function close() { if (canLeave()) emit('close') }
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregister = register?.(canLeave)
onBeforeUnmount(() => unregister?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
async function save() {
if (!access.can('platform:appointment:delete') || saving.value) return
if (!pending.value && !reason.value.trim()) { ElMessage.warning('请填写实际取消原因'); return }
pending.value ||= {number: props.appointmentNo, version: props.version, reason: reason.value.trim()}
saving.value = true; error.value = null
try {
await deleteAppointment(pending.value.number, pending.value.version, pending.value.reason)
pending.value = null; emit('cancelled')
} catch (cause) {
error.value = getApiErrorDetails(cause, '取消结果待核实,请重试本次取消')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null
} finally { saving.value = false }
}
</script>
<template>
<AppDialog :model-value="true" title="取消预约" width="min(600px, calc(100vw - 48px))" class="admin-density-dialog" confirm-type="danger" :confirm-text="pending ? '重试本次取消' : '确认取消预约'" :show-confirm="access.can('platform:appointment:delete')" :confirm-loading="saving" :before-close="beforeClose" @update:model-value="close" @confirm="save">
<p>{{ appointmentNo }} · {{ selectedCount }} 票货物</p>
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
<el-form label-position="top" :disabled="saving || !!pending">
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit placeholder="请填写实际取消原因" /></el-form-item>
</el-form>
</AppDialog>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, ref, onBeforeUnmount } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listAppointmentCancellations, type AppointmentCancellation } from './api'
const props = defineProps<{ appointmentNo: string }>()
const page = ref(1), size = ref(10), total = ref(0), rows = ref<AppointmentCancellation[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let request = 0
const columns: DataTableColumn[] = [
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:170,singleLine:false},
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:220,singleLine:false},
{key:'snapshot',label:'取消时记录',slotName:'snapshot',minWidth:260,singleLine:false},
]
async function load() {
const current = ++request; loading.value = true; error.value = null
try { const result = await listAppointmentCancellations(props.appointmentNo,page.value,size.value); if(current===request) {rows.value=result.records;total.value=result.total} }
catch(cause) { if(current===request) error.value=getApiErrorDetails(cause,'取消历史加载失败') }
finally { if(current===request) loading.value=false }
}
onMounted(load)
onBeforeUnmount(()=>++request)
</script>
<template>
<LoadErrorState v-if="error" title="取消历史加载失败" :message="error.message" @retry="load" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" row-key="version" @update:page="load" @update:page-size="page=1;load()">
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
<template #snapshot="{row}"><details><summary>{{ row.snapshot.fbaWarehouseCode }} · {{ row.snapshot.selectedCount }} 票 · 查看取消时记录</summary><p>原业务备注:{{ row.snapshot.appointmentRemark || '—' }}</p><p>原计划:{{ row.snapshot.plannedAt || row.snapshot.plannedDate || '待定' }}<span v-if="row.snapshot.plannedEndAt"> — {{ row.snapshot.plannedEndAt }}</span></p><p>原确认:{{ row.snapshot.appointmentAt || '未确认' }}<span v-if="row.snapshot.appointmentEndAt"> — {{ row.snapshot.appointmentEndAt }}</span> · {{ row.snapshot.timezone || '时区待核实' }}</p><p>外部确认号:{{ row.snapshot.confirmationNo || '—' }}</p></details></template>
</DataTable>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, ref, onBeforeUnmount } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listAppointmentCancellationRecords, type AppointmentCancellationSummary } from './api'
const emit = defineEmits<{ view: [appointmentNo: string] }>()
const page=ref(1),size=ref(10),total=ref(0),rows=ref<AppointmentCancellationSummary[]>([])
const loading=ref(false),error=ref<ApiErrorDetails|null>(null)
let request=0
const columns: DataTableColumn[]=[
{key:'actions',label:'操作',slotName:'actions',width:90,fixed:'left'},
{prop:'appointmentNo',label:'预约单号',minWidth:220,singleLine:false},
{prop:'fbaWarehouseCode',label:'目的仓',width:100},
{prop:'selectedCount',label:'票数',width:80},
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:180,singleLine:false},
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:240,singleLine:false},
]
async function load() {
const current=++request;loading.value=true;error.value=null
try {const result=await listAppointmentCancellationRecords(page.value,size.value);if(current===request){rows.value=result.records;total.value=result.total}}
catch(cause){if(current===request)error.value=getApiErrorDetails(cause,'取消记录加载失败')}
finally{if(current===request)loading.value=false}
}
onMounted(load)
onBeforeUnmount(()=>++request)
</script>
<template>
<LoadErrorState v-if="error" title="取消记录加载失败" :message="error.message" @retry="load" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="(row: object) => `${(row as AppointmentCancellationSummary).appointmentNo}:${(row as AppointmentCancellationSummary).version}`" @update:page="load" @update:page-size="page=1;load()">
<template #actions="{row}"><el-button @click="emit('view',row.appointmentNo)">查看</el-button></template>
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
</DataTable>
</template>
@@ -0,0 +1,44 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import { listPlanningCargo } from './planningApi'
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
function render() {
return mount(AppointmentCargoPanel, { global: { stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
LoadErrorState: true, ApiErrorMeta: true, RowActions: true,
} } })
}
describe('appointment planning cargo query', () => {
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
it('uses one server-page request per entry, page change and reset', async () => {
const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
await wrapper.get('[data-search]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(3)
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
})
it('shows a source failure instead of an authoritative empty list', async () => {
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('目录不可用'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
})
it('discards a late previous page and uses the new server total', async () => {
let resolveOld!: (value: typeof empty) => void
vi.mocked(listPlanningCargo).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = render()
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ ...empty, page: 2, total: 21, pages: 2 })
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
resolveOld({ ...empty, total: 100, pages: 5 }); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(21)
expect(vi.mocked(listPlanningCargo).mock.calls[0]![1]!.aborted).toBe(true)
})
})
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
import AppDialog from '@/shared/components/AppDialog.vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const router = useRouter()
const access = useAccessPreviewStore()
const props = defineProps<{ externalSource?: PlanningSource | null }>()
const emit = defineEmits<{ created: []; sourceAccepted: [] }>()
const arrangement = reactive(createAppointmentForm())
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
const following = ref<ResolvedPlanningSource | null>(null)
const candidateMode = ref<'firstleg' | 'external'>('firstleg')
const externalRevision = ref(0)
const followupError = ref<ApiErrorDetails | null>(null)
type Selection = Pick<PlanningSource, 'sourceKey' | 'customerId' | 'originWarehouseId' | 'destinationWarehouseCode' | 'inboundCode'> & { bookingNo?: string | null }
const selected = ref<Record<string, Selection>>({})
const selectedRows = computed(() => Object.values(selected.value))
const externalRows = computed(() => selectedRows.value.filter(row => row.sourceKey.startsWith('BOOKING:')))
const batchPreview = ref<PlanningBatchPreview | null>(null)
const batchLoading = ref(false), batchError = ref<ApiErrorDetails | null>(null)
let batchRequest = 0
function addSource(source: PlanningSource) {
if (!access.can(overseasWarehousePermissions.inboundOrderAppointment) || batchLoading.value || creating.value) return false
if (!source.canFollowup) { ElMessage.warning('当前来源尚不能安排预约'); return false }
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== source.originWarehouseId || anchor.destinationWarehouseCode !== source.destinationWarehouseCode)) {
ElMessage.warning('请先完成或清空当前合批选择;不同起运仓或目的仓不能混入'); return false
}
if (!selected.value[source.sourceKey] && selectedRows.value.length >= 100) { ElMessage.warning('每批最多选择100票'); return false }
selected.value[source.sourceKey] = source; batchError.value = null
return true
}
function acceptExternalSource() {
if (props.externalSource && addSource(props.externalSource)) emit('sourceAccepted')
}
function arrangeFollowing() {
if (following.value && !following.value.coverage.appointments.length && addSource(following.value.source)) following.value = null
}
function selectable(row: PlanningCargo) {
const anchor = selectedRows.value[0]
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
}
function toggle(row: PlanningCargo, checked: unknown) {
batchError.value = null
if (checked && selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row
else delete selected.value[row.sourceKey]
}
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
async function previewBatch() {
if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
const current = ++batchRequest
batchLoading.value = true; batchError.value = null
try {
const result = await previewPlanningBatch(selectedRows.value.map(row => ({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })))
if (current === batchRequest) { batchPreview.value = result; Object.assign(arrangement, createAppointmentForm()); pendingCreate.value = null; createError.value = null }
} catch (cause) { if (current === batchRequest) batchError.value = getApiErrorDetails(cause, '所选货物暂不能合批') }
finally { if (current === batchRequest) batchLoading.value = false }
}
async function saveArrangement() {
if (!batchPreview.value || creating.value || !access.can(overseasWarehousePermissions.inboundOrderAppointment)) return
const error = appointmentFormError(arrangement)
if (!pendingCreate.value && error) { ElMessage.warning(error); return }
pendingCreate.value ||= { originWarehouseId: batchPreview.value.group.originWarehouseId, fbaWarehouseCode: batchPreview.value.destinationWarehouseCode, sources: batchPreview.value.sources.map(row => ({sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: row.destinationKey})), details: appointmentPayload(arrangement), idempotencyKey: crypto.randomUUID() }
creating.value = true; createError.value = null
try {
const result = await createSourceAppointment(pendingCreate.value)
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
} catch (cause) {
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
} finally { creating.value = false }
}
let followupRequest = 0
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
const page = ref(1), size = ref(20), total = ref(0)
const rows = ref<PlanningCargo[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let request = 0
let controller: AbortController | undefined
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' },
{ key: 'select', label: '选择', slotName: 'select', width: 65 },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 150 },
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
{ prop: 'originWarehouseName', label: '接仓点', minWidth: 140 },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '预报箱数', width: 100 },
{ prop: 'receivedPackages', label: '实收箱数', width: 100 },
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 180, slotName: 'warehouseEta', singleLine: false },
{ key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 },
]
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
if (request !== current) return
rows.value = result.records; total.value = result.total
} catch (cause) {
if (request === current) { error.value = getApiErrorDetails(cause, '已开船货物加载失败'); rows.value = []; total.value = 0 }
} finally { if (request === current) loading.value = false }
}
function source(row: PlanningCargo) {
return router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderId }, query: { customerId: row.customerId } })
}
async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId' | 'destinationWarehouseCode'>) {
const request = ++followupRequest
followupError.value = null
try {
const result = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })
if (request === followupRequest) following.value = result
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
}
function canLeave() {
if (creating.value || pendingCreate.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return true
}
function closeBatch() { if (canLeave()) batchPreview.value = null }
function beforeClose(done: () => void) { if (canLeave()) done() }
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
onBeforeUnmount(() => unregisterLeaveGuard?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(() => load())
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
</script>
<template>
<div role="group" aria-label="预约货源类型">
<el-button :type="candidateMode === 'firstleg' ? 'primary' : 'default'" @click="candidateMode = 'firstleg'">头程货物</el-button>
<el-button :type="candidateMode === 'external' ? 'primary' : 'default'" @click="candidateMode = 'external'">外接货物</el-button>
</div>
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
<template #toolbar-actions>
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
</template>
<template #notices>
<details v-if="selectedRows.length" class="external-selection"><summary>已选 {{ selectedRows.length }} 票(切换货源保留选择)</summary>
<div class="external-selection-list"><div v-for="row in selectedRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
</details>
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false" />
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
</template>
</AppointmentExternalCandidates>
<DataTable v-show="candidateMode === 'firstleg'" v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
<template #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
<template #toolbar-actions>
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
</template>
<template #filters><QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
<template #notices>
<div v-if="externalSource" class="cargo-note">
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
<el-button v-if="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :disabled="batchLoading || creating" @click="acceptExternalSource">加入本次选择</el-button>
</div>
<details v-if="externalRows.length" class="external-selection">
<summary>已选外接货 {{ externalRows.length }} 票(可与同仓头程货合批)</summary>
<div class="external-selection-list"><div v-for="row in externalRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
</details>
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false"><template #details><ApiErrorMeta :details="batchError" /></template></LoadErrorState>
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
<LoadErrorState v-if="error" title="已开船货物加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else class="cargo-note">来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。</p>
</template>
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
<template #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="(value: boolean | string | number) => toggle(row as PlanningCargo, value)" /></template>
<template #warehouseEta="{ row }"><span>{{ row.estimatedWarehouseAt?.replace('T', ' ') || '—' }}</span><div v-if="row.estimatedWarehouseAt" class="cargo-note">来源时区未明确</div></template>
<template #progress="{ row }">
<span v-if="row.detained">暂扣,待处理</span>
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
<span v-else-if="row.hasActiveAppointment">已有预约</span>
<span v-else>{{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}</span>
</template>
</DataTable>
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
<FbaAppointmentFields :timezone="batchPreview.destinationTimezone" :model="arrangement" :disabled="creating || !!pendingCreate" />
</AppDialog>
</template>
<style scoped>
.cargo-note { margin: var(--admin-space-3) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.external-selection { margin: var(--admin-space-3) 0; }
.external-selection-list { max-height: 216px; overflow-y: auto; }
.external-selection-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
import { listImpacts, refreshImpacts, impactStatus } from './impactApi'
const impactReady = ref(false), impactError = ref<ApiErrorDetails | null>(null)
const impactId = ref<string | null>(null), refreshing = ref(false), onlyChanges = ref(false)
const access = useAccessPreviewStore()
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total = ref(0)
const loading = ref(false), opening = ref(false)
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
const context = ref<ContainerFollowupContext | null>(null)
const changedCount = computed(() => rows.value.filter(row => row.impact?.status === 'OPEN').length)
const displayedRows = computed(() => onlyChanges.value ? rows.value.filter(row => row.impact?.status === 'OPEN') : rows.value)
async function refreshForecasts() {
refreshing.value = true; contextError.value = null
try { await refreshImpacts({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }); await load() }
catch (cause) { contextError.value = getApiErrorDetails(cause, '船期核对未完成,原记录保留') }
finally { refreshing.value = false }
}
let request = 0, contextRequest = 0, controller: AbortController | undefined
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 200, fixed: 'left' },
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 },
{ prop: 'orderCount', label: '美国范围票数', width: 120 },
{ prop: 'customerCount', label: '客户数', width: 90 },
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false },
{ key: 'impact', label: '船期跟进', slotName: 'impact', minWidth: 160 },
{ key: 'owner', label: '负责人', slotName: 'owner', minWidth: 110 },
{ key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
]
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null; impactReady.value = false; impactError.value = null
try {
const params = { page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }
const [cargo, impacts] = await Promise.allSettled([listPlanningContainers(params, controller.signal), listImpacts(params, controller.signal)])
if (current !== request) return
if (cargo.status === 'rejected') throw cargo.reason
rows.value = cargo.value.records; total.value = cargo.value.total
if (impacts.status === 'fulfilled') {
const states = new Map(impacts.value.records.map(row => [row.container.containerBookingId, row.impact]))
rows.value = rows.value.map(row => ({ ...row, impact: states.get(row.containerBookingId) || null }))
impactReady.value = true
} else { onlyChanges.value = false; impactError.value = getApiErrorDetails(impacts.reason, '船期跟进暂不可用') }
} catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
finally { if (current === request) loading.value = false }
}
async function follow(row: PlanningContainer, warehouseId?: string) {
const current = ++contextRequest
contextError.value = null; opening.value = true
try {
const result = await resolvePlanningContainer(row.containerBookingId, warehouseId)
if (current === contextRequest) context.value = result
} catch (cause) { if (current === contextRequest) contextError.value = getApiErrorDetails(cause, '整柜范围核对失败') }
finally { if (current === contextRequest) opening.value = false }
}
const displayTime = (value: string | null) => value?.replace('T', ' ') || '—'
onMounted(() => load())
onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="displayedRows" :total="total" :loading="loading || refreshing" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
<template #filters><QueryToolbar v-model="query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)">
<template #actions>
<el-button v-if="impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges">本页待处理 {{ changedCount }}</el-button>
<el-button v-if="impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts">核对船期</el-button>
</template>
</QueryToolbar></template>
<template #notices>
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></details>
</template>
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
<template #arrival="{ row }">{{ displayTime(row.earliestArrivalAt) }}<span v-if="row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt"> 至 {{ displayTime(row.latestArrivalAt) }}</span></template>
<template #receiving="{ row }"><div v-for="warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)">接仓跟进</el-button></div></template>
<template #impact="{ row }">{{ row.impact ? impactStatus[row.impact.status] : (impactReady ? '未核对' : '暂不可用') }}</template>
<template #owner="{ row }">{{ row.impact?.ownerName || '未认领' }}</template>
<template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
</DataTable>
<AppointmentImpactDialog v-if="impactId" :container-id="impactId" :can-write="access.can('platform:appointment:followup')" @close="impactId = null" @saved="load()" />
<AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
</template>
<style scoped>
.container-help { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.container-help summary { cursor: pointer; }
.receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
</style>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listExternalAppointmentCandidates, type ExternalAppointmentCandidate, type PlanningSource } from './planningApi'
defineProps<{ canArrange: boolean; selectedKeys: string[]; busy: boolean }>()
const emit = defineEmits<{ select: [source: PlanningSource]; followup: [row: ExternalAppointmentCandidate] }>()
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
const rows = ref<ExternalAppointmentCandidate[]>([]), error = ref<ApiErrorDetails | null>(null)
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '外接货', placeholder: '登记号、车次号、客户、来货号', width: 300 }]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 160, fixed: 'left' },
{ key: 'booking', label: '原登记号', slotName: 'booking', minWidth: 190 },
{ prop: 'customerName', label: '客户', minWidth: 130 },
{ prop: 'tripNo', label: '车次号', minWidth: 190 },
{ key: 'origin', label: '接仓点', slotName: 'origin', minWidth: 140 },
{ key: 'destination', label: '目的FBA仓', slotName: 'destination', width: 110 },
{ key: 'packages', label: '箱数', slotName: 'packages', width: 90 },
{ key: 'coverage', label: '预约情况', slotName: 'coverage', minWidth: 180 },
]
let request = 0
let controller: AbortController | undefined
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listExternalAppointmentCandidates({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
if (current === request) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '外接候选加载失败') }
} finally { if (current === request) loading.value = false }
}
onMounted(() => load())
onBeforeUnmount(() => { ++request; controller?.abort() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" @update:page="load()" @update:page-size="load(true)">
<template #toolbar-actions><slot name="toolbar-actions" /></template>
<template #filters><QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
<template #notices>
<slot name="notices" />
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else>已确认承运的美国FBA货物,来货前后保留同一原登记;箱数未知可先保存待确认安排。</p>
</template>
<template #actions="{ row }"><template v-if="row.source && row.coverage">
<el-button v-if="canArrange" link type="primary" :disabled="busy || selectedKeys.includes(row.source.sourceKey) || !row.source.canFollowup || row.coverage.appointments.length > 0" @click="emit('select', row.source)">{{ selectedKeys.includes(row.source.sourceKey) ? '已加入' : '加入合批' }}</el-button>
<el-button link type="primary" @click="emit('followup', row as ExternalAppointmentCandidate)">跟进</el-button>
</template></template>
<template #booking="{ row }">{{ row.source?.bookingNo || '—' }}</template>
<template #origin="{ row }">{{ row.source?.originWarehouseName || '—' }}</template>
<template #destination="{ row }">{{ row.source?.destinationWarehouseCode || '—' }}</template>
<template #packages="{ row }">{{ row.source?.packages ?? '待核实' }}</template>
<template #coverage="{ row }"><span v-if="row.source && row.coverage">{{ row.coverage.appointments.length ? (row.coverage.changeReviewRequired ? '已有预约,待复核' : '已有预约') : (row.source.canFollowup ? '待安排' : row.source.blockReasons.join(';')) }}</span></template>
</DataTable>
</template>
@@ -0,0 +1,80 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi'
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
function render(canWrite = true, container?: ContainerFollowupContext) {
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '<div><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElSelect: true, ElOption: true, ElDatePicker: true,
DataTable: true, LoadErrorState: true, ApiErrorMeta: true,
} } })
}
describe('appointment followup recovery', () => {
beforeEach(() => {
vi.resetAllMocks(); vi.mocked(getFollowups).mockResolvedValue(history)
vi.mocked(getContainerFollowups).mockResolvedValue(history)
vi.mocked(saveFollowup).mockResolvedValue({ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' })
})
it.each([null, '100'])('saves the correct container stage for warehouse %s without customer-scoped calls', async warehouseId => {
const container = { containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId, warehouseName: warehouseId ? '洛杉矶仓' : null, canFollowup: true, blockReasons: [] }
const wrapper = render(true, container); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('同柜统一联系')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveContainerFollowup).toHaveBeenCalledWith(container, expect.objectContaining({ stage: warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT', expectedVersion: 0, result: '同柜统一联系' }))
expect(getContainerFollowups).toHaveBeenCalledTimes(2)
expect(getFollowups).not.toHaveBeenCalled()
expect(saveFollowup).not.toHaveBeenCalled()
wrapper.unmount()
})
it('describes FBA followup independently of batch action visibility', async () => {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('FBA收货跟进')
expect(wrapper.text()).not.toContain('同柜多客户共用提柜')
wrapper.unmount()
})
it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'FBA_RECEIVING', expectedVersion: 0, result: '已联系,等待回复', followupDate: null }))
const body = vi.mocked(saveFollowup).mock.calls[0]![2]
expect(body).not.toHaveProperty('appointmentNo'); expect(body).not.toHaveProperty('driverId'); expect(body).not.toHaveProperty('operatorId')
expect(getFollowups).toHaveBeenCalledTimes(2)
})
it('retries an unknown outcome with the exact same body and persisted command key', async () => {
vi.mocked(saveFollowup).mockRejectedValueOnce(new Error('network'))
const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(saveFollowup).mock.calls[0]![2]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.emitted('close')).toBeUndefined()
const done = vi.fn()
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
expect(done).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
expect(guard()).toBe(false)
}
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
expect(first.idempotencyKey).toBeTruthy()
})
it('does not allow a write without write permission or a successfully loaded version', async () => {
const readonly = render(false); await flushPromises()
expect(readonly.get('[data-save]').attributes('disabled')).toBeDefined()
vi.mocked(getFollowups).mockRejectedValueOnce(new Error('unavailable'))
const failed = render(); await flushPromises()
expect(failed.get('[data-save]').attributes('disabled')).toBeDefined()
expect(saveFollowup).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { inject, computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type ContainerFollowupContext, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
const props = defineProps<({ source: PlanningSource; coverage: AppointmentCoverage; container?: never } | { container: ContainerFollowupContext; source?: never; coverage?: never }) & { canWrite: boolean; canArrange?: boolean }>()
const emit = defineEmits<{ close: []; arrange: [] }>()
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
const coverageNeeds: Record<string, string> = {
DESTINATION: '目的仓与原安排不一致,请核对。',
DESTINATION_UNVERIFIED: '来源目的地资料待核实,尚不能判断是否发生变更。',
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先保存待确认安排;正式确认前需补齐资料。',
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
RECEIVING_APPOINTMENT: '尚未安排收货预约。',
UNCOVERED_CARGO: '部分货物尚未纳入收货预约。',
RECEIVING_CONFIRMATION: '尚未取得全部货物的收货确认。',
APPOINTMENT_TIMEZONE_UNVERIFIED: '原预约未记录时区,请核实收货地点当地时间。',
FBA_SHIPMENT_REFERENCES: 'FBA货件资料尚未齐全,请补充货件号或追踪号。',
SOURCE_CHANGED_REVIEW: '货源资料更新晚于预约,请核对是否需要改约。',
}
const allowedStages = computed(() => props.container ? props.container.warehouseId ? { WAREHOUSE_RECEIVING: stages.WAREHOUSE_RECEIVING } : { DRAYAGE_CONTACT: stages.DRAYAGE_CONTACT, TERMINAL_APPOINTMENT: stages.TERMINAL_APPOINTMENT } : { FBA_RECEIVING: stages.FBA_RECEIVING })
const canFollowup = computed(() => props.container?.canFollowup ?? props.source?.canFollowup ?? false)
const blockReasons = computed(() => props.container?.blockReasons ?? props.source?.blockReasons ?? [])
const title = computed(() => props.container ? `${props.container.containerNo || '未回传柜号'} · ${props.container.warehouseName || '提柜'}` : props.source?.inboundCode || props.source?.bookingNo || '未关联入仓码')
const form = reactive({ stage: (props.container ? props.container.warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT' : 'FBA_RECEIVING') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
const rows = ref<FollowupRecord[]>([])
const versions = ref<Record<FollowupStage, number>>({ DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 })
const page = ref(1), size = ref(10), total = ref(0)
const loading = ref(false), saving = ref(false), ready = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const pending = ref<SaveFollowup | null>(null)
let generation = 0
const columns: DataTableColumn[] = [
{ key: 'stage', label: '事项', slotName: 'stage', width: 120 },
{ prop: 'contactedParty', label: '联系对象', minWidth: 130 },
{ prop: 'result', label: '联系结果', minWidth: 200 },
{ prop: 'nextAction', label: '下一步', minWidth: 150 },
{ prop: 'followupDate', label: '下次跟进日', width: 120 },
{ prop: 'operatorName', label: '跟进人', minWidth: 110 },
]
function rowKey(row: object) { const value = row as FollowupRecord; return `${value.stage}:${value.version}` }
async function load() {
const request = ++generation
loading.value = true; error.value = null; ready.value = false
try {
const result = props.container ? await getContainerFollowups(props.container, page.value, size.value) : await getFollowups(props.source!.sourceKey, props.source!.customerId, page.value, size.value)
if (request !== generation) return
rows.value = result.history.records; total.value = result.history.total; versions.value = result.versions; ready.value = true
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '跟进记录加载失败') }
finally { if (request === generation) loading.value = false }
}
async function save() {
if (!props.canWrite || !canFollowup.value || saving.value || !ready.value) return
if (!pending.value && (!form.result.trim() || (form.followupDate && !form.nextAction.trim()))) {
ElMessage.warning('请填写联系结果;设置下次跟进日时需填写下一步安排'); return
}
pending.value ||= { stage: form.stage, expectedVersion: versions.value[form.stage], idempotencyKey: crypto.randomUUID(),
contactedParty: form.contactedParty.trim() || undefined, result: form.result.trim(), nextAction: form.nextAction.trim() || undefined, followupDate: form.followupDate || null }
saving.value = true; saveError.value = null
try {
if (props.container) await saveContainerFollowup(props.container, pending.value)
else await saveFollowup(props.source!.sourceKey, props.source!.customerId, pending.value)
pending.value = null; form.result = ''; form.nextAction = ''; form.followupDate = ''; page.value = 1
ElMessage.success('跟进记录已保存'); await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
} finally { saving.value = false }
}
function canLeave() {
if (saving.value || pending.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return true
}
function close() { if (canLeave()) emit('close') }
function arrange() { if (canLeave()) emit('arrange') }
function beforeClose(done: () => void) { if (canLeave()) done() }
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
onBeforeUnmount(() => unregisterLeaveGuard?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(load)
onBeforeUnmount(() => { ++generation })
</script>
<template>
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="close" :before-close="beforeClose">
<p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving || !!pending" @click="arrange">加入预约合批</el-button>
<p v-if="container">同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
<p v-else>本条记录用于此货源的FBA收货跟进;保存联系不自动确认预约或推进收货。</p>
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-for="reason in blockReasons" :key="reason">{{ reason }}</p>
<p v-if="coverage?.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
<p v-if="coverage?.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
<p v-else-if="coverage">尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
</el-form>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
</DataTable>
</AppDialog>
</template>
<style scoped>
.followup-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-6); }
.wide { grid-column: 1 / -1; }
.followup-form :deep(.el-select), .followup-form :deep(.el-date-editor) { width: 100%; }
</style>
@@ -0,0 +1,105 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { getImpact, handleImpact, type ImpactDetail } from './impactApi'
vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal<typeof import('./impactApi')>(), getImpact: vi.fn(), handleImpact: vi.fn() }))
const guards = vi.hoisted(() => ({ register: vi.fn(), unregister: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: guards.push }) }))
const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } }
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
describe('appointment impact handling', () => {
beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) })
function deferred() {
let resolve!: (value: ImpactDetail) => void, reject!: (reason: Error) => void
const promise=new Promise<ImpactDetail>((yes,no)=>{resolve=yes;reject=no})
return {promise,resolve,reject}
}
const pageDetail=(label:string):ImpactDetail=>({...detail,history:{total:60,records:[{version:1,action:'RECORD',operatorName:null,result:label,nextAction:null,followupDate:null,createdAt:'2026-10-11T00:00:00'}]}})
it('keeps the latest history page when responses finish in reverse order',async()=>{
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
const wrapper=render();await flushPromises()
const second=deferred(),third=deferred()
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
const pager=wrapper.findComponent({name:'ElPagination'})
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
await flushPromises()
third.resolve(pageDetail('第三页最新'));await flushPromises()
second.resolve(pageDetail('第二页过期'));await flushPromises()
expect(wrapper.text()).toContain('第三页最新');expect(wrapper.text()).not.toContain('第二页过期')
wrapper.unmount()
})
it('ignores an obsolete failure and keeps loading until the current request finishes',async()=>{
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
const wrapper=render();await flushPromises()
const second=deferred(),third=deferred()
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
const pager=wrapper.findComponent({name:'ElPagination'})
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
second.reject(new Error('obsolete page failure'));await flushPromises()
expect(wrapper.text()).not.toContain('obsolete page failure')
expect(pager.props('disabled')).toBe(true)
expect(wrapper.findAll('button').find(b=>b.text()==='认领跟进')!.attributes('disabled')).toBeDefined()
wrapper.findAllComponents({name:'ElButton'}).find(b=>b.text()==='认领跟进')!.vm.$emit('click')
await flushPromises();expect(handleImpact).not.toHaveBeenCalled()
third.resolve(pageDetail('第三页最新'));await flushPromises()
expect(pager.props('disabled')).toBe(false);wrapper.unmount()
})
it('does not let a closed dialog response replace a newly opened impact',async()=>{
const old=deferred();vi.mocked(getImpact).mockReturnValueOnce(old.promise)
const first=render();await flushPromises();first.unmount()
vi.mocked(getImpact).mockResolvedValueOnce({...pageDetail('新影响历史'),impact:{...detail.impact,impactNo:'AI-NEW'}})
const next=render();await flushPromises()
old.resolve(pageDetail('已关闭历史'));await flushPromises()
expect(next.text()).toContain('AI-NEW');expect(next.text()).not.toContain('已关闭历史');next.unmount()
})
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
const wrapper = render(false); await flushPromises()
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
expect(wrapper.text()).toContain('2026-10-17T09:00:00')
expect(wrapper.text()).toContain('2026-10-16T09:00:00')
expect(wrapper.text()).toContain('无车次查看权限')
expect(wrapper.text()).toContain('来源:carrier')
expect(wrapper.text()).toContain('核对原因:到港预测变化')
expect(wrapper.text()).not.toContain('认领跟进')
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
})
it('labels manual observations without exposing internal operator references and explains missing forecasts', async () => {
vi.mocked(getImpact).mockResolvedValueOnce({ ...detail, impact: { ...detail.impact, reason: 'FORECAST_MISSING', latest: { ...detail.impact.latest!, providerCode: 'MANUAL', sourceReference: 'MANUAL:1000001', estimatedAt: null } } })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('来源:人工维护')
expect(wrapper.text()).toContain('来源预计到港缺失,待核实')
expect(wrapper.text()).not.toContain('1000001')
expect(wrapper.text()).not.toContain('MANUAL:')
expect(wrapper.text()).not.toContain('已延误')
expect(wrapper.text()).toContain('认领跟进')
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
})
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
const original = vi.mocked(handleImpact).mock.calls[0]![1]
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
expect(guards.register.mock.calls[0]![0]()).toBe(false)
await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(getImpact).toHaveBeenCalledTimes(2)
expect(guards.register.mock.calls[0]![0]()).toBe(true)
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
})
it('does not resolve without an actual handling result', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '完成本次核对')!.trigger('click'); await flushPromises()
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
})
})
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { inject, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getImpact, handleImpact, impactStatus, impactReason, type ImpactDetail, type ImpactCommand } from './impactApi'
const props = defineProps<{ containerId: string; canWrite: boolean }>()
const emit = defineEmits<{ close: []; saved: [] }>()
const router = useRouter(), detail = ref<ImpactDetail | null>(null), loading = ref(false), saving = ref(false), historyPage = ref(1)
const error = ref<ApiErrorDetails | null>(null), pending = ref<ImpactCommand | null>(null)
const form = reactive({ result: '', nextAction: '', followupDate: '' })
const actions: Record<string, string> = { OBSERVED: '船期核对', CLAIM: '认领', RECORD: '记录处理', RESOLVE: '完成核对' }
let loadGeneration = 0
async function load() {
const generation = ++loadGeneration
loading.value = true; error.value = null
try { const result = await getImpact(props.containerId, historyPage.value); if (generation === loadGeneration) detail.value = result }
catch (cause) { if (generation === loadGeneration) error.value = getApiErrorDetails(cause, '影响详情加载失败') }
finally { if (generation === loadGeneration) loading.value = false }
}
async function save(action: ImpactCommand['action']) {
if (!detail.value || loading.value || saving.value || !props.canWrite) return
if (!pending.value && action !== 'CLAIM' && !form.result.trim()) { ElMessage.warning('请填写实际处理结果'); return }
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
saving.value = true; error.value = null
try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() }
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
finally { saving.value = false }
}
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
function close() { if (canLeave()) emit('close') }
function beforeClose(done: () => void) { if (canLeave()) done() }
function openTrip(tripNo: string) { if (canLeave()) void router.push(`/overseas-master-data/trips/${encodeURIComponent(tripNo)}`) }
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregister = register?.(canLeave)
onBeforeUnmount(() => { ++loadGeneration; unregister?.() })
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(load)
</script>
<template>
<AppDialog :model-value="true" title="船期变化影响核对" width="min(1200px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog" @update:model-value="close" :before-close="beforeClose">
<LoadErrorState v-if="error" title="核对未完成" :message="error.message" :show-retry="!pending" @retry="load" />
<template v-if="detail">
<p>{{ detail.impact.impactNo }} · {{ impactStatus[detail.impact.status] }} · 负责人:{{ detail.impact.ownerName || '未认领' }}</p>
<p>核对原因:{{ detail.impact.reason === 'FORECAST_MISSING' ? '来源预计到港缺失,待核实' : impactReason[detail.impact.reason || ''] || '待核实' }}</p>
<p>基准 ETA:{{ detail.impact.baseline?.estimatedAt || '待核实' }} → 最新 ETA:{{ detail.impact.latest?.estimatedAt || '待核实' }}</p>
<p>来源:{{ detail.impact.latest?.providerCode === 'MANUAL' ? '人工维护' : detail.impact.latest?.providerCode || '待核实' }} · 观察时间:{{ detail.impact.latest?.observedAt || '—' }}</p>
<p>实际到港:{{ detail.impact.latest?.actualAt || '未记录' }}。以上为来源原始时间,时区未核实;不能据此判定跨时区违约。</p>
<details open><summary>直接关联货物 {{ detail.cargo.length }} 票</summary><div class="impact-list"><p v-for="(row, index) in detail.cargo" :key="index">{{ row.inboundCode || '未关联入仓码' }} · {{ row.customerName || '—' }} · {{ row.destination || '—' }} · {{ row.packages ?? '待核实' }} 箱</p></div></details>
<details open><summary>需复核的收货预约 {{ detail.appointments.length }} 张(确认时间不自动修改)</summary><div class="impact-list"><p v-for="row in detail.appointments" :key="row.appointmentNo">{{ row.appointmentNo }} · {{ row.destination }} · {{ row.confirmedAt || '尚未确认时段' }} {{ row.timezone || '时区待核实' }}</p></div></details>
<p v-if="!detail.transport">无车次查看权限,运输影响未加载。</p>
<template v-else>
<details open><summary>关联车次 {{ detail.transport.trips.length }} 辆</summary><div class="impact-list"><p v-for="row in detail.transport.trips" :key="row.tripNo"><el-button link type="primary" @click="openTrip(row.tripNo)">{{ row.tripNo }}</el-button> · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}</p></div></details>
<details><summary>同车外接承诺 {{ detail.transport.sameTripBookings.length }} 票,仅提示运输安排复核</summary><div class="impact-list"><p v-for="row in detail.transport.sameTripBookings" :key="row.bookingNo">{{ row.bookingNo }} · {{ row.customerName || '—' }} · {{ row.tripNo }} · {{ row.destinationLabel || '—' }}</p></div></details>
</template>
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
<el-form-item label="实际处理结果"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录已联系、核对或改约的实际结果;此处不会自动改约" /></el-form-item>
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写后续处理事项" /></el-form-item>
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" value-format="YYYY-MM-DD" /></el-form-item>
</el-form>
<div v-if="canWrite && detail.impact.status === 'OPEN'">
<el-button v-if="!pending" :disabled="saving || loading" @click="save('CLAIM')">认领跟进</el-button>
<el-button v-if="!pending" :disabled="saving || loading" @click="save('RECORD')">记录处理</el-button>
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
</div>
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
</template>
</AppDialog>
</template>
<style scoped>.impact-list { max-height: 220px; overflow-y: auto; overflow-wrap: anywhere; }</style>
@@ -0,0 +1,41 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
describe('appointment inbound code list', () => {
const codes = Array.from({ length: 20 }, (_, index) => `WT-LONG-INBOUND-CODE-${index + 1}`)
it('previews three codes, exposes the total and expands without losing full codes', async () => {
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
expect(wrapper.findAll('li')).toHaveLength(3)
expect(wrapper.text()).toContain('20 个')
expect(wrapper.get('button').text()).toContain('还有 17 个')
expect(wrapper.get('li').attributes('title')).toBe(codes[0])
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('li').map(row => row.text())).toEqual(codes)
expect(wrapper.get('ul').classes()).toContain('expanded')
expect(wrapper.get('ul').attributes('tabindex')).toBe('0')
expect(wrapper.get('button').attributes('aria-controls')).toBe(wrapper.get('ul').attributes('id'))
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('li')).toHaveLength(3)
})
it('does not count missing or duplicate codes and shows no redundant toggle', () => {
const wrapper = mount(AppointmentInboundCodes, { props: { codes: [' A ', 'A', '', null, undefined, 'B'] } })
expect(wrapper.findAll('li').map(row => row.text())).toEqual(['A', 'B'])
expect(wrapper.text()).toContain('2 个')
expect(wrapper.find('button').exists()).toBe(false)
expect(mount(AppointmentInboundCodes, { props: { codes: [] } }).text()).toContain('—')
})
it('preserves expansion on equivalent data but resets when the appointment codes change', async () => {
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
await wrapper.get('button').trigger('click')
await wrapper.setProps({ codes: [...codes] })
expect(wrapper.findAll('li')).toHaveLength(20)
await wrapper.setProps({ codes: codes.map(code => `${code}-NEW`) })
expect(wrapper.findAll('li')).toHaveLength(3)
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
})
})
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
const props = defineProps<{ codes: Array<string | null | undefined> }>()
const expanded = ref(false)
const listId = useId()
const codes = computed(() => [...new Set(props.codes.map(code => code?.trim()).filter((code): code is string => !!code))])
const visibleCodes = computed(() => expanded.value ? codes.value : codes.value.slice(0, 3))
watch(() => codes.value.join('\n'), () => { expanded.value = false })
</script>
<template>
<section class="inbound-codes" aria-label="关联入仓码">
<div class="codes-heading">
<span :title="`共 ${codes.length} 个入仓码`">入仓码 <span v-if="codes.length">({{ codes.length }} 个)</span></span>
<button v-if="codes.length > 3" type="button" :aria-expanded="expanded" :aria-controls="listId" @click="expanded = !expanded">
{{ expanded ? '收起' : `查看全部(还有 ${codes.length - 3} 个)` }}
</button>
</div>
<ul v-if="codes.length" :id="listId" class="codes-list" :class="{ expanded }" :tabindex="expanded ? 0 : undefined" aria-label="入仓码列表">
<li v-for="code in visibleCodes" :key="code" :title="code">{{ code }}</li>
</ul>
<span v-else class="codes-empty">—</span>
</section>
</template>
<style scoped>
.inbound-codes { min-width: 0; margin: var(--admin-space-5) 0; font-size: var(--admin-font-body); }
.codes-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.codes-heading button { border: 0; background: transparent; color: var(--el-color-primary); cursor: pointer; font: inherit; min-height: var(--admin-action-height); padding: 0; }
.codes-list { list-style: none; padding: 0; margin: var(--admin-space-1) 0 0; line-height: 1.8; }
.codes-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.codes-list.expanded { max-height: 216px; overflow-y: auto; overscroll-behavior: contain; }
.codes-list.expanded li { white-space: normal; overflow-wrap: anywhere; }
.codes-heading button:focus-visible, .codes-list:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.codes-empty { display: block; margin-top: var(--admin-space-1); }
</style>
@@ -0,0 +1,169 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import ElementPlus from 'element-plus'
import { listImpacts } from './impactApi'
vi.mock('./impactApi', async original => ({ ...await original<typeof import('./impactApi')>(), listImpacts: vi.fn(), refreshImpacts: vi.fn() }))
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningContainer, type PlanningSource } from './planningApi'
vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', customerName: '客户甲', orderId: '1', inboundCode: 'IN-A', containerBookingId: '2', containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseCode: 'US', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: null, shippedPackages: null, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: '2026-10-10T13:48:15', orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }
describe('appointment tables use actual shared table slots', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(listImpacts).mockImplementation(async () => {
const page = await vi.mocked(listPlanningContainers).mock.results.at(-1)!.value
return { total: page.total, records: page.records.map((container: PlanningContainer) => ({ container, impact: container.impact || null })) }
})
})
it('discovers external candidates lazily and mixes original bookings with selected first-leg cargo', async () => {
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
const external: PlanningSource = { ...source, sourceKey: 'BOOKING:B-1', customerId: 'B', orderId: null, inboundCode: null, bookingNo: 'B-1', packages: null }
vi.mocked(listExternalAppointmentCandidates).mockResolvedValue({ records: [{ source: external, customerName: '客户乙', tripNo: 'TR-1', coverage: { status: 'UNSCHEDULED', appointments: [], missingFields: [], changeReviewRequired: false } }], total: 1 })
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: null, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 1 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source, external], missingFields: [] })
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await flushPromises()
expect(listExternalAppointmentCandidates).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === '选择本页可合批货物')!.trigger('click')
await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises()
expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('B-1')
await wrapper.findAll('button').find(b => b.text() === '加入合批')!.trigger('click')
await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises()
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'BOOKING:B-1', customerId: 'B', destinationKey: 'FBA:ONT8' }])
expect(resolvePlanningSource).not.toHaveBeenCalled()
wrapper.unmount()
})
it('combines an original external booking with first-leg cargo and retries the identical save command', async () => {
const external: PlanningSource={...source,sourceKey:'BOOKING:B-1',customerId:'B',bookingNo:'B-1',orderId:null,inboundCode:null,packages:null}
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',destinationTimezone:null,sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']})
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({id:'1',version:0,appointmentNo:'AP-MIXED',appointmentStatus:'PENDING',originWarehouseId:'100',fbaWarehouseCode:'ONT8',selectedCount:2,items:[]})
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}})
await flushPromises()
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}])
await wrapper.get('[data-save]').trigger('click');await flushPromises()
const first=vi.mocked(createSourceAppointment).mock.calls[0]![0]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.find('[data-save]').exists()).toBe(true)
const done = vi.fn()
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
expect(done).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
expect(guard()).toBe(false)
}
expect(first).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}})
expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1')
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(createSourceAppointment).toHaveBeenNthCalledWith(2,first);expect(wrapper.emitted('created')).toHaveLength(1)
expect(resolvePlanningSource).not.toHaveBeenCalled();wrapper.unmount()
})
it('retains the current selection when a linked source has a different origin', async () => {
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
vi.mocked(previewPlanningBatch).mockRejectedValue(new Error('stop after observing request'))
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:{...source,sourceKey:'BOOKING:B-2',bookingNo:'B-2',originWarehouseId:'200'}},global:{plugins:[ElementPlus]}})
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
expect(wrapper.emitted('sourceAccepted')).toBeUndefined()
await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}]);wrapper.unmount()
})
it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => {
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo, { ...cargo, sourceKey: 'ORDER:2', orderId: '2', inboundCode: 'IN-B', customerId: 'B', customerName: '客户乙' }], page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] })
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click')
await wrapper.findAll('button').find(button => button.text().startsWith('合批核对'))!.trigger('click')
await flushPromises()
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'ORDER:2', customerId: 'B', destinationKey: 'FBA:ONT8' }])
expect(resolvePlanningSource).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱')
expect(wrapper.text()).toContain('保存前尚未占用货物')
expect(wrapper.text()).toContain('America/Los_Angeles')
expect(wrapper.text()).not.toContain('目的仓时区待核实')
expect(wrapper.text()).toContain('2026-10-10 13:48:15')
expect(wrapper.text()).toContain('来源时区未明确')
wrapper.unmount()
})
it('keeps the cargo list usable when impact service is unavailable and never reports a false zero', async () => {
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [] }] })
vi.mocked(listImpacts).mockRejectedValueOnce(new Error('Resource not found'))
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(1)
expect(wrapper.text()).toContain('CONTAINER-A')
expect(wrapper.text()).toContain('船期跟进暂不可用,货柜列表仍可查看')
expect(wrapper.text()).not.toContain('本页待处理 0')
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.findAll('details').every(node => node.attributes('open') === undefined)).toBe(true)
expect(listPlanningContainers).toHaveBeenCalledTimes(1); expect(listImpacts).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => {
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ impact: { containerBookingId: 'C1', impactNo: 'AI-T', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: 'A', ownerName: '运营甲', baseline: null, latest: null, forecastToken: 'x', updatedAt: '2026-10-10T00:00:00' }, containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] })
vi.mocked(resolvePlanningContainer).mockResolvedValue({ containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId: '100', warehouseName: '洛杉矶仓', canFollowup: true, blockReasons: [] })
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
await flushPromises()
expect(listPlanningContainers).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('本页待处理 1')
expect(wrapper.text()).toContain('处理船期变化')
expect(wrapper.text()).toContain('运营甲')
expect(resolvePlanningContainer).not.toHaveBeenCalled()
const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row')
expect(rows).toHaveLength(1)
expect(rows[0]!.text()).toContain('待核实')
expect(rows[0]!.text()).toContain('洛杉矶仓')
await rows[0]!.findAll('button').find(button => button.text() === '接仓跟进')!.trigger('click')
await flushPromises()
expect(resolvePlanningContainer).toHaveBeenCalledExactlyOnceWith('C1', '100')
expect(wrapper.findComponent(AppointmentFollowupDialog).props('container')?.warehouseId).toBe('100')
wrapper.unmount()
})
it('renders working source actions and readable progress through DataTable and RowActions', async () => {
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage })
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
await flushPromises()
const row = wrapper.get('.el-table__body-wrapper .el-table__row')
expect(row.text()).toContain('IN-A')
expect(row.text()).toContain('尚未建入仓计划')
const followup = row.findAll('button').find(button => button.text() === '跟进')
expect(followup).toBeDefined()
expect(row.findAll('button').some(button => button.text() === '查看来源')).toBe(true)
await followup!.trigger('click')
await flushPromises()
expect(resolvePlanningSource).toHaveBeenCalledExactlyOnceWith({ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' })
expect(wrapper.findComponent(AppointmentFollowupDialog).exists()).toBe(true)
wrapper.unmount()
})
it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => {
vi.mocked(getFollowups).mockResolvedValue({history:{records:[],total:0,page:1,size:10,pages:0},versions:{DRAYAGE_CONTACT:0,TERMINAL_APPOINTMENT:0,WAREHOUSE_RECEIVING:0,FBA_RECEIVING:0}})
const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /></section>'}}}})
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click')
expect(wrapper.emitted('arrange')).toHaveLength(1)
await wrapper.setProps({canArrange:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false)
await wrapper.setProps({canArrange:true,coverage:{...coverage,appointments:[{appointmentNo:'AP-EXISTING',confirmationNo:null,status:'PENDING',confirmedAt:null,confirmedEndAt:null,timezone:null}]}})
expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false);wrapper.unmount()
})
it('renders followup stage names instead of blank table cells', async () => {
vi.mocked(getFollowups).mockResolvedValue({ history: { records: [{ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '等待回复', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' }], total: 1, page: 1, size: 10, pages: 1 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 1 } })
const wrapper = mount(AppointmentFollowupDialog, { props: { source, coverage, canWrite: false }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await flushPromises()
const row = wrapper.get('.el-table__body-wrapper .el-table__row')
expect(row.text()).toContain('FBA收货跟进')
expect(row.text()).toContain('等待回复')
wrapper.unmount()
})
})
@@ -7,13 +7,36 @@
| 页面/操作 | 原型承载与布局 | 实现约束 |
|---|---|---|
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
| 新增预约 | 居中双列表单,原型最大宽度760px | 保留居中双列表单;为跨客户货物选择扩至1080px,在同弹框下方分页选择真实入仓货物 |
| 新增预约 | 居中表单,按用户简化要求使用1080px三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 |
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
| 删除预约 | 二次确认 | 显式提示全部货物解除预约关联;独立删除权限,已关联有效排车拒绝删除 |
| 取消预约 | 二次确认 | 显式提示全部货物释放占用、历史保留;沿用独立权限,已关联有效排车时先处理运输安排,不能自动回退库存 |
| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
| 多入仓码 | 用户2026-10-09补充截图:单预约20票导致两列详情被长字符串撑高 | 右侧卡片与编辑弹框共用独立整行入仓码列表;去空去重后显示总数,默认3条,长码悬停可读全文;超过3条可展开/收起,展开后216px内独立滚动并可键盘聚焦,不拼成长段文字 |
| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
| 整柜提柜与接仓 | 美国预约统一方案的整柜共享事项 | 独立工作视图按柜汇总,操作列左置;接仓点在行内分组,点击后共用1080px跟进弹框。整柜只选拖车/码头事项,接仓只记录对应仓库,单票只记录FBA联系;数量未知或范围不完整明确显示,不按客户复制整柜联系。首次只请求一页整柜数据,详情按点击读取;待1920×1080页面验收 |
| 在途跨客户合批安排 | 美国预约统一方案的同起运仓、同目的仓合批 | 操作列后选择同起运仓及同目的仓,跨页累计最多100票;批量核对后在1080px弹框填写与入仓入口共用的安排表单。未知箱数保留待核实;暂扣、多目的地和已有预约不可选入。保存必须重新核对,未知结果保留同一命令供重试 |
| 外接货进入合批 | 同仓跨客户共用预约,不因未入仓而只允许跟进 | 后端核实BOOKING深链后,“加入预约合批”进入现有合批工作区,再显式加入选择;可与同起运仓/目的仓头程货混选,保留原BOOKING键与未知箱数。跨仓不清空已有选择,已有关联预约不再开放新增入口;切换日历/合批工作视图保留本页选择,外接选择默认折叠、展开216px内滚动 |
| 完整外接候选 | 统一方案第九节两类货源均可筹划、合批及核对覆盖 | 在同一合批工作区切换头程/外接来源列表,外接按服务端分页、关键词筛选,展示原登记、客户、车次、接仓点、目的仓、可空箱数及预约覆盖。加入选择复用原100票合批选择与保存弹框;查看跟进沿用原弹框,不新增预约数据副本。切换来源保留选择,未打开外接列表不请求;待1920×1080验收 |
| 未定时安排 | 总览不得伪造时段 | 日历上方独立折叠分页列表;日期未定不补零点或09点,继续编辑仍操作原系统单号;仅有计划日期时日历显示日期,不生成确认时段 |
| 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 |
| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 |
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
| 船期变化影响与处理 | 统一方案第十节场景16,旧/新预测、下游影响、负责人及结果闭环 | 并入提柜与到仓的货柜列表,不设第四工作视图;行内处理船期变化,本页待办计数明确标识范围;显式本页批量核对,不逐柜轮询。1200px居中详情展示直接货物/预约、关联车次与同车待复核承诺;各明细220px内滚动,历史服务端分页。认领及结果表单复用现有控件;未知提交保留原命令并禁止关闭,已确认预约不因核对动作改变。待1920×1080真实验收 |
预约结束时间未明确时允许为空;不把预计装车时间当结束时间。预约号、目的仓和货物来源遵守服务端合同。日历范围最多42天,每次范围变化查询预约与到港各一次;选择范围内日期不发送请求。候选只在用户点击查询时分页读取,跨页选择保留,重新查询清空。
结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜、车次来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。
本轮视觉验收:待人工1920×1080截图确认。
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
按用户确认收敛:主视图仅提柜与到仓、送仓与排车、预约日历,默认有权限的列表视图;日历首次进入才加载。到港参考、用车计划默认隐藏,可独立开关,不计入预约数量;缺国家资料与查询超限分开提示。送仓与排车目前仍以选货合批为主,目的仓货量汇总及7/14/30天窗口尚未完成,不因标题调整宣称完成。
用户截图密度修正:业务标题复用density-detail-title并与三视图同行,不独占大标题区;船期操作并入查询栏,不单独占空白工具栏。常驻业务解释改为默认折叠“说明”;错误保留重试,诊断信息默认折叠。基础货柜与船期状态独立读取,后者失败不阻断货柜列表、不展示假的零待办。
用户最新截图确认:移除“美国预约总览”文字,仅保留三视图导航;日历图例和图层开关归入有内边距的辅助栏,长范围提示默认折叠,月格112px仍容纳3条摘要及剩余数;右侧固定360px,系统单号/完整时段/备注跨整行。该项以当前用户要求覆盖原标题建议,待1920×1080实际页面确认。
用户最新确认覆盖默认列表建议:预约日历排第一并默认打开,之后为提柜与到仓、送仓与排车;范围资料提示移入日历工具栏“数据提示”,点击展开浮层,不在日历上方占行。真实接口错误仍保留错误与重试入口。
用户确认待核实车次不占日历顶部:入口并入“数据提示”,显式点击后打开1000px居中分页明细;保留原UTC、缺项及查看来源操作,未打开不渲染明细,不增加查询;日期范围变化关闭旧明细。
+47 -2
View File
@@ -12,6 +12,29 @@ export interface ArrivalCalendarEntry {
arrivalType: 'ACTUAL' | 'ESTIMATED'
}
export interface PickupCalendarEntry {
containerBookingId: string
bookingNo: string
containerNo: string | null
destinationPortName: string | null
destinationWarehouseCode: string | null
fleetSupplierName: string | null
pickupAppointmentAt: string
actualPickupAt: string | null
}
export interface PickupCalendar {
records: PickupCalendarEntry[]
truncated: boolean
unclassifiedCount: number
scopeBasis: 'US_DESTINATION_WAREHOUSE'
timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'
}
export async function listPickupCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<PickupCalendar>, ApiEnvelope<PickupCalendar>>(
'/platform/v1/terminal-drayages/calendar', { params: query, signal },
))
}
export async function listArrivalCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<ArrivalCalendarEntry[]>, ApiEnvelope<ArrivalCalendarEntry[]>>(
'/platform/v1/port-schedules/arrival-calendar', { params: query, signal },
@@ -19,10 +42,32 @@ export async function listArrivalCalendar(query: { from: string; to: string }, s
return unwrapApiResponse(response)
}
export async function deleteAppointment(appointmentNo: string, version: number) {
export async function deleteAppointment(appointmentNo: string, version: number, cancelReason: string) {
const response = await http.delete<ApiEnvelope<void>, ApiEnvelope<void>>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
{ params: { version } },
{ data: { version, cancelReason } },
)
return unwrapApiResponse(response)
}
export interface AppointmentCancellation {
appointmentNo: string; version: number; cancelReason: string | null; cancelledAt: string
snapshot: { fbaWarehouseCode: string; appointmentStatus: string; appointmentRemark: string | null;
plannedDate: string | null; plannedAt: string | null; plannedEndAt: string | null;
confirmationNo: string | null; appointmentAt: string | null; appointmentEndAt: string | null;
timezone: string | null; selectedCount: number }
}
export async function listAppointmentCancellations(appointmentNo: string, page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: AppointmentCancellation[]; total: number}>, ApiEnvelope<{records: AppointmentCancellation[]; total: number}>>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}/cancellations`, {params: {page, size}},
))
return {...result, total: Number(result.total)}
}
export type AppointmentCancellationSummary = Omit<AppointmentCancellation, 'snapshot'> & {fbaWarehouseCode: string; selectedCount: number}
export async function listAppointmentCancellationRecords(page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: AppointmentCancellationSummary[]; total: number}>, ApiEnvelope<{records: AppointmentCancellationSummary[]; total: number}>>(
'/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations', {params: {page, size}},
))
return {...result, total: Number(result.total)}
}
@@ -1,5 +1,13 @@
export type CalendarMode = 'month' | 'week'
/** End times are exclusive; a slot ending at midnight does not occupy the next day. */
export function occursOnDate(startDate: string, endAt: string | undefined, date: string): boolean {
if (date === startDate) return true
if (date < startDate || !endAt) return false
const endDate = endAt.slice(0, 10)
return date < endDate || (date === endDate && /[1-9]/.test(endAt.slice(11)))
}
/** Calendar dates are warehouse-local dates, never UTC conversions. */
export function dateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
@@ -0,0 +1,31 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { PlanningContainer } from './planningApi'
export interface ForecastFact { estimatedAt: string | null; actualAt: string | null; observedAt: string | null; providerCode: string | null; sourceReference: string | null }
export interface Impact { containerBookingId: string; impactNo: string; version: number; status: string; reason: string | null; ownerName: string | null; ownerId: string | null; baseline: ForecastFact | null; latest: ForecastFact | null; forecastToken: string; updatedAt: string }
export interface ImpactRow { container: PlanningContainer; impact: Impact | null }
export interface ImpactDetail {
impact: Impact
cargo: Array<{ inboundCode: string | null; customerName: string | null; packages: number | null; destination: string | null }>
appointments: Array<{ appointmentNo: string; status: string; destination: string; confirmedAt: string | null; timezone: string | null }>
transport: { trips: Array<{ tripNo: string; status: string; plannedDepartureAt: string | null }>; sameTripBookings: Array<{ tripNo: string; bookingNo: string; customerName: string | null; destinationLabel: string | null; status: string }> } | null
history: { records: Array<{ version: number; action: string; operatorName: string | null; result: string | null; nextAction: string | null; followupDate: string | null; createdAt: string }>; total: number }
}
export interface ImpactCommand { expectedVersion: number; forecastToken: string; idempotencyKey: string; action: 'CLAIM' | 'RECORD' | 'RESOLVE'; result: string | null; nextAction: string | null; followupDate: string | null }
const base = '/platform/v1/last-leg/appointment-planning/impacts'
export async function listImpacts(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: ImpactRow[]; total: number }>, ApiEnvelope<{ records: ImpactRow[]; total: number }>>(base, { params: query, signal }))
return { ...result, total: Number(result.total) }
}
export async function refreshImpacts(query: { page: number; size: number; keyword?: string }) {
return unwrapApiResponse(await http.post<ApiEnvelope<Impact[]>, ApiEnvelope<Impact[]>>(`${base}/refresh`, query))
}
export async function getImpact(id: string, page = 1) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<ImpactDetail>, ApiEnvelope<ImpactDetail>>(`${base}/${encodeURIComponent(id)}`, { params: { page, size: 20 } }))
return { ...result, impact: { ...result.impact, version: Number(result.impact.version) }, history: { ...result.history, total: Number(result.history.total) } }
}
export async function handleImpact(id: string, command: ImpactCommand) {
return unwrapApiResponse(await http.post<ApiEnvelope<Impact>, ApiEnvelope<Impact>>(`${base}/${encodeURIComponent(id)}/commands`, command))
}
export const impactStatus: Record<string, string> = { OPEN: '待处理', RESOLVED: '已核对处理', MONITORING: '暂无变化' }
export const impactReason: Record<string, string> = { FORECAST_MISSING: '到港预测待核实', FORECAST_CHANGED: '到港预测变化', ACTUAL_ARRIVAL_REVIEW: '实际到港已更新', SOURCE_CHANGED: '预测来源变化', UNCHANGED: '预测未变', BASELINE_ESTABLISHED: '已建立核对基准' }
@@ -0,0 +1,22 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listPlanningCargo, listPlanningContainers } from './planningApi'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ sourceKey: 'ORDER:9007199254740993', orderId: '9007199254740993', estimatedArrivalAt: null }], page: '2', size: '20', total: '21', pages: '2' } })
const controller = new AbortController()
const result = await listPlanningCargo({ page: 2, size: 20, keyword: 'IN-A' }, controller.signal)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/cargo', { params: { page: 2, size: 20, keyword: 'IN-A' }, signal: controller.signal })
expect(result.records[0]!.orderId).toBe('9007199254740993')
expect(result.records[0]!.estimatedArrivalAt).toBeNull()
expect(result.total).toBe(21)
})
it('keeps basic container listing on its own endpoint rather than requiring impact persistence', async () => {
vi.mocked(http.get).mockClear()
vi.mocked(http.get).mockResolvedValue({ data: { records: [], total: '0' } })
const result = await listPlanningContainers({ page: 1, size: 20 })
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
expect(result.total).toBe(0)
})
@@ -0,0 +1,133 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FbaAppointmentDetailsPayload, FbaAppointmentDto } from '@/modules/overseas-warehouse'
export interface PlanningCargo {
sourceKey: string
orderId: string
customerId: string
customerName: string | null
inboundCode: string | null
containerBookingId: string
containerNo: string | null
originWarehouseId: string
originWarehouseCode: string
originWarehouseName: string
destinationWarehouseCode: string
packages: number | null
receivedPackages: number | null
shippedPackages: number | null
sourcePalletCount: number | null
volumeCbm: number | null
estimatedArrivalAt: string | null
estimatedWarehouseAt: string | null
orderVersion: number
standardNode: string
shippingVersion: string | null
shippingObservedAt: string | null
shippingProvider: string | null
hasInboundPlan: boolean
hasActiveAppointment: boolean
detained: boolean
mixedDestinations: boolean
}
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
)
const page = unwrapApiResponse(response)
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
}
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record<FollowupStage, number> }
export interface SaveFollowup { stage: FollowupStage; expectedVersion: number; idempotencyKey: string; contactedParty?: string; result: string; nextAction?: string; followupDate: string | null }
export async function getFollowups(sourceKey: string, customerId: string, page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<FollowupHistory>, ApiEnvelope<FollowupHistory>>(
`/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, { params: { customerId, page, size } },
))
return { ...result, history: { ...result.history, total: Number(result.history.total) }, versions: Object.fromEntries(Object.entries(result.versions).map(([stage, version]) => [stage, Number(version)])) as Record<FollowupStage, number> }
}
export async function saveFollowup(sourceKey: string, customerId: string, body: SaveFollowup) {
return unwrapApiResponse(await http.post<ApiEnvelope<FollowupRecord>, ApiEnvelope<FollowupRecord>>(
`/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, body, { params: { customerId } },
))
}
export interface PlanningSource {
sourceKey: string; customerId: string; orderId: string | null; inboundCode: string | null; bookingNo: string | null;
containerNo: string | null; originWarehouseId: string; originWarehouseName: string; destinationKey: string;
destinationWarehouseCode: string; packages: number | null; sourceVersion: string | null; canFollowup: boolean; blockReasons: string[];
}
export interface AppointmentCoverage {
status: string; missingFields: string[]; changeReviewRequired: boolean;
appointments: Array<{ appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }>;
}
export interface ResolvedPlanningSource { source: PlanningSource; coverage: AppointmentCoverage }
export interface ExternalAppointmentCandidate extends ResolvedPlanningSource { customerName: string | null; tripNo: string }
export async function listExternalAppointmentCandidates(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>, ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>>(
'/platform/v1/last-leg/appointment-planning/external-candidates', { params: query, signal },
))
return { ...result, total: Number(result.total) }
}
export interface PlanningBatchPreview {
group: { originWarehouseId: string; destinationKey: string; packages: number | null; sourceCount: number; customerCount: number; readinessIncomplete: boolean; readyFrom: string | null; readyTo: string | null; unknownQuantitySources: number }
originWarehouseName: string
destinationWarehouseCode: string
destinationTimezone: string | null
sources: PlanningSource[]
missingFields: string[]
}
export async function previewPlanningBatch(sources: Array<{sourceKey: string; customerId: string; destinationKey: string}>) {
const result = unwrapApiResponse(await http.post<ApiEnvelope<PlanningBatchPreview>, ApiEnvelope<PlanningBatchPreview>>(
'/platform/v1/last-leg/appointment-planning/batches/preview', { sources },
))
return { ...result, group: { ...result.group, packages: result.group.packages == null ? null : Number(result.group.packages), sourceCount: Number(result.group.sourceCount), customerCount: Number(result.group.customerCount) } }
}
export interface SourceAppointmentPayload { originWarehouseId: string; fbaWarehouseCode: string; sources: Array<{sourceKey: string; customerId: string; destinationKey: string}>; details: FbaAppointmentDetailsPayload; idempotencyKey: string }
export async function createSourceAppointment(payload: SourceAppointmentPayload) {
return unwrapApiResponse(await http.post<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>('/platform/v1/last-leg/appointment-planning/appointments', payload))
}
export async function listUnscheduledAppointments(page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: FbaAppointmentDto[]; total: number}>, ApiEnvelope<{records: FbaAppointmentDto[]; total: number}>>('/platform/v1/last-leg/appointment-planning/appointments/unscheduled', { params: { page, size } }))
return { ...result, total: Number(result.total) }
}
export async function resolvePlanningSource(query: { sourceKey: string; customerId: string; destinationKey?: string }) {
return unwrapApiResponse(await http.get<ApiEnvelope<ResolvedPlanningSource>, ApiEnvelope<ResolvedPlanningSource>>(
'/platform/v1/last-leg/appointment-planning/sources/resolve', { params: query },
))
}
export interface PlanningContainer {
impact?: import('./impactApi').Impact | null
containerBookingId: string; containerNo: string | null; orderCount: number; customerCount: number;
packages: number | null; unknownPackageOrders: number; earliestArrivalAt: string | null; latestArrivalAt: string | null;
standardNode: string; sourceVersion: string | null; incompleteScope: boolean;
warehouses: Array<{ warehouseId: string; warehouseName: string; orderCount: number; customerCount: number }>;
}
export interface ContainerFollowupContext {
containerBookingId: string; containerNo: string | null; warehouseId: string | null; warehouseName: string | null;
canFollowup: boolean; blockReasons: string[];
}
const containerBase = '/platform/v1/last-leg/appointment-planning/containers'
export async function listPlanningContainers(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: PlanningContainer[]; total: number }>, ApiEnvelope<{ records: PlanningContainer[]; total: number }>>(containerBase, { params: query, signal }))
return { ...result, total: Number(result.total), records: result.records.map(row => ({
...row, orderCount: Number(row.orderCount), customerCount: Number(row.customerCount),
packages: row.packages == null ? null : Number(row.packages), unknownPackageOrders: Number(row.unknownPackageOrders),
warehouses: row.warehouses.map(warehouse => ({ ...warehouse, orderCount: Number(warehouse.orderCount), customerCount: Number(warehouse.customerCount) })),
})) }
}
export async function resolvePlanningContainer(containerId: string, warehouseId?: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<ContainerFollowupContext>, ApiEnvelope<ContainerFollowupContext>>(`${containerBase}/${encodeURIComponent(containerId)}`, { params: { warehouseId } }))
}
export async function getContainerFollowups(context: ContainerFollowupContext, page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<FollowupHistory>, ApiEnvelope<FollowupHistory>>(`${containerBase}/${encodeURIComponent(context.containerBookingId)}/follow-ups`, { params: { warehouseId: context.warehouseId || undefined, page, size } }))
return { ...result, history: { ...result.history, total: Number(result.history.total) }, versions: Object.fromEntries(Object.entries(result.versions).map(([stage, version]) => [stage, Number(version)])) as Record<FollowupStage, number> }
}
export async function saveContainerFollowup(context: ContainerFollowupContext, body: SaveFollowup) {
return unwrapApiResponse(await http.post<ApiEnvelope<FollowupRecord>, ApiEnvelope<FollowupRecord>>(`${containerBase}/${encodeURIComponent(context.containerBookingId)}/follow-ups`, body, { params: { warehouseId: context.warehouseId || undefined } }))
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalendarEntry } from './tripCalendar'
import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
const range = { from: '2026-10-01', to: '2026-11-01' }
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
const localRange = {from:'2026-09-28',to:'2026-11-09'}
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
vi.mocked(http.get).mockResolvedValue({data:{records:[],unresolvedRecords:[]}})
await listTripCalendar(localRange)
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/calendar',{params:tripQueryWindow(localRange),signal:undefined})
expect(() => tripQueryWindow({from:'2026-10-01',to:'2026-11-20'})).toThrow()
})
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
})
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
})
it('retains original instants through DST overlap and does not replace the plan date with actual departure', () => {
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
expect(events.map(event=>event.time)).toEqual(['01:30','01:30'])
expect(events[0]).toMatchObject({date:'2026-11-01',subtitle:'运输计划,不代表收货方预约已确认'})
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
})
})
@@ -0,0 +1,83 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
export interface TripCalendarEntry {
sourceType: 'TRIP_PLAN'
tripNo: string
status: string
originWarehouseId: string | null
originWarehouseCode: string | null
originWarehouseName: string | null
originCountryCode: string | null
originTimezone: string | null
originWarehouseStatus: string | null
plannedDepartureAt: string | null
expectedDriverEndAt: string | null
departedAt: string | null
completedAt: string | null
stops: Array<{ name: string; address?: string | null; destinationKey?: string | null }>
incompleteReasons: string[]
}
export interface TripCalendarResult { records: TripCalendarEntry[]; unresolvedRecords: TripCalendarEntry[] }
export const tripIssueLabels: Record<string, string> = {
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
}
export function tripIssues(row: TripCalendarEntry) {
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
}
export function tripQueryWindow(range: { from: string; to: string }) {
const start = Date.parse(`${range.from}T00:00:00Z`), end = Date.parse(`${range.to}T00:00:00Z`)
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end - start > 42 * 86400000) throw new Error('日历日期范围无效')
// Query all possible location dates, then place each record using its actual origin zone.
return { from: new Date(start - 14 * 3600000).toISOString(), to: new Date(end + 14 * 3600000).toISOString() }
}
export async function listTripCalendar(range: { from: string; to: string }, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<TripCalendarResult>, ApiEnvelope<TripCalendarResult>>(
'/platform/v1/last-leg/fleet/trips/calendar', { params: tripQueryWindow(range), signal },
))
}
const formatters = new Map<string, Intl.DateTimeFormat>()
function localStamp(value: string | null, zone: string | null) {
if (!value || !/(Z|[+-]\d{2}:\d{2})$/.test(value) || !zone?.includes('/') || /^(Etc|SystemV)\//.test(zone)) return null
const instant = new Date(value)
if (!Number.isFinite(instant.getTime())) return null
try {
let formatter = formatters.get(zone)
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
formatters.set(zone, formatter)
}
const parts = Object.fromEntries(formatter.formatToParts(instant).map(part => [part.type, part.value]))
return { date: `${parts.year}-${parts.month}-${parts.day}`, time: `${parts.hour}:${parts.minute}` }
} catch { return null }
}
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
for (const row of result.records) {
const planned = localStamp(row.plannedDepartureAt, row.originTimezone)
if (row.originCountryCode !== 'US' || !planned) {
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'LOCAL_DATE_UNRESOLVED'] }); continue
}
if (planned.date < range.from || planned.date >= range.to) continue
const actual = localStamp(row.departedAt, row.originTimezone)
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: planned.date, time: planned.time,
title: `${row.tripNo} 用车计划`, subtitle: '运输计划,不代表收货方预约已确认',
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
fields: [
{ label: '车次状态', value: labels[row.status] || '待核实' },
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
{ label: '地点时区', value: row.originTimezone },
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
{ label: '路线', value: row.stops.slice(0, 3).map(stop => stop.name).join(' → ') + (row.stops.length > 3 ? ` 等${row.stops.length}站` : '') },
{ label: '实际出发', value: actual ? `${actual.date} ${actual.time}` : row.departedAt ? '时刻待核实' : '尚未记录' },
{ label: '预计司机结束', value: driverEnd ? `${driverEnd.date} ${driverEnd.time}` : '待核实' },
{ label: '资料提示', value: tripIssues(row) },
].filter(field => field.value),
})
}
return { events, unresolved }
}
@@ -1,5 +1,9 @@
# 海外业务原型核对矩阵
采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。
本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。
## 池来源本土订单承运安排
依据:[内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7节及本轮用户授权。
@@ -4,18 +4,19 @@ const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn(), post: vi.fn()
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: overseas }))
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
updateUsTruckOrder,
} from './platformOverseasBusiness'
import type { UpdateLocalTransportOrderRequest } from '../types'
import { readPendingProcurement } from '../procurementPending'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const purchaseSummary = {
id: '9007199254740993', orderNo: 'OP-1', customerId: '10001', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995',
purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 0, inventoryQuantity: 5, status: 'PENDING_OUTBOUND', version: 4,
purchaseWarehouseName: '采购仓', status: 'WAIT_PACKING', version: 4,
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
}
const truckSummary = {
@@ -34,6 +35,29 @@ const looseboardSummary = {
describe('platform overseas business API adapter', () => {
beforeEach(() => vi.clearAllMocks())
it('normalizes wire integer strings before building replayable procurement commands', async () => {
overseas.get.mockResolvedValue(envelope({
customerView: { order: { ...purchaseSummary, version: '0', purchaseQuantity: '1' }, boxes: [{ quantity: '1' }], shipments: [{ quantity: '1' }], fees: [{ version: '2' }] },
purchaseFacts: { purchased_quantity: '1' }, physical: { packingVersion: '3', dispositions: [{ id: '901', quantity: '1' }], receipts: [{ id: '902', goodQuantity: '1', damagedQuantity: '0', extraQuantity: '0' }], cartons: [{ id: '903', quantity: '1', sources: [{ receiptId: '902', quantity: '1' }] }] },
fees: [{ id: '904', feeVersion: '2' }], controls: { notes: [], remaining: [{ quantity: '1' }], closures: [], closureItems: [{ quantity: '1' }] }, files: [{ id: '905', fileId: '9007199254740995', sizeBytes: '123' }],
transport: { overseasCargo: [{ cartonId: '903', quantity: '1', sources: [{ receiptId: '902', quantity: '1' }] }], domesticCargo: [], overseasLabels: { id: '906', manifestId: '907', version: '4', labels: [{ cartonId: '903', fileId: '9007199254740995', page: '1' }] }, domesticLabels: null, shipments: [] },
}))
const detail = await getOverseasPurchase('OP-1', '10001')
const payload = { version: detail.customerView.order.version, idempotencyKey: 'review-wire', decision: 'APPROVE', purchaseWarehouseId: '9007199254740995' }
expect(() => readPendingProcurement(JSON.stringify({ command: 'review', payload }))).not.toThrow()
expect(detail.customerView.order.purchaseQuantity).toBe(1)
expect(detail.physical.receipts[0].goodQuantity + detail.physical.receipts[0].damagedQuantity).toBe(1)
expect(detail.physical.packingVersion).toBe(3)
expect(detail.physical.cartons[0].sources[0].quantity).toBe(1)
expect(detail.transport.overseasCargo[0].quantity).toBe(1)
expect(detail.transport.overseasLabels?.version).toBe(4)
expect(detail.transport.overseasLabels?.labels[0].page).toBe(1)
expect(detail.files[0]).toMatchObject({ fileId: '9007199254740995', sizeBytes: 123 })
expect(detail.fees[0].feeVersion).toBe(2)
expect(detail.controls.remaining[0].quantity).toBe(1)
expect(detail.controls.closureItems[0].quantity).toBe(1)
})
it('loads procurement candidates and normalizes procurement pages', async () => {
overseas.get
.mockResolvedValueOnce(envelope([{ customerId: '10001', customerName: '客户一' }]))
@@ -48,23 +72,24 @@ describe('platform overseas business API adapter', () => {
})
it('loads and patches procurement detail with three-state null payloads', async () => {
const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] }
overseas.get.mockResolvedValue(envelope(detail))
expect((await getOverseasPurchase('OP/1', '10001')).histories[0]?.operationType).toBe('UPDATE')
const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] }
overseas.get.mockResolvedValue(envelope({ customerView: { order: purchaseSummary, boxes: [], shipments: [], fees: [] }, reviewHistory: detail.histories, physical: { dispositions: [], receipts: [], cartons: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] } }))
expect((await getOverseasPurchase('OP/1', '10001')).reviewHistory[0]?.operationType).toBe('UPDATE')
overseas.patch.mockResolvedValue(envelope(detail))
const payload = { version: 4, productUrl: null, estimatedArrivalAt: null }
const payload = { version: 4, remark: null }
await updateOverseasPurchase('OP-1', '10001', payload)
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: '10001' } })
})
it('submits procurement review with version and idempotency data', async () => {
const detail = { summary: { ...purchaseSummary, status: 'APPROVED', version: 5 }, histories: [] }
overseas.post.mockResolvedValue(envelope(detail))
const payload = { version: 4, decision: 'APPROVE' as const, idempotencyKey: 'review-key', remark: '资料齐全' }
const result = await reviewOverseasPurchase('OP/1', '10001', payload)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/review', payload, { params: { customerId: '10001' } })
expect(result.status).toBe('APPROVED')
expect(result.version).toBe(5)
it('reads the aggregate editor context and strips obsolete mutation fields', async () => {
const detail = { customerView: { order: { orderNo: 'OP/1', customerId: '10001', productName: '外套', purchaseQuantity: 10, version: 4 } }, internalRemark: '内部' }
overseas.get.mockResolvedValue(envelope(detail))
expect(await getOverseasPurchaseEditor('OP/1', '10001')).toEqual(detail)
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { params: { customerId: '10001' } })
overseas.patch.mockResolvedValue(envelope({ summary: purchaseSummary }))
const obsolete = { version: 4, remark: '内部', inboundQuantity: 200, purchaseWarehouseId: 'other', productName: 'changed' }
await updateOverseasPurchase('OP/1', '10001', obsolete)
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { version: 4, remark: '内部' }, { params: { customerId: '10001' } })
})
it('supports platform truck list/detail/create/update without status endpoints', async () => {
@@ -1,12 +1,13 @@
import type { PlatformProcurement } from '../procurementTypes'
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardDetail, LocalLooseboardOrder,
LocalLooseboardQuery, LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery,
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail,
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption,
OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest,
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest, OverseasPurchaseOutboundDetail,
OverseasPurchaseFile, OverseasPurchaseFee, ReviewOverseasPurchaseRequest,
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest,
OverseasPurchaseEditorDetail,
} from '../types'
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
@@ -15,13 +16,7 @@ const looseboardBasePath = '/platform/v1/last-leg/local-looseboard-orders'
type Numeric = number | string
interface PagePayload<T> { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
interface ProcurementSummaryPayload extends Omit<OverseasPurchaseSummary, 'id' | 'purchaseWarehouseId' | 'budgetUnitPrice'> { id: Numeric; purchaseWarehouseId?: Numeric | null; budgetUnitPrice?: Numeric | null }
interface ProcurementDetailPayload {
summary: ProcurementSummaryPayload; productUrl?: string | null; receivingAddress?: string | null
contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null
histories?: OverseasBusinessHistory[]; outboundDetails?: OverseasPurchaseOutboundDetail[]; files?: any[]; fees?: any[]
}
interface ProcurementDetailPayload { summary: ProcurementSummaryPayload }
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
interface TruckDetailPayload {
@@ -37,11 +32,12 @@ interface LooseboardDetailPayload {
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
}
function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary {
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
function procurementInteger(value: unknown): number {
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('采购数据中的数量或版本无效,请刷新后重试')
return Number(value)
}
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [], outboundDetails: (value.outboundDetails || []).map((item) => ({ ...item, id: String(item.id), quantity: Number(item.quantity) })), files: (value.files || []).map((item) => ({ ...item, id: String(item.id), fileId: String(item.fileId), sizeBytes: Number(item.sizeBytes) })), fees: (value.fees || []).map((item) => ({ ...item, id: String(item.id), voucherFileId: String(item.voucherFileId), amount: String(item.amount) })) }
function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary {
return { ...value, version: procurementInteger(value.version), purchaseQuantity: procurementInteger(value.purchaseQuantity), id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
}
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
@@ -69,25 +65,54 @@ export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
return normalizePage(unwrapApiResponse(response), procurementSummary)
}
export async function getOverseasPurchase(orderNo: string, customerId: string) {
const response = await http.get<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
return procurementDetail(unwrapApiResponse(response))
const response = await http.get<ApiEnvelope<PlatformProcurement>, ApiEnvelope<PlatformProcurement>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
const detail = unwrapApiResponse(response)
detail.customerView.order.version = procurementInteger(detail.customerView.order.version)
detail.customerView.order.purchaseQuantity = procurementInteger(detail.customerView.order.purchaseQuantity)
for (const row of [...detail.customerView.boxes, ...detail.customerView.shipments]) row.quantity = procurementInteger(row.quantity)
for (const fee of detail.customerView.fees) if (fee.version != null) fee.version = procurementInteger(fee.version)
if (detail.purchaseFacts?.purchased_quantity != null) detail.purchaseFacts.purchased_quantity = procurementInteger(detail.purchaseFacts.purchased_quantity)
if (detail.physical.packingVersion != null) detail.physical.packingVersion = procurementInteger(detail.physical.packingVersion)
for (const row of detail.physical.receipts) {
row.goodQuantity = procurementInteger(row.goodQuantity); row.damagedQuantity = procurementInteger(row.damagedQuantity); row.extraQuantity = procurementInteger(row.extraQuantity)
}
for (const row of [...detail.physical.dispositions, ...detail.controls.remaining, ...detail.controls.closureItems]) row.quantity = procurementInteger(row.quantity)
for (const row of [...detail.physical.cartons, ...detail.transport.overseasCargo, ...detail.transport.domesticCargo]) {
row.quantity = procurementInteger(row.quantity)
for (const source of row.sources) source.quantity = procurementInteger(source.quantity)
}
for (const fee of detail.fees) fee.feeVersion = procurementInteger(fee.feeVersion)
for (const file of detail.files) file.sizeBytes = procurementInteger(file.sizeBytes)
if (detail.purchaseFacts?.actual_warehouse_id != null) detail.purchaseFacts.actual_warehouse_id = String(detail.purchaseFacts.actual_warehouse_id)
detail.physical.dispositions = detail.physical.dispositions.map(row => ({ ...row, id: String(row.id), source_carton_id: row.source_carton_id == null ? null : String(row.source_carton_id), receipt_id: row.receipt_id == null ? null : String(row.receipt_id), voucher_file_id: String(row.voucher_file_id) }))
detail.physical.receipts = detail.physical.receipts.map(row => ({ ...row, id: String(row.id), sourceCartonId: row.sourceCartonId == null ? null : String(row.sourceCartonId) }))
detail.physical.cartons = detail.physical.cartons.map(row => ({ ...row, id: String(row.id), sources: row.sources.map(source => ({ ...source, receiptId: String(source.receiptId) })) }))
detail.fees = detail.fees.map(fee => ({ ...fee, id: String(fee.id), parentFeeId: fee.parentFeeId == null ? null : String(fee.parentFeeId), sourceFeeId: fee.sourceFeeId == null ? null : String(fee.sourceFeeId), voucherFileId: fee.voucherFileId == null ? null : String(fee.voucherFileId), walletLedgerId: fee.walletLedgerId == null ? null : String(fee.walletLedgerId) }))
detail.transport.shipments = detail.transport.shipments.map(row => ({ ...row, id: String(row.id) }))
detail.controls.notes = detail.controls.notes.map(row => ({ ...row, id: String(row.id), target_id: row.target_id == null ? null : String(row.target_id), voucher_file_id: String(row.voucher_file_id), resolution_voucher_id: row.resolution_voucher_id == null ? null : String(row.resolution_voucher_id) }))
detail.controls.remaining = detail.controls.remaining.map(row => ({ ...row, sourceId: row.sourceId == null ? null : String(row.sourceId) }))
detail.controls.closures = detail.controls.closures.map(row => ({ ...row, id: String(row.id) }))
detail.controls.closureItems = detail.controls.closureItems.map(row => ({ ...row, id: String(row.id), closure_id: String(row.closure_id), source_id: row.source_id == null ? null : String(row.source_id), voucher_file_id: String(row.voucher_file_id) }))
detail.files = detail.files.map(file => ({ ...file, id: String(file.id), fileId: String(file.fileId) }))
for (const leg of ['overseasCargo', 'domesticCargo'] as const) detail.transport[leg] = detail.transport[leg].map(row => ({ ...row, cartonId: String(row.cartonId), packagingRecordId: row.packagingRecordId == null ? null : String(row.packagingRecordId), sources: row.sources.map(source => ({ ...source, receiptId: String(source.receiptId) })) }))
for (const labels of [detail.transport.overseasLabels, detail.transport.domesticLabels]) if (labels) {
labels.version = procurementInteger(labels.version)
for (const label of labels.labels) label.page = procurementInteger(label.page)
labels.id = String(labels.id); labels.manifestId = String(labels.manifestId)
labels.labels = labels.labels.map(row => ({ ...row, cartonId: String(row.cartonId), fileId: String(row.fileId) }))
}
return detail
}
export async function getOverseasPurchaseEditor(orderNo: string, customerId: string) {
const response = await http.get<ApiEnvelope<OverseasPurchaseEditorDetail>, ApiEnvelope<OverseasPurchaseEditorDetail>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
const detail = unwrapApiResponse(response)
detail.customerView.order.version = procurementInteger(detail.customerView.order.version)
detail.customerView.order.purchaseQuantity = procurementInteger(detail.customerView.order.purchaseQuantity)
return detail
}
export async function updateOverseasPurchase(orderNo: string, customerId: string, payload: UpdateOverseasPurchaseRequest) {
const response = await http.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
return procurementDetail(unwrapApiResponse(response))
}
export async function reviewOverseasPurchase(orderNo: string, customerId: string, payload: ReviewOverseasPurchaseRequest) {
const response = await http.post<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } })
return procurementDetail(unwrapApiResponse(response))
}
export async function uploadOverseasPurchaseFile(orderNo: string, customerId: string, category: 'RECEIPT' | 'VOUCHER', file: File) {
const body = new FormData(); body.append('file', file)
const response = await http.post<ApiEnvelope<OverseasPurchaseFile>, ApiEnvelope<OverseasPurchaseFile>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/files/${category}`, body, { params: { customerId } })
return unwrapApiResponse(response)
}
export async function createOverseasPurchaseFee(orderNo: string, customerId: string, payload: { feeItemCode: string; feeItemName: string; amount: number; voucherFileId: string; remark?: string }) {
const response = await http.post<ApiEnvelope<OverseasPurchaseFee>, ApiEnvelope<OverseasPurchaseFee>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/fees`, payload, { params: { customerId } })
return unwrapApiResponse(response)
const response = await http.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { version: payload.version, remark: payload.remark }, { params: { customerId } })
return procurementSummary(unwrapApiResponse(response).summary)
}
export async function listUsTruckOrders(query: LocalTransportQuery = {}) {
const response = await http.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } })
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: api }))
import { submitProcurementCommand, uploadProcurementFile, downloadProcurementFile, type ProcurementCommand } from './procurementCommands'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
beforeEach(() => vi.clearAllMocks())
describe('procurement node HTTP contract', () => {
it.each<ProcurementCommand>(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'control-notes', 'actual-warehouse/correct', 'exception-close'])('sends %s to the scoped order endpoint without interpreting money success', async command => {
api.post.mockResolvedValue(envelope({ status: 'WAIT_OVERSEAS_RECEIPT', purchase: { walletStatus: 'FREEZE_PENDING' } }))
const payload = { version: 3, idempotencyKey: 'same-command', reason: '已核对', amount: '9007199254740993.01' }
await submitProcurementCommand('OP/1', '10001', command, payload)
expect(api.post).toHaveBeenCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${command}`, payload, { params: { customerId: '10001' } })
})
it('posts additional expenses without a fee route id or inherited correction fields', async () => {
api.post.mockResolvedValue(envelope({}))
const payload = { version: 3, idempotencyKey: 'additional', reason: 'invoice', name: 'Storage', amount: '3.25', currency: 'USD' as const, businessDate: '2020-01-03', voucherFileId: '501' }
await submitProcurementCommand('OP/1', '10001', 'fees/additional', { ...payload, feeId: '11', targetOriginalAmount: '99' })
expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/additional', payload, { params: { customerId: '10001' } })
})
it('resolves the exact order-owned blocker and does not send the internal route reference as a mutable field', async () => {
api.post.mockResolvedValue(envelope({}))
await submitProcurementCommand('OP/1', '10001', 'control-notes/resolve', { version: 3, idempotencyKey: 'resolve-one', reason: '核对完成', noteId: '71', voucherFileId: '501' })
expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/control-notes/71/resolve', { version: 3, idempotencyKey: 'resolve-one', reason: '核对完成', voucherFileId: '501' }, { params: { customerId: '10001' } })
})
it('uses the exact fee endpoint and sends only fields belonging to that financial operation', async () => {
api.post.mockResolvedValue(envelope({}))
const base = { version: 3, idempotencyKey: 'finance-one', reason: '核对', feeId: '11' }
await submitProcurementCommand('OP/1', '10001', 'fees/correct-target', { ...base, targetOriginalAmount: '12.00', voucherFileId: '501', amount: 'unrelated' })
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/correct-target', { version: 3, idempotencyKey: 'finance-one', reason: '核对', targetOriginalAmount: '12.00', voucherFileId: '501' }, { params: { customerId: '10001' } })
await submitProcurementCommand('OP/1', '10001', 'fees/replace-failed', { ...base, targetOriginalAmount: '12.00', voucherFileId: '501' })
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/replace-failed', { version: 3, idempotencyKey: 'finance-one', reason: '核对', targetOriginalAmount: '12.00', voucherFileId: '501' }, { params: { customerId: '10001' } })
await submitProcurementCommand('OP/1', '10001', 'fees/refund', { ...base, amount: '1.01', voucherFileId: '501' })
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/refund', { version: 3, idempotencyKey: 'finance-one', reason: '核对', amount: '1.01', voucherFileId: '501' }, { params: { customerId: '10001' } })
for (const command of ['fees/retry', 'fees/terminate', 'fees/release-estimate'] as const) {
await submitProcurementCommand('OP/1', '10001', command, base)
expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/${command.slice(5)}`, { version: 3, idempotencyKey: 'finance-one', reason: '核对' }, { params: { customerId: '10001' } })
}
})
it('targets an original receipt and nests only the receipt contract plus correction evidence', async () => {
api.post.mockResolvedValue(envelope({}))
const receipt = { version: 3, idempotencyKey: 'correct-one', reason: '实收复核', leg: 'OVERSEAS' as const, warehouseId: '20', goodQuantity: 5, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00' }
await submitProcurementCommand('OP/1', '10001', 'receipts/correct', { ...receipt, receiptId: '11', voucherFileId: '901' })
expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/receipts/11/correct', { receipt: { ...receipt, sourceCartonId: undefined }, voucherFileId: '901' }, { params: { customerId: '10001' } })
await submitProcurementCommand('OP/1', '10001', 'dispositions/revoke', { version: 3, idempotencyKey: 'revoke-one', reason: '撤销错误处置', dispositionId: '71', voucherFileId: '901' })
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/dispositions/71/revoke', { version: 3, idempotencyKey: 'revoke-one', reason: '撤销错误处置', voucherFileId: '901' }, { params: { customerId: '10001' } })
})
it('uses purchase-purpose multipart evidence and normalizes file references', async () => {
api.post.mockResolvedValue(envelope({ id: 11, fileId: '9007199254740993', category: 'PURCHASE', fileName: 'purchase.pdf' }))
const file = new File(['pdf'], 'purchase.pdf', { type: 'application/pdf' })
expect(await uploadProcurementFile('OP/1', '10001', 'PURCHASE', file)).toMatchObject({ id: '11', fileId: '9007199254740993' })
const call = api.post.mock.calls[0]; expect(call[0]).toBe('/platform/v1/last-leg/overseas-procurements/OP%2F1/files/PURCHASE'); expect(call[1].get('file')).toBe(file); expect(call[2]).toEqual({ params: { customerId: '10001' } })
})
it('downloads binary files through the authenticated order boundary', async () => {
const blob = new Blob(['pdf'], { type: 'application/pdf' }); api.get.mockResolvedValue(blob)
expect(await downloadProcurementFile('OP/1', '10001', '901')).toBe(blob)
expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/files/901/content', { params: { customerId: '10001' }, responseType: 'blob' })
})
})
@@ -0,0 +1,62 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind } from '../procurementTypes'
export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close'
export interface ProcurementCommandBody {
name?: string
targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string
resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string
feeId?: string; targetOriginalAmount?: string; internationalFreight?: ProcurementFeeSelection; duty?: ProcurementFeeSelection; domesticFreight?: ProcurementFeeSelection
otherDetermination?: 'NOT_APPLICABLE' | 'ITEMIZED'; otherFees?: { code: string; name: string; selection: ProcurementFeeSelection }[]
version: number; idempotencyKey: string; reason?: string; decision?: 'APPROVE' | 'REJECT' | 'ACCEPT_SHORTAGE' | 'DISPOSE_DAMAGED' | 'RETURN_EXTRA'; remark?: string
purchaseWarehouseId?: string; warehouseId?: string; productName?: string; specification?: string
leg?: 'OVERSEAS' | 'DOMESTIC'; sourceCartonId?: string; receiptId?: string; dispositionId?: string
goodQuantity?: number; damagedQuantity?: number; extraQuantity?: number; receivedAt?: string
labelVersion?: number; receiver?: { name: string; phone: string; address: string }; addressVoucherFileId?: string
labels?: { cartonId: string; fileId: string; page: number; carrier: string; trackingNo: string }[]
cartonIds?: string[]; businessDate?: string; appliedAt?: string; shippedAt?: string; cartonId?: string
lengthCm?: string; widthCm?: string; heightCm?: string; grossWeightKg?: string
kind?: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; packingVersion?: number; cartons?: ProcurementPackingCarton[]
quantity?: number; amount?: string; currency?: 'CNY' | 'USD'; purchasedAt?: string; estimatedArrivalAt?: string; voucherFileId?: string
}
export interface ProcurementResolution { kind: ProcurementRemainingKind; sourceId: string | null; quantity: number; decision: string; destination: string; voucherFileId: string }
export interface ProcurementChargeAmount { determination: 'POSITIVE' | 'ZERO' | 'NOT_APPLICABLE'; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId?: string; reason: string }
export interface ProcurementFeeSelection { feeId?: string; value?: ProcurementChargeAmount }
export interface ProcurementPackingCarton { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number }[] }
const base = '/platform/v1/last-leg/overseas-procurements'
export async function submitProcurementCommand(orderNo: string, customerId: string, command: ProcurementCommand, payload: ProcurementCommandBody) {
let path: string = command; let body: unknown = payload
if (command === 'receipts/correct') {
if (!payload.receiptId || !/^[1-9]\d*$/.test(payload.receiptId)) throw new Error('缺少有效实收来源')
path = `receipts/${payload.receiptId}/correct`
body = { receipt: { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, leg: payload.leg, warehouseId: payload.warehouseId, sourceCartonId: payload.sourceCartonId, goodQuantity: payload.goodQuantity, damagedQuantity: payload.damagedQuantity, extraQuantity: payload.extraQuantity, receivedAt: payload.receivedAt }, voucherFileId: payload.voucherFileId }
} else if (command === 'dispositions/revoke') {
if (!payload.dispositionId || !/^[1-9]\d*$/.test(payload.dispositionId)) throw new Error('缺少有效差异处置来源')
path = `dispositions/${payload.dispositionId}/revoke`
body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, voucherFileId: payload.voucherFileId }
}
if (command === 'control-notes/resolve') {
if (!payload.noteId || !/^[1-9]\d*$/.test(payload.noteId)) throw new Error('缺少有效阻断异常来源')
path = `control-notes/${payload.noteId}/resolve`
body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, voucherFileId: payload.voucherFileId }
}
if (command === 'fees/additional') body = { version: payload.version, idempotencyKey: payload.idempotencyKey, name: payload.name, amount: payload.amount, currency: payload.currency, businessDate: payload.businessDate, voucherFileId: payload.voucherFileId, reason: payload.reason }
if (command.startsWith('fees/') && command !== 'fees/additional') {
if (!payload.feeId || !/^[1-9]\d*$/.test(payload.feeId)) throw new Error('缺少有效费用来源')
path = `fees/${payload.feeId}/${command.slice(5)}`
body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason,
...(['fees/correct-target', 'fees/replace-failed'].includes(command) ? { targetOriginalAmount: payload.targetOriginalAmount, voucherFileId: payload.voucherFileId } : {}),
...(command === 'fees/refund' ? { amount: payload.amount, voucherFileId: payload.voucherFileId } : {}) }
}
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`${base}/${encodeURIComponent(orderNo)}/${path}`, body, { params: { customerId } })
unwrapApiResponse(response)
}
export async function uploadProcurementFile(orderNo: string, customerId: string, category: ProcurementFileCategory, file: File) {
const data = new FormData(); data.append('file', file)
const response = await http.post<ApiEnvelope<ProcurementFile>, ApiEnvelope<ProcurementFile>>(`${base}/${encodeURIComponent(orderNo)}/files/${category}`, data, { params: { customerId } })
const result = unwrapApiResponse(response)
return { ...result, id: String(result.id), fileId: String(result.fileId) }
}
export async function downloadProcurementFile(orderNo: string, customerId: string, fileId: string) {
return http.get<Blob, Blob>(`${base}/${encodeURIComponent(orderNo)}/files/${encodeURIComponent(fileId)}/content`, { params: { customerId }, responseType: 'blob' })
}
@@ -0,0 +1,29 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import type { ChargeDraft } from '../procurementFinanceForm'
import { money } from '../procurementPresentation'
const props = defineProps<{ detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<ChargeDraft>({ required: true })
const existing = computed(() => props.detail.fees.filter(row => row.active && row.purpose === form.value.purpose && (row.purpose !== 'OTHER' || row.feeItemCode === form.value.code)))
const selected = computed(() => existing.value.find(row => row.id === form.value.feeId))
function determine() { form.value.amount = form.value.determination === 'POSITIVE' ? '' : form.value.determination ? '0.00' : '' }
</script>
<template>
<div class="admin-density charge-fields">
<template v-if="form.mode === 'EXISTING'"><el-form-item label="引用已核定版本" required><el-select v-model="form.feeId" placeholder="选择本单该用途的有效费用"><el-option v-for="row in existing" :key="row.id" :value="row.id" :label="`${row.feeNo} · ${money(row.originalAmount, row.originalCurrency)}`" /></el-select></el-form-item><dl v-if="selected" class="density-fields"><div><dt>原费用版本</dt><dd>{{ selected.feeVersion }}</dd></div><div><dt>原业务日期</dt><dd>{{ selected.businessDate }}</dd></div></dl><p>本次仅核对并引用原费用,不重复扣款。金额更正请使用财务调整。</p></template>
<el-alert v-else-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本项缺少有效版本,请先由财务完成替代核定。" type="warning" :closable="false" />
<template v-else>
<div class="charge-grid"><el-form-item label="核定结果" required><el-select v-model="form.determination" placeholder="明确正额、零元或不适用" @change="determine"><el-option value="POSITIVE" label="确认正额" /><el-option value="ZERO" label="确认零元" /><el-option value="NOT_APPLICABLE" label="不适用" /></el-select></el-form-item><el-form-item label="原币金额" required><el-input v-model="form.amount" :readonly="form.determination === 'ZERO' || form.determination === 'NOT_APPLICABLE'" placeholder="填写实际金额,最多两位小数" /></el-form-item><el-form-item label="币种" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
<el-form-item label="本项费用凭证" :required="form.determination === 'POSITIVE'"><el-select v-model="form.voucherFileId" clearable placeholder="选择已上传的本单费用凭证"><el-option v-for="file in files.filter(row => row.category === 'VOUCHER')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
<el-form-item label="本项核定说明" required><el-input v-model="form.reason" maxlength="500" placeholder="说明计费依据;零元或不适用也需说明" /></el-form-item>
</template>
</div>
</template>
<style scoped>
.charge-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.charge-fields p { color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media (max-width: 760px) { .charge-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import { allowedDecisions, decisionLabels, remainingLabels, type ControlForm } from '../procurementControlForm'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<ControlForm>({ required: true })
const proofs = computed(() => props.files.filter(file => file.category === 'EXCEPTION'))
const blockers = computed(() => props.detail.controls.notes.filter(note => note.blocking && !note.resolved_at))
</script>
<template>
<div class="admin-density">
<template v-if="command === 'control-notes'">
<el-alert title="追加说明保留原购买、仓库、实收及交寄事实。需要阻断时,正式装箱、预估核对、核费及交寄须先处理异常。" type="info" :closable="false" />
<el-form-item label="关联事实类型" required><el-select v-model="form.targetType" placeholder="选择需要留痕的事实" @change="form.targetId = ''"><el-option value="PURCHASE" label="真实购买" /><el-option value="WAREHOUSE" label="实际收货仓" /><el-option value="RECEIPT" label="实收记录" /><el-option value="SHIPMENT" label="实际交寄记录" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'SHIPMENT'" label="关联实际交寄" required><el-select v-model="form.targetId" placeholder="选择本单实际交寄记录"><el-option v-for="row in detail.transport.shipments" :key="row.id" :value="row.id" :label="`${row.shipmentNo} · ${row.leg === 'OVERSEAS' ? '国际段' : '寄客户段'}`" /></el-select></el-form-item>
<dl v-if="form.targetType === 'WAREHOUSE'" class="density-fields"><div><dt>当前实际仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚无实际仓' }}</dd></div></dl>
<el-form-item label="是否阻断后续正式作业" required><el-radio-group v-model="form.blocking"><el-radio :value="true">需要阻断</el-radio><el-radio :value="false">仅追加说明</el-radio></el-radio-group></el-form-item>
<el-form-item label="对客摘要(客户可见,选填)"><el-input v-model="form.clientSummary" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="仅填写允许客户看到的处理摘要" /></el-form-item>
</template>
<template v-if="command === 'control-notes/resolve'"><el-alert title="解除阻断须填写处理依据并关联本单凭证,原说明与处理记录均保留。" type="info" :closable="false" /><el-form-item label="待解除阻断" required><el-select v-model="form.noteId" placeholder="选择本单未处理的阻断异常"><el-option v-for="note in blockers" :key="note.id" :value="note.id" :label="`${note.note_no} · ${note.reason.slice(0, 60)}`" /></el-select></el-form-item></template>
<template v-if="command === 'actual-warehouse/correct'"><el-alert title="已有箱明细、交寄记录或订单已结束时,本操作仅追加更正说明和阻断,不重新归属货物。是否修改有效仓以服务端历史记录为准。" type="warning" :closable="false" /><dl class="density-fields"><div><dt>原实际收货仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚未记录' }}</dd></div></dl></template>
<template v-if="command === 'exception-close'">
<el-alert title="确认全部实物去向和资金处理后关闭。此操作不自动退款、不生成退货物流,不覆盖原履约事实。" type="warning" :closable="false" />
<section v-for="row in form.resolutions" :key="`${row.kind}:${row.sourceId || ''}`" class="resolution-row"><h3>{{ remainingLabels[row.kind] }} · {{ row.businessNo || '—' }} · {{ row.quantity }} 件</h3><div class="resolution-grid"><el-form-item label="实际处置" required><el-select v-model="row.decision" placeholder="明确实际处置结果"><el-option v-for="value in allowedDecisions[row.kind]" :key="value" :value="value" :label="decisionLabels[value]" /></el-select></el-form-item><el-form-item label="本项处置凭证" required><el-select v-model="row.voucherFileId" placeholder="选择本单对应凭证"><el-option v-for="file in proofs" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item></div><el-form-item label="实际去向及接收方" required><el-input v-model="row.destination" maxlength="500" placeholder="填写货物实际去向、接收方或确认丢失的依据" /></el-form-item></section>
<p v-if="!form.resolutions.length">服务端当前无待处置的剩余货物,仍需核对客户停止履约凭证和资金处理结论。</p>
<el-form-item label="客户同意停止履约凭证" required><el-select v-model="form.customerVoucherFileId" placeholder="选择本单客户确认凭证"><el-option v-for="file in proofs" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
<el-form-item label="财务处理结论(仅平台可见)" required><el-input v-model="form.financialConclusion" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="说明扣款、调整、退款及冻结释放的最终处理结果" /></el-form-item>
<el-form-item label="对客关闭摘要(客户可见)" required><el-input v-model="form.clientSummary" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="填写允许客户看到的最终处理结论" /></el-form-item>
</template>
</div>
</template>
<style scoped>
.resolution-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }
.resolution-row { padding-block: var(--admin-space-3); border-bottom: 1px solid var(--cc-border); }
.resolution-row h3 { font-size: var(--admin-font-section); }
:deep(.el-select) { width: 100%; }
@media (max-width: 760px) { .resolution-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, watch } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import { chargeDraft, feeRetryPermission, refundAvailable, targetPreview, replacementCandidates, replacementPreview, type FinanceForm } from '../procurementFinanceForm'
import { money, decimal, feePurposes } from '../procurementPresentation'
import { commandKey } from '../procurementPending'
import ProcurementChargeFields from './ProcurementChargeFields.vue'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[]; can: (permission: string) => boolean }>()
const form = defineModel<FinanceForm>({ required: true })
const candidates = computed(() => props.detail.fees.filter(fee => {
if (props.command === 'fees/replace-failed') return replacementCandidates(props.detail).some(row => row.id === fee.id)
if (props.command === 'fees/retry') return fee.active && fee.walletStatus.endsWith('_FAILED') && props.can(feeRetryPermission(fee))
if (props.command === 'fees/terminate') return fee.active && fee.walletStatus.endsWith('_FAILED')
if (props.command === 'fees/release-estimate') return fee.active && fee.purpose === 'INTERNATIONAL_ESTIMATE' && ['FROZEN', 'ZERO'].includes(fee.walletStatus)
if (props.command === 'fees/correct-target') return fee.active && !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)
return fee.walletStatus === 'SETTLED' && !!fee.walletLedgerId && fee.purpose !== 'INTERNATIONAL_ESTIMATE'
}))
watch(candidates, rows => { if (form.value.feeId && !rows.some(row => row.id === form.value.feeId)) form.value.feeId = '' })
const selected = computed(() => props.detail.fees.find(row => row.id === form.value.feeId))
const preview = computed(() => {
if (!selected.value) return null
try {
if (props.command === 'fees/replace-failed' && form.value.targetOriginalAmount) return { ...replacementPreview(props.detail, selected.value, form.value.targetOriginalAmount), error: '' }
if (props.command === 'fees/correct-target' && form.value.targetOriginalAmount) return { ...targetPreview(props.detail, selected.value, form.value.targetOriginalAmount), error: '' }
if (props.command === 'fees/refund') return { refundable: decimal(refundAvailable(props.detail, selected.value)), error: '' }
} catch (error) { return { error: error instanceof Error ? error.message : '请核对原费用' } }
return null
})
function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = chargeDraft('OTHER', undefined, `OTHER_${commandKey().slice(0, 20).toUpperCase()}`); row.name = ''; form.value.others.push(row) }
</script>
<template>
<div class="admin-density">
<template v-if="command === 'charges/confirm'">
<el-alert v-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本单已完成过核费,本次只引用已有费用版本;新增费用或重新核定请使用财务操作。" type="info" :closable="false" />
<el-alert title="一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<section class="charge-section"><h3>其他费用</h3><el-form-item label="其他费用适用情况" required><el-radio-group v-model="form.otherDetermination"><el-radio value="NOT_APPLICABLE" :disabled="detail.fees.some(row => row.active && row.purpose === 'OTHER')">不适用</el-radio><el-radio value="ITEMIZED">逐项核定</el-radio></el-radio-group></el-form-item>
<template v-if="form.otherDetermination === 'ITEMIZED'"><section v-for="(row, index) in form.others" :key="row.code" class="other-charge"><div class="charge-heading"><el-form-item label="费用名称" required><el-input v-model="row.name" :readonly="row.mode === 'EXISTING'" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item><el-button v-if="row.mode === 'NEW'" class="density-action" data-tone="danger" @click="form.others.splice(index, 1)">移除新费用</el-button></div><ProcurementChargeFields v-model="form.others[index]" :detail="detail" :files="files" /></section><el-button v-if="!detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" class="density-action" @click="addOther">增加其他费用</el-button></template>
</section>
</template>
<template v-else-if="command === 'fees/additional'">
<el-alert title="登记独立新发生的正额费用,使用该业务日期的汇率。与原费用金额更正分开处理;已完成订单保留原履约状态。" type="info" :closable="false" />
<el-form-item label="独立费用名称" required><el-input v-model="form.additional.name" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item>
<el-form-item label="新增费用原币金额" required><el-input v-model="form.additional.amount" placeholder="填写正额金额,最多两位小数" /></el-form-item>
<el-form-item label="新增费用币种" required><el-select v-model="form.additional.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item>
<el-form-item label="新增费用业务日期" required><el-date-picker v-model="form.additional.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="新增费用凭证" required><el-select v-model="form.additional.voucherFileId" placeholder="选择本单已上传的费用凭证"><el-option v-for="file in files.filter(row => row.category === 'VOUCHER')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
</template>
<template v-else>
<el-alert v-if="command === 'fees/replace-failed'" title="原费用须已明确失败并完成终止及冻结释放。本次按核定总额创建新的实际费用版本,沿用原汇率和业务日期;货款仍受客户预算限制。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/correct-target'" title="输入这条原费用链正确的原币总额。沿用原汇率,按目标与净实扣差额生成追加或逐笔退款;历史扣款不覆盖。" type="info" :closable="false" />
<el-alert v-if="command === 'fees/refund'" title="退款回到客户资金账户,关联选中的原成功扣款;不以普通充值替代,不自动退款其他费用。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/terminate'" title="仅终止明确失败的费用版本;仍有有效冻结时先释放,释放成功后才可重新核定,不能将未决结果当作失败。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/release-estimate'" title="释放有效预估冻结不产生退款或实际收费,后续交寄须重新满足预估核对条件。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/retry'" title="仅重试已明确失败的原资金请求,金额与费用版本不改变;处理中请刷新结果。" type="info" :closable="false" />
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${feePurposes[fee.purpose] || fee.feeItemName} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<dl v-if="selected" class="density-fields"><div><dt>原币金额</dt><dd>{{ money(selected.originalAmount, selected.originalCurrency) }}</dd></div><div><dt>原汇率及月份</dt><dd>{{ selected.exchangeRate }} / {{ selected.exchangeRateMonth }}</dd></div></dl>
<el-form-item v-if="['fees/correct-target', 'fees/replace-failed'].includes(command)" :label="`正确原币总额(${selected?.originalCurrency || '先选择费用'})`" required><el-input v-model="form.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item>
<el-form-item v-if="command === 'fees/refund'" label="本次退款金额(CNY)" required><el-input v-model="form.refundAmount" placeholder="填写本次正额退款金额" /></el-form-item>
<el-alert v-if="preview?.error" :title="preview.error" type="warning" :closable="false" />
<dl v-else-if="preview" class="density-fields"><template v-if="'target' in preview"><div><dt>目标人民币金额</dt><dd>{{ money(preview.target, 'CNY') }}</dd></div><div><dt>当前净实扣</dt><dd>{{ money(preview.net, 'CNY') }}</dd></div><div><dt>本次差额</dt><dd>{{ money(preview.difference, 'CNY') }}</dd></div></template><div v-if="'refundable' in preview"><dt>该笔剩余可退</dt><dd>{{ money(preview.refundable, 'CNY') }}</dd></div></dl>
</template>
</div>
</template>
<style scoped>
.charge-section { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
.charge-section h3 { font-size: var(--admin-font-section); margin: 0 0 var(--admin-space-3); }
.charge-heading { display: flex; align-items: center; gap: var(--admin-space-3); }
.charge-heading :deep(.el-form-item) { flex: 1; }
.other-charge { padding-bottom: var(--admin-space-3); }
:deep(.el-select) { width: 100%; }
</style>
@@ -0,0 +1,308 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import ProcurementNodeActions from './ProcurementNodeActions.vue'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
import { readPendingProcurement } from '../procurementPending'
import type { PlatformProcurement } from '../procurementTypes'
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
const storageKey = 'procurement-command:7:10001:OP-100'
function storage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: key => values.get(key) ?? null, key: index => [...values.keys()][index] ?? null, removeItem: key => values.delete(key), setItem: (key, value) => values.set(key, value) } }
function fixture(key = 'REVIEW', allowed = true): PlatformProcurement {
const keys: Record<string, [string, string]> = { REVIEW: ['审核采购', 'review'], PURCHASE: ['登记购买完成', 'buy'], AUTHORIZE_BUDGET: ['核验预算上限', 'review'], PLAN_WAREHOUSE: ['修改计划仓', 'review'], CANCEL: ['购买前取消', 'cancel'], RECEIVE_OVERSEAS: ['登记海外实收', 'receive-overseas'], FINISH_DOMESTIC: ['确认国内收货完成', 'receive-domestic'], PACK: ['保存装箱草稿', 'pack'], MANAGE: ['事实更正及异常处置', 'manage'], LABELS: ['维护两段面单', 'label'], ESTIMATE: ['核对预估运费', 'estimate'], SHIP_OVERSEAS: ['确认海外交寄', 'ship-overseas'], CONFIRM_CHARGES: ['确认国内费用', 'charge'], FINANCE_ADJUST: ['费用更正与退款', 'finance-adjust'], EXCEPTION_CLOSE: ['异常关闭', 'manage'] }
const [label, suffix] = keys[key]
return {
customerView: { order: { id: '1', orderNo: 'OP-100', customerId: '10001', customerName: '测试客户', purchaseCategory: '家居', productName: '样品商品', productSpecification: '黑色', productUrl: null, purchaseQuantity: 2, budgetUnitPrice: '10.00', currency: 'USD', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: null, actualWarehouseCode: null, actualWarehouseName: null, status: 'PENDING_REVIEW', version: 3, createdAt: '', updatedAt: '' }, budget: { totalAmount: '20.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: [], exceptionSummaries: [] },
purchaseFacts: {}, physical: { dispositions: [], receipts: [], cartons: [], packingVersion: null, packingStatus: null }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [{ key, label, permission: `platform:overseas-business:purchase:${suffix}`, allowed, reason: allowed ? null : '业务条件不满足' }], internalRemark: null,
}
}
function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'receive-overseas', 'receive-domestic', 'pack', 'manage', 'label', 'estimate', 'ship-overseas', 'charge', 'finance-adjust']) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.token = 'test-token'; auth.permissions = permissions.map(item => `platform:overseas-business:purchase:${item}`); useAccessPreviewStore().initializeAfterLogin()
return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } })
}
async function open(wrapper: ReturnType<typeof render>) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) }
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) }
async function confirm(wrapper: ReturnType<typeof render>) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() }
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => {
vi.stubGlobal('sessionStorage', storage()); vi.stubGlobal('localStorage', storage()); document.body.innerHTML = ''
vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外收货仓', countryCode: 'US' }])
vi.mocked(submitProcurementCommand).mockReset().mockResolvedValue(undefined)
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
})
describe('procurement purchase node operations', () => {
it('disables terminal physical changes and keeps the management evidence form open', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER'
value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' }
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02', active: true }]
const wrapper = render(value); const dialog = await open(wrapper)
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' })
expect(selector.props('modelValue')).toBe('control-notes')
const options = selector.findAllComponents({ name: 'ElOption' })
expect(options.find(option => option.props('value') === 'receipts/correct')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('订单已结束') })
expect(options.find(option => option.props('value') === 'control-notes')!.props('disabled')).toBe(false)
expect(options.find(option => option.props('value') === 'actual-warehouse/correct')!.props('disabled')).toBe(false)
selector.vm.$emit('update:modelValue', 'receipts/correct'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
await confirm(wrapper)
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('disables overseas receipt candidates after international shipment without blocking domestic correction', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'DOMESTIC_RECEIVING'
value.purchaseFacts.overseas_shipped_at = '2020-01-02'
const receipt = { id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS' as const, sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-01', active: true }
value.physical.receipts = [receipt, { ...receipt, id: '12', receiptNo: 'PR-12', leg: 'DOMESTIC', sourceCartonId: '31' }]
const wrapper = render(value); const dialog = await open(wrapper)
dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'receipts/correct')
await flushPromises()
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '待更正实收')!.findComponent({ name: 'ElSelect' })
const options = selector.findAllComponents({ name: 'ElOption' })
expect(options.find(option => option.props('value') === '11')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('本段已实际交寄') })
expect(options.find(option => option.props('value') === '12')!.props('disabled')).toBe(false)
selector.vm.$emit('update:modelValue', '12'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(submitProcurementCommand).not.toHaveBeenCalled()
})
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
const value = fixture('FINANCE_ADJUST', false)
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
const original = JSON.stringify({ command: 'fees/correct-target', payload: { version: 3, idempotencyKey: 'original-request', feeId: '11', targetOriginalAmount: '11.00', reason: '原请求', voucherFileId: '501' } })
sessionStorage.setItem(storageKey, original)
let wrapper = render(value)
const click = async (label: string) => { await wrapper.findAll('button').find(button => button.text() === label)!.trigger('click'); await flushPromises() }
await click('准备费用更正草稿')
const dialog = wrapper.findComponent(AppDialog)
dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '原费用')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11')
await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00')
await explain(wrapper, '准备下一版本')
await click('保存本地草稿')
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(sessionStorage.getItem(storageKey)).toBe(original)
expect(JSON.parse(sessionStorage.getItem(`${storageKey}:correction-draft`)!)).toMatchObject({ targetOriginalAmount: '12.00', feeId: '11', reason: '准备下一版本' })
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeDefined()
wrapper.unmount(); wrapper = render(value)
await click('恢复费用更正草稿')
expect((wrapper.findComponent(AppDialog).find('input[placeholder="填写原费用链正确总额,最多两位小数"]').element as HTMLInputElement).value).toBe('12.00')
await click('确认上次提交结果')
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/correct-target', JSON.parse(original).payload)
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull()
const latest = structuredClone(value)
latest.actions[0].allowed = true; latest.customerView.order.version = 9
latest.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }]
await wrapper.setProps({ detail: latest })
await click('恢复费用更正草稿'); await click('核对并继续确认')
wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '本单凭证')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(AppDialog).text()).toContain('原实际费用及来源关联尚未完成')
latest.fees[0].walletStatus = 'SETTLED'; latest.fees[0].walletLedgerId = 'LEDGER-1'
latest.customerView.fees = [{ feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', name: '样品货款', version: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeNo: null, walletStatus: 'SETTLED', sourceLinked: true }]
await wrapper.setProps({ detail: structuredClone(latest) })
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenLastCalledWith('OP-100', '10001', 'fees/correct-target', expect.objectContaining({ version: 9, targetOriginalAmount: '12.00', feeId: '11' }))
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).toBeNull()
// Restore a saved draft before changing the scope to ensure isolation is meaningful.
sessionStorage.setItem(`${storageKey}:correction-draft`, JSON.stringify({ feeId: '11', targetOriginalAmount: '13.00', reason: '下一目标', voucherFileId: '501', orderVersion: 9 }))
await wrapper.setProps({ customerId: 'OTHER' }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
await wrapper.setProps({ customerId: '10001', detail: { ...value, customerView: { ...value.customerView, order: { ...value.customerView.order, orderNo: 'OTHER' } } } }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
useAuthStore().user = { id: '8', name: '另一操作员', account: 'other', roles: [] }
await wrapper.setProps({ detail: value }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
})
it('requires explicit remaining-goods outcomes and keeps private closure reasons separate', async () => {
const value = fixture('EXCEPTION_CLOSE'); value.purchaseFacts.purchased_at = '2020-01-01T10:00:00'
value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-100', quantity: 2 }]
value.files = [{ id: '51', fileId: '501', category: 'EXCEPTION', fileName: 'agreement.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }]
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '内部客户沟通及供应商处理依据')
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
field('实际处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SUPPLIER_RETAINED')
field('本项处置凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
await field('实际去向及接收方').find('input').setValue('供应商原仓留存')
field('客户同意停止履约凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
await field('财务处理结论(仅平台可见)').find('textarea').setValue('全部退款及冻结释放已处理')
await field('对客关闭摘要(客户可见)').find('textarea').setValue('货物由供应商保留,停止后续寄送')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'exception-close', expect.objectContaining({ resolutions: [{ kind: 'PURCHASE_REMAINDER', sourceId: null, quantity: 2, decision: 'SUPPLIER_RETAINED', destination: '供应商原仓留存', voucherFileId: '501' }], customerVoucherFileId: '501', financialConclusion: '全部退款及冻结释放已处理', clientSummary: '货物由供应商保留,停止后续寄送' }))
expect(dialog.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
})
it('confirms three explicit zero determinations and marks other charges not applicable in one request', async () => {
const value = fixture('CONFIRM_CHARGES'); value.purchaseFacts.domestic_received_at = '2020-01-04T10:00:00'
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '整单费用核对完成')
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
const groups = dialog.findAllComponents({ name: 'ProcurementChargeFields' }); expect(groups).toHaveLength(3)
for (const group of groups) {
const determination = group.findAllComponents({ name: 'ElSelect' })[0]; determination.vm.$emit('update:modelValue', 'ZERO'); determination.vm.$emit('change', 'ZERO')
group.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-04')
await group.find('input[placeholder="说明计费依据;零元或不适用也需说明"]').setValue('本项费用免收')
}
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
const [number, customer, command, payload] = vi.mocked(submitProcurementCommand).mock.calls[0]
expect([number, customer, command]).toEqual(['OP-100', '10001', 'charges/confirm'])
for (const selection of [payload.internationalFreight, payload.duty, payload.domesticFreight]) expect(selection).toEqual({ value: { determination: 'ZERO', amount: '0.00', currency: 'CNY', businessDate: '2020-01-04', reason: '本项费用免收' } })
expect(payload.otherDetermination).toBe('NOT_APPLICABLE'); expect(payload.otherFees).toEqual([])
})
it('lets finance replace a terminated unpaid sample without modifying its original purchase fact', async () => {
const value = fixture('FINANCE_ADJUST')
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: false, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'TERMINATED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
value.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }]
const wrapper = render(value, ['finance-adjust']); const dialog = await open(wrapper)
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
field('财务处理方式').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'fees/replace-failed'); await flushPromises()
field('原费用').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await flushPromises()
await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00')
field('本单凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await explain(wrapper, '原资金已终止,按真实凭证重新核定')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/replace-failed', expect.objectContaining({ feeId: '11', targetOriginalAmount: '12.00', voucherFileId: '501' }))
expect(value.fees[0].originalAmount).toBe('10.00'); expect(value.fees[0].active).toBe(false)
})
it('lets a buyer retry a failed sample payment without granting ordinary charge or finance-adjust access', async () => {
const value = fixture('PURCHASE')
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'FREEZE_FAILED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
const wrapper = render(value, ['buy']); await wrapper.findAll('button').find(button => button.text() === '重试失败资金请求')!.trigger('click'); await flushPromises(); await explain(wrapper, '客户已补足余额,重试原资金请求')
wrapper.findComponent(AppDialog).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/retry', expect.objectContaining({ feeId: '11', version: 3 }))
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('amount')
wrapper.unmount(); const restricted = render(value, ['charge']); expect(restricted.text()).not.toContain('重试失败资金请求')
})
it('uploads an external label and submits a per-carton association with receiving details', async () => {
const value = fixture('LABELS'); value.physical.packingStatus = 'CONFIRMED'; value.physical.packingVersion = 2
value.transport.overseasCargo = [{ cartonId: '31', cartonNo: 'BOX-31', quantity: 2, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', packagingRecordId: null, sources: [{ receiptId: '11', quantity: 2 }] }]
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '51', fileId: '501', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '国内客服制作国际面单')
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
await field('国内接收人').find('input').setValue('国内收货人'); await field('联系电话').find('input').setValue('13800000001'); await field('完整收件地址').find('input').setValue('上海国内收货地址')
const input = dialog.find('input[aria-label="上传快递面单"]'); const file = new File(['pdf'], 'label.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises()
field('本单面单文件').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await field('承运商').find('input').setValue('UPS'); await field('本箱运单号').find('input').setValue('TRACK-31')
await confirm(wrapper)
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'LABEL', file)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'labels', expect.objectContaining({ leg: 'OVERSEAS', packingVersion: 2, receiver: { name: '国内收货人', phone: '13800000001', address: '上海国内收货地址' }, labels: [{ cartonId: '31', fileId: '501', page: 1, carrier: 'UPS', trackingNo: 'TRACK-31' }] }))
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
})
it('records a partial overseas receipt separately from completing receipt', async () => {
const value = fixture('RECEIVE_OVERSEAS'); value.purchaseFacts = { purchased_at: '2020-01-01T10:00:00', purchased_quantity: 2 }
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '首批样品到仓')
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
dialog.findAllComponents({ name: 'ElInputNumber' })[0].vm.$emit('update:modelValue', 1)
dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-02T10:00:00')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'overseas-receipts', expect.objectContaining({ version: 3, leg: 'OVERSEAS', warehouseId: '20001', goodQuantity: 1, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00' }))
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('sourceCartonId')
})
it('saves structured packing sources without invoking packing confirmation', async () => {
const value = fixture('PACK')
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }]
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '核对装箱草稿')
const values = ['BOX-ONE', '10.12', '20', '30', '1.234']
for (const [index, input] of dialog.findAll('.packing-grid input').entries()) await input.setValue(values[index])
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11')
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'packing', expect.objectContaining({ cartons: [{ cartonNo: 'BOX-ONE', lengthCm: '10.12', widthCm: '20', heightCm: '30', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 2 }] }] }))
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
})
it('uses exception evidence and source quantities for damaged goods disposition', async () => {
const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2; value.purchaseFacts.purchased_at = '2020-01-01T10:00:00'
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 1, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }]
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '12', fileId: '102', category: 'EXCEPTION', fileName: 'damage.png', contentType: 'image/png', sizeBytes: 3, createdAt: '' })
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '破损样品已经处理,客户已确认')
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
field('差异及处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DAMAGED'); await flushPromises()
field('对应实收').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11')
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1)
const input = dialog.find('input[type="file"]'); const file = new File(['img'], 'damage.png', { type: 'image/png' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises()
await confirm(wrapper)
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'EXCEPTION', file)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'dispositions', expect.objectContaining({ receiptId: '11', kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', quantity: 1, voucherFileId: '102' }))
})
it('requires an authoritative warehouse on approval and sends the current order version', async () => {
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓')
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('rejects without a warehouse and never substitutes an invented candidate', async () => {
vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('unavailable'))
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper, '购买对象不符合要求')
dialog.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' }))
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId')
})
it('records explicit zero purchase with the exact product snapshot and purchase-purpose evidence', async () => {
const wrapper = render(fixture('PURCHASE')); const dialog = await open(wrapper); await explain(wrapper, '供应商免费提供样品')
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
const amount = dialog.findAll('.el-form-item').find(item => item.text().startsWith('供应商实付总额'))!.find('input'); await amount.setValue('0.00')
const dates = dialog.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-08T10:00:00'); dates[1].vm.$emit('update:modelValue', '2026-10-15T10:00:00')
const input = dialog.find('input[type="file"]'); const file = new File(['pdf'], 'purchase.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises()
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'PURCHASE', file)
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'purchase', expect.objectContaining({ productName: '样品商品', specification: '黑色', quantity: 2, amount: '0.00', currency: 'USD', voucherFileId: '101', purchasedAt: '2026-10-08T10:00:00' }))
})
it('recovers an uncertain request after reload without changing its body or key', async () => {
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout'))
let wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
const original = vi.mocked(submitProcurementCommand).mock.calls[0][3]; expect(sessionStorage.getItem(storageKey)).not.toBeNull(); wrapper.unmount()
wrapper = render(fixture('CANCEL')); await flushPromises()
const retry = wrapper.findAll('button').find(button => button.text() === '确认上次提交结果')!; await retry.trigger('click'); await flushPromises()
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(original); expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('preserves an earlier uncertain financial or physical command when replay is rejected', async () => {
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')).mockRejectedValueOnce(new ApiResponseError('资源正在处理中', 'A0409'))
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
const saved = sessionStorage.getItem(storageKey); expect(saved).not.toBeNull()
await confirm(wrapper)
expect(sessionStorage.getItem(storageKey)).toBe(saved)
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(vi.mocked(submitProcurementCommand).mock.calls[0][3])
expect(wrapper.emitted('changed')).toBeUndefined()
})
it('keeps a version conflict visible, releases the failed command and does not report success', async () => {
vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409'))
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(document.body.textContent).toContain('订单版本冲突'); expect(wrapper.emitted('changed')).toBeUndefined()
})
it('hides unauthorized actions and preserves blocked node reasons without a submit button', async () => {
let wrapper = render(fixture(), []); expect(wrapper.find('.purchase-actions button').exists()).toBe(false); wrapper.unmount()
wrapper = render(fixture('REVIEW', false))
expect(wrapper.find('.purchase-actions button').exists()).toBe(false)
expect(wrapper.find('details').text()).toContain('审核采购')
expect(wrapper.find('details').text()).toContain('业务条件不满足')
expect(submitProcurementCommand).not.toHaveBeenCalled()
})
it('keeps executable workflow actions in front and financial exceptions in a closed group', () => {
const detail = fixture('PURCHASE')
detail.actions.push(...fixture('FINANCE_ADJUST').actions, ...fixture('PACK', false).actions)
const wrapper = render(detail)
const primary = wrapper.get('[data-testid="current-procurement-actions"]')
expect(primary.text()).toContain('登记购买完成')
expect(primary.text()).not.toContain('费用更正与退款')
expect(primary.text()).not.toContain('保存装箱草稿')
const groups = wrapper.findAll('details')
expect(groups[0].text()).toContain('费用更正与退款')
expect(groups[0].attributes('open')).toBeUndefined()
expect(groups[1].text()).toContain('业务条件不满足')
})
it('prevents a second submit while the first command is pending', async () => {
let complete!: () => void; vi.mocked(submitProcurementCommand).mockImplementation(() => new Promise(resolve => { complete = resolve }))
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledTimes(1); complete(); await flushPromises()
})
it('rejects malformed recovery records instead of allowing a fresh competing operation', async () => {
sessionStorage.setItem(storageKey, '{bad')
const wrapper = render(); expect(wrapper.text()).toContain('上次操作记录无法读取'); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined()
expect(() => readPendingProcurement(JSON.stringify({ command: 'purchase', payload: { version: 3, idempotencyKey: 'a', amount: '-2' } }))).toThrow()
})
})
@@ -0,0 +1,245 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft'
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
import ProcurementControlFields from './ProcurementControlFields.vue'
import { controlCommands, controlForm, buildControl } from '../procurementControlForm'
import ProcurementFinanceFields from './ProcurementFinanceFields.vue'
import { financeCommands, financeForm, buildFinance, feeRetryPermission } from '../procurementFinanceForm'
import ProcurementTransportFields from './ProcurementTransportFields.vue'
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore()
const routes: Record<string, ProcurementCommand> = { REVIEW: 'review', PURCHASE: 'purchase', CONFIRM_PURCHASE: 'purchase/confirm', AUTHORIZE_BUDGET: 'budget-authorizations', PLAN_WAREHOUSE: 'planned-warehouse', CANCEL: 'cancel', RECEIVE_OVERSEAS: 'overseas-receipts', RECEIVE_DOMESTIC: 'domestic-receipts', FINISH_OVERSEAS: 'overseas-receipts/complete', FINISH_DOMESTIC: 'domestic-receipts/complete', PACK: 'packing', CONFIRM_PACKING: 'packing/confirm', MANAGE: 'dispositions', LABELS: 'labels', ESTIMATE: 'international-estimate', APPLY_LABELS: 'labels/apply', SHIP_OVERSEAS: 'overseas-shipment', SHIP_CUSTOMER: 'customer-shipment', REPACK: 'domestic-repack', CONFIRM_CHARGES: 'charges/confirm', FINANCE_ADJUST: 'fees/correct-target', RETRY_FEE: 'fees/retry', EXCEPTION_CLOSE: 'exception-close' }
const retryFees = computed(() => props.detail.fees.filter(fee => fee.active && fee.walletStatus.endsWith('_FAILED') && access.can(feeRetryPermission(fee))))
const actions = computed(() => [...props.detail.actions.filter(item => routes[item.key] && access.can(item.permission)), ...(retryFees.value.length ? [{ key: 'RETRY_FEE', label: '重试失败资金请求', permission: feeRetryPermission(retryFees.value[0]), allowed: true, reason: null }] : [])])
const secondaryKeys = new Set(['AUTHORIZE_BUDGET', 'PLAN_WAREHOUSE', 'MANAGE', 'FINANCE_ADJUST', 'CANCEL', 'EXCEPTION_CLOSE'])
const currentActions = computed(() => actions.value.filter(action => action.allowed && !secondaryKeys.has(action.key)))
const secondaryActions = computed(() => actions.value.filter(action => secondaryKeys.has(action.key)))
const unavailableActions = computed(() => actions.value.filter(action => !action.allowed && !secondaryKeys.has(action.key)))
const opened = ref(false); const active = ref<ProcurementAction | null>(null); const saving = ref(false); const uploading = ref(false)
const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); const pending = ref<PendingProcurement | null>(null)
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
const extraFiles = ref<ProcurementFile[]>([])
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const control = ref(controlForm(props.detail))
const finance = ref(financeForm(props.detail))
const financialCommand = ref<ProcurementCommand>('fees/correct-target')
const transport = ref(transportForm(props.detail))
const physical = ref(physicalForm())
const manageCommand = ref<ProcurementCommand>('dispositions')
const key = computed(() => auth.user?.id ? `procurement-command:${auth.user.id}:${props.customerId}:${props.detail.customerView.order.orderNo}` : '')
const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : '')
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, reason: managementReason(option.value, props.detail) })))
const managementBlock = computed(() => {
if (active.value?.key !== 'MANAGE' || !command.value || pending.value) return null
const reason = managementReason(command.value, props.detail)
if (reason) return reason
if (command.value === 'dispositions') return dispositionReason(props.detail, physical.value.leg)
const row = command.value === 'receipts/correct'
? props.detail.physical.receipts.find(item => item.id === physical.value.receiptId)
: command.value === 'dispositions/revoke' ? props.detail.physical.dispositions.find(item => item.id === physical.value.dispositionId) : null
return row ? receiptCorrectionReason(props.detail, row.leg) : null
})
const isControl = computed(() => !!command.value && controlCommands.has(command.value))
const isFinance = computed(() => !!command.value && financeCommands.has(command.value))
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
const isPhysical = computed(() => !!command.value && physicalCommands.has(command.value))
const needsEvidence = computed(() => ['dispositions', 'receipts/correct', 'dispositions/revoke', 'domestic-repack', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(command.value || ''))
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || (command.value === 'review' && form.decision === 'APPROVE'))
const needsAmount = computed(() => ['purchase', 'budget-authorizations'].includes(command.value || ''))
const fileCategory = computed(() => isControl.value ? 'EXCEPTION' : isFinance.value ? 'VOUCHER' : command.value === 'labels' ? 'LABEL' : command.value === 'domestic-repack' ? 'RECEIPT' : needsEvidence.value ? 'EXCEPTION' : command.value === 'purchase' ? 'PURCHASE' : 'BUDGET')
const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].filter((file, index, all) => all.findIndex(other => other.fileId === file.fileId) === index))
const files = computed(() => allFiles.value.filter(file => file.category === fileCategory.value))
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purchase-action', label: computed(() => active.value?.label || '采购节点操作'), isOpen: opened, model: reactive({ purchase: form, physical, transport, finance, control }), requiredPermission: computed(() => active.value?.permission ?? null) })
watch(key, () => {
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null; editingDraft.value = false; correctionDraft.value = null; draftError.value = ''
if (!key.value) return
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
}, { immediate: true })
async function loadWarehouses() {
if (warehouseLoading.value) return
warehouseLoading.value = true; warehouseError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] }
finally { warehouseLoading.value = false }
}
function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = manageOptions.value.find(option => !option.reason)?.value || 'control-notes'; physical.value = physicalForm(props.detail)
control.value = controlForm(props.detail)
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
if (action.key.includes('DOMESTIC')) physical.value.leg = 'DOMESTIC'
Object.assign(form, { decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
opened.value = true; captureBaseline()
if (needsWarehouse.value) void loadWarehouses()
}
function openCorrectionDraft() {
if (!canDraft.value || saving.value || uploading.value) return
editingDraft.value = true
active.value = { key: 'CORRECTION_DRAFT', label: '费用更正草稿', permission: 'platform:overseas-business:purchase:finance-adjust', allowed: true, reason: null }
finance.value = financeForm(props.detail)
const draft = correctionDraft.value
finance.value.feeId = draft?.feeId || ''; finance.value.targetOriginalAmount = draft?.targetOriginalAmount || ''
form.reason = draft?.reason || ''; form.voucherFileId = draft?.voucherFileId || ''
error.value = null; opened.value = true; captureBaseline()
}
function saveCorrectionDraft() {
if (!editingDraft.value || !canDraft.value || saving.value || uploading.value) return
try {
const draft = { feeId: finance.value.feeId, targetOriginalAmount: finance.value.targetOriginalAmount, reason: form.reason, voucherFileId: form.voucherFileId, orderVersion: props.detail.customerView.order.version }
sessionStorage.setItem(draftKey.value, JSON.stringify(draft)); correctionDraft.value = draft; draftError.value = ''; captureBaseline()
ElMessage.success('更正草稿已保存在本浏览器标签页,尚未确认费用或发起资金请求')
} catch { draftError.value = '草稿保存失败,请保留当前输入并重试' }
}
function useCorrectionDraft() {
if (!editingDraft.value || pending.value || storageError.value || !canDraft.value) return
const action = actions.value.find(item => item.key === 'FINANCE_ADJUST' && item.allowed)
if (!action) { error.value = { message: '当前业务状态不允许财务确认,请刷新订单后核对' }; return }
const fee = props.detail.fees.find(row => row.id === finance.value.feeId)
active.value = action; financialCommand.value = fee && !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) ? 'fees/replace-failed' : 'fees/correct-target'; editingDraft.value = false
}
async function retryPending() {
if (editingDraft.value) { saveCorrectionDraft(); if (draftError.value) return }
editingDraft.value = false; opened.value = false
await submit()
}
watch(financialCommand, () => { if (editingDraft.value) return; finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }, { flush: 'sync' })
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
function build(): ProcurementCommandBody {
const order = props.detail.customerView.order
if (!form.reason.trim()) throw new Error('请填写操作说明')
const base = { version: order.version, idempotencyKey: commandKey(), reason: form.reason.trim() }
if (needsWarehouse.value && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
if (isControl.value && command.value) return buildControl(command.value, control.value, props.detail, allFiles.value, base, form.warehouseId, form.voucherFileId)
if (isFinance.value && command.value) return buildFinance(command.value, finance.value, props.detail, allFiles.value, base, form.voucherFileId)
if (isTransport.value && command.value) return buildTransport(command.value, transport.value, props.detail, allFiles.value, base, form.voucherFileId)
if (isPhysical.value && command.value) {
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
}
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
if (needsAmount.value) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
if (!files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单对应用途的凭证')
const amount = { ...base, amount: form.amount.trim(), currency: form.currency, voucherFileId: form.voucherFileId }
if (command.value === 'budget-authorizations') return amount
if (!form.purchasedAt || !form.estimatedArrivalAt) throw new Error('请填写真实购买时间及预计到仓时间')
return { ...amount, productName: order.productName, specification: order.productSpecification ?? '', quantity: order.purchaseQuantity, purchasedAt: form.purchasedAt, estimatedArrivalAt: form.estimatedArrivalAt }
}
return base
}
async function submit() {
if (editingDraft.value) { saveCorrectionDraft(); return }
if (saving.value || uploading.value || storageError.value || !key.value) return
const resuming = !!pending.value
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
if (!pending.value) {
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
try {
if (managementBlock.value) throw new Error(managementBlock.value)
const request = { command: command.value, payload: build() }
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
sessionStorage.setItem(scope, serialized); pending.value = request
} catch (issue) { error.value = { message: issue instanceof Error ? issue.message : '提交信息保存失败,请重试' }; return }
}
const retryFee = pending.value.command === 'fees/retry' ? props.detail.fees.find(fee => fee.id === pending.value?.payload.feeId) : null
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
saving.value = true; error.value = null
const confirmsCorrection = !resuming && ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
try {
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
sessionStorage.removeItem(scope)
if (key.value !== scope) return
if (confirmsCorrection) { sessionStorage.removeItem(draftKey.value); correctionDraft.value = null }
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
} catch (issue) {
if (key.value !== scope) return
if (!resuming && definitelyRejected(issue)) { sessionStorage.removeItem(scope); pending.value = null }
error.value = getApiErrorDetails(issue, '结果尚未确认,请使用原请求重试')
} finally { saving.value = false }
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || uploading.value || (pending.value && !editingDraft.value)) return
if (file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) { ElMessage.warning('请上传不超过 10MB 的 PDF、JPG 或 PNG'); return }
const scope = key.value; uploading.value = true
try {
const saved = await uploadProcurementFile(props.detail.customerView.order.orderNo, props.customerId, fileCategory.value, file)
if (scope !== key.value) return
extraFiles.value.push(saved); form.voucherFileId = saved.fileId
} catch (issue) { if (scope === key.value) error.value = getApiErrorDetails(issue, '凭证上传失败') }
finally { uploading.value = false }
}
</script>
<template>
<section class="admin-density" aria-label="采购履约节点操作">
<div class="density-actions purchase-actions" data-testid="current-procurement-actions"><el-tooltip v-for="action in currentActions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip></div>
<details v-if="secondaryActions.length || canDraft" class="purchase-secondary-actions">
<summary>更正、财务及异常处理</summary>
<div class="density-actions purchase-actions"><el-tooltip v-for="action in secondaryActions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!action.allowed || !!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip>
<el-button v-if="canDraft" :disabled="saving || uploading" @click="openCorrectionDraft">{{ correctionDraft ? '恢复费用更正草稿' : '准备费用更正草稿' }}</el-button>
</div>
</details>
<details v-if="unavailableActions.length" class="purchase-secondary-actions">
<summary>其他履约节点及不可操作原因</summary>
<dl><template v-for="action in unavailableActions" :key="action.key"><dt>{{ action.label }}</dt><dd>{{ action.reason || '当前不可操作' }}</dd></template></dl>
</details>
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
<ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
<ProcurementControlFields v-if="isControl && command" v-model="control" :command="command" :detail="detail" :files="allFiles" />
<template v-if="editingDraft"><el-form-item label="原费用"><el-select v-model="finance.feeId" placeholder="选择本单原实际费用"><el-option v-for="fee in detail.fees.filter(row => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(row.purpose))" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${fee.feeItemName}`" /></el-select></el-form-item><el-form-item label="正确原币总额"><el-input v-model="finance.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item></template>
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
<el-form-item v-if="command === 'review'" label="审核决定"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? '实际海外收货仓' : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" placeholder="选择有效海外收货仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到海外仓时间" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item label="操作说明" required><el-input v-model="form.reason" placeholder="填写本次操作的事实、依据或原因" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></template>
</AppDialog>
</section>
</template>
<style scoped>
.purchase-actions { flex-wrap: wrap; }
.purchase-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.purchase-form-grid :deep(.el-date-editor), :deep(.el-select) { width: 100%; }
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement } from '../procurementTypes'
import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, type PhysicalForm } from '../procurementPhysicalForm'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
const form = defineModel<PhysicalForm>({ required: true })
const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null)))))
const sources = computed(() => props.detail.physical.receipts.filter(row => row.active && row.leg === 'OVERSEAS' && row.goodQuantity > 0))
const domesticBoxes = computed(() => shippedCartons(props.detail))
const showReceipt = computed(() => ['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(props.command))
function selectReceipt(id: string) {
if (props.command !== 'receipts/correct') return
const row = props.detail.physical.receipts.find(item => item.id === id && item.active)
if (row) Object.assign(form.value, { leg: row.leg, sourceCartonId: row.sourceCartonId || '', goodQuantity: row.goodQuantity, damagedQuantity: row.damagedQuantity, extraQuantity: row.extraQuantity, receivedAt: row.receivedAt })
}
const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' }
</script>
<template>
<div class="admin-density">
<el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" />
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次合格件数" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次破损件数" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次多到件数" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<template v-if="command === 'packing'">
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
<div class="packing-heading"><strong>第 {{ index + 1 }} 箱</strong><el-button class="density-action" data-tone="danger" @click="form.cartons.splice(index, 1)">移除本箱</el-button></div>
<div class="packing-grid"><el-form-item label="箱号" required><el-input v-model="box.cartonNo" maxlength="64" placeholder="填写本单唯一箱号" /></el-form-item><el-form-item label="长(cm)" required><el-input v-model="box.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="宽(cm)" required><el-input v-model="box.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="高(cm)" required><el-input v-model="box.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="毛重(kg)" required><el-input v-model="box.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item label="海外实收来源" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<el-button class="density-action" @click="box.sources.push({ receiptId: '', quantity: undefined })">增加来源</el-button>
</section><el-button class="density-action" data-tone="primary" @click="form.cartons.push(newCarton())">增加装货箱</el-button>
</template>
</div>
</template>
<style scoped>
.physical-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3); }
.packing-grid { display: grid; grid-template-columns: 2fr repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
.packing-source { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; gap: var(--admin-space-3); align-items: center; }
.packing-box { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
.packing-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-3); }
:deep(.el-select), :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .physical-grid, .packing-grid { grid-template-columns: 1fr; } .packing-source { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import type { ProcurementCommand } from '../api/procurementCommands'
import { transportForm, transportCargo, type TransportForm } from '../procurementTransportForm'
import { dateTime, measurement, money } from '../procurementPresentation'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<TransportForm>({ required: true })
const cargo = computed(() => transportCargo(props.detail, form.value.leg))
const labelSet = computed(() => form.value.leg === 'OVERSEAS' ? props.detail.transport.overseasLabels : props.detail.transport.domesticLabels)
const sender = computed(() => form.value.leg === 'DOMESTIC' ? props.detail.transport.overseasLabels?.receiver : labelSet.value?.sender)
const estimate = computed(() => props.detail.customerView.fees.find(row => row.active && row.purpose === 'INTERNATIONAL_ESTIMATE'))
const changedReceiver = computed(() => form.value.leg === 'DOMESTIC' && (form.value.receiver.name.trim() !== props.detail.customerView.order.contactName || form.value.receiver.phone.trim() !== props.detail.customerView.order.contactPhone || form.value.receiver.address.trim() !== props.detail.customerView.order.receivingAddress))
function changeLeg() { form.value = transportForm(props.detail, form.value.leg) }
function selectCarton(id: string) { const box = props.detail.transport.domesticCargo.find(row => row.cartonId === id); if (box) Object.assign(form.value, { lengthCm: String(box.lengthCm), widthCm: String(box.widthCm), heightCm: String(box.heightCm), grossWeightKg: String(box.grossWeightKg) }) }
</script>
<template>
<div class="admin-density">
<template v-if="command === 'labels'">
<el-form-item label="面单运输段" required><el-radio-group v-model="form.leg" @change="changeLeg"><el-radio value="OVERSEAS" :disabled="!!detail.purchaseFacts.overseas_shipped_at">国际段</el-radio><el-radio value="DOMESTIC" :disabled="!detail.purchaseFacts.domestic_received_at">寄客户段</el-radio></el-radio-group></el-form-item>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : '由实际海外收货仓主档提供,保存时核对。' }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" placeholder="填写快递收件人" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" placeholder="填写真实快递联系电话" /></el-form-item></div>
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" placeholder="填写完整快递收件地址" /></el-form-item>
<el-form-item v-if="changedReceiver" label="客户确认收件信息变更凭证" required><el-select v-model="form.addressVoucherFileId" placeholder="选择本单已上传的客户确认凭证"><el-option v-for="file in files.filter(item => item.category === 'EXCEPTION')" :key="file.fileId" :label="file.fileName" :value="file.fileId" /></el-select></el-form-item>
<section v-for="row in form.labels" :key="row.cartonId" class="label-row">
<strong>{{ cargo.find(box => box.cartonId === row.cartonId)?.cartonNo || '未关联箱' }}</strong>
<div class="label-grid"><el-form-item label="本单面单文件" required><el-select v-model="row.fileId" placeholder="选择 PDF、JPG 或 PNG"><el-option v-for="file in files.filter(item => item.category === 'LABEL')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item><el-form-item label="对应页码" required><el-input-number v-model="row.page" :min="1" :precision="0" controls-position="right" placeholder="图片填 1" /></el-form-item><el-form-item label="承运商" required><el-input v-model="row.carrier" maxlength="128" placeholder="填写实际承运商" /></el-form-item><el-form-item label="本箱运单号" required><el-input v-model="row.trackingNo" maxlength="128" placeholder="填写本箱主/子运单号" /></el-form-item></div>
</section>
</template>
<template v-if="command === 'international-estimate'">
<el-alert title="本次只冻结预估国际运费,不直接扣款。变价时先释放原冻结,释放完成后再次核对并提交新预估;同价也需核对当前面单版本。" type="info" :closable="false" />
<p>当前面单:第 {{ labelSet?.version || '—' }} 版;有效原预估:{{ estimate ? money(estimate.amount, estimate.currency) : '尚未核定' }}</p>
<div class="transport-grid"><el-form-item label="预估国际运费(CNY)" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
</template>
<template v-if="command === 'labels/apply'">
<p>国际面单第 {{ labelSet?.version || '—' }} 版,逐箱记录实际贴单。</p>
<el-form-item label="本次已贴面单的箱" required><el-checkbox-group v-model="form.cartonIds"><el-checkbox v-for="row in labelSet?.labels || []" :key="row.cartonId" :value="row.cartonId" :disabled="!!row.appliedAt">{{ cargo.find(box => box.cartonId === row.cartonId)?.cartonNo || '未关联箱' }} · {{ row.appliedAt ? `已贴单 ${dateTime(row.appliedAt)}` : '待贴单' }}</el-checkbox></el-checkbox-group></el-form-item>
<el-form-item label="实际贴单时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
</template>
<template v-if="command === 'domestic-repack'">
<el-alert title="只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" placeholder="选择国内可寄原箱" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<div class="measure-grid"><el-form-item label="新长度(cm)" required><el-input v-model="form.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新宽度(cm)" required><el-input v-model="form.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新高度(cm)" required><el-input v-model="form.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新毛重(kg)" required><el-input v-model="form.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
</template>
<template v-if="['overseas-shipment', 'customer-shipment'].includes(command)">
<el-alert title="仅在货物实际交给承运方后确认。本次将整批交寄全部可寄原箱,不能拆批或重复交寄。" type="warning" :closable="false" />
<p>面单第 {{ labelSet?.version || '—' }} 版;收件信息:{{ labelSet ? `${labelSet.receiver.name} · ${labelSet.receiver.phone} · ${labelSet.receiver.address}` : '未建立' }}</p>
<el-form-item label="实际交承运方时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
</template>
<table class="density-table"><thead><tr><th>原箱</th><th>当前可寄件数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th></tr></thead><tbody><tr v-for="box in cargo" :key="box.cartonId"><td>{{ box.cartonNo }}</td><td>{{ box.quantity }}</td><td>{{ [box.lengthCm, box.widthCm, box.heightCm].map(value => measurement(value, 2)).join(' × ') }}</td><td>{{ measurement(box.grossWeightKg, 3) }}</td></tr><tr v-if="!cargo.length"><td colspan="4">当前没有可寄货物</td></tr></tbody></table>
</div>
</template>
<style scoped>
.transport-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }
.label-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.8fr); gap: var(--admin-space-3); }
.measure-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
.label-row { padding-top: var(--admin-space-3); border-top: 1px solid var(--cc-border); }
:deep(.el-select), :deep(.el-input-number), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 760px) { .transport-grid, .label-grid, .measure-grid { grid-template-columns: 1fr; } }
</style>
@@ -14,14 +14,14 @@ describe('overseas business real contract pages', () => {
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
})
it('loads procurement candidates, operations and three-state PATCH fields', () => {
it('keeps procurement general editing limited to internal remarks', () => {
const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue')
expect(list).toContain('listOverseasPurchaseCustomerOptions')
expect(edit).toContain('listOverseasPurchaseWarehouseOptions')
expect(detail).toContain('detail?.histories || []')
expect(edit).toContain('getOverseasPurchaseEditor')
expect(detail).toContain('detail.reviewHistory')
expect(detail).not.toContain('outbounds')
expect(edit).toContain('estimatedArrivalAt: form.estimatedArrivalAt || null')
expect(edit).toContain('purchaseWarehouseId: form.purchaseWarehouseId || null')
expect(edit).toContain('remark: form.remark.trim() || null')
expect(edit).not.toMatch(/v-model="form\.(inboundQuantity|purchaseWarehouseId|outboundDetails|productName)/)
expect(edit).not.toContain('入库量不能小于 0')
expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/)
})
@@ -1,3 +1,4 @@
import AppDialog from '@/shared/components/AppDialog.vue'
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
@@ -8,13 +9,14 @@ import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { PlatformProcurement } from '../procurementTypes'
import { downloadProcurementFile } from '../api/procurementCommands'
import type { FleetArrangement } from '../fleetArrangement'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
import {
createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions,
listOverseasPurchases, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
listOverseasPurchases, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
} from '../api/platformOverseasBusiness'
import PurchaseOrdersView from './PurchaseOrdersView.vue'
import PurchaseOrderDetailView from './PurchaseOrderDetailView.vue'
@@ -28,8 +30,12 @@ const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/ov
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/procurementCommands', async importOriginal => ({
...await importOriginal<typeof import('../api/procurementCommands')>(),
downloadProcurementFile: vi.fn(),
}))
vi.mock('../api/platformOverseasBusiness', () => ({
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), reviewOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), getOverseasPurchaseEditor: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(),
listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(),
}))
@@ -38,20 +44,24 @@ vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWor
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
const purchase: OverseasPurchaseDetail = {
const purchase = {
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, outboundQuantity: 0, inventoryQuantity: 10, status: 'PENDING_OUTBOUND', version: 3,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', domesticCarrier: null, domesticTrackingNo: null,
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'WAIT_PACKING' as const, version: 3,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086',
estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
}
const procurementDetail = (): PlatformProcurement => ({
customerView: { order: { ...purchase, status: 'WAIT_PURCHASE', plannedWarehouseName: '洛杉矶采购仓', actualWarehouseCode: null, actualWarehouseName: null, receivingAddress: '深圳收件地址', contactName: 'Alice', contactPhone: '13800000000' }, budget: { totalAmount: '250.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [{ key: 'CREATED', title: '客户下单', completedAt: purchase.createdAt }], boxes: [], shipments: [], fees: [], exceptionSummaries: [] },
purchaseFacts: {}, physical: { dispositions: [], receipts: [], packingVersion: null, packingStatus: null, cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: purchase.histories, operations: [], actions: [], internalRemark: '采购备注',
})
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
@@ -59,7 +69,9 @@ describe('overseas business mounted pages', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 }); vi.mocked(reviewOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, status: 'APPROVED', version: 4 })
vi.mocked(downloadProcurementFile).mockReset()
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail)
vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail)
@@ -67,6 +79,40 @@ describe('overseas business mounted pages', () => {
})
it.each(['查看路线'])('opens the shared route map from looseboard %s without requerying orders', async label => {
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' })
await flushPromises()
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(false)
const requests = vi.mocked(listLocalLooseboardOrders).mock.calls.length
await wrapper.findAll('button').find(button => button.text() === label)!.trigger('click')
await flushPromises()
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
expect(document.body.textContent).toContain('LA → ONT')
expect(listLocalLooseboardOrders).toHaveBeenCalledTimes(requests)
})
it.each([false, true])('opens truck route map on create/edit without saving (%s)', async editing => {
if (editing) { routing.route.params = { orderId: truck.id }; routing.route.query = { customerId: truck.customerId } }
const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '查看路线')!.trigger('click')
await flushPromises()
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
expect(createUsTruckOrder).not.toHaveBeenCalled()
expect(updateUsTruckOrder).not.toHaveBeenCalled()
const dialog = wrapper.findComponent({ name: 'TransportRouteDialog' })
expect(dialog.props('origin')).toEqual({ kind: 'address', label: editing ? 'Long Beach' : '', address: editing ? 'Long Beach Pier A' : '' })
expect(dialog.props('destinations')).toEqual([{ kind: 'address', label: editing ? 'Ontario' : '', address: editing ? 'Ontario Warehouse B' : '' }])
})
it('opens the associated commercial order using a server-side order number filter', async () => {
routing.route.query = { orderNo: 'LB-LINKED' }
const wrapper = mount(TransportOrdersView, { props: { domain: 'looseboard' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listLocalLooseboardOrders).toHaveBeenCalledWith(expect.objectContaining({ orderNo: 'LB-LINKED' }))
expect(wrapper.find('input[placeholder="请输入商业单号"]').element).toHaveProperty('value', 'LB-LINKED')
})
it('opens pool arrangements from the list without another request or exposing edit actions', async () => {
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
@@ -116,55 +162,136 @@ describe('overseas business mounted pages', () => {
await flushPromises()
expect(wrapper.get('.density-detail-title').text()).toBe('OP-100')
expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓')
expect(wrapper.findAll('.density-fields')).toHaveLength(4)
expect(wrapper.findAll('.density-fields dt')).toHaveLength(23)
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(4)
expect(wrapper.findAll('.density-fields')).toHaveLength(3)
expect(wrapper.text()).toContain('预算单价')
expect(wrapper.text()).toContain('两段实际交寄')
expect(wrapper.text()).not.toContain('库存信息')
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(2)
expect(wrapper.text()).not.toContain('internal-purchase-route')
await wrapper.get('.density-back').trigger('click')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' })
})
it('retains the voucher upload and fee dialog workflow with teleported density', async () => {
it('downloads procurement files with list access and prevents duplicate pending downloads', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
const voucher = { id: 'voucher-1', fileId: 'file-1', fileName: 'voucher.pdf', category: 'VOUCHER' as const, contentType: 'application/pdf', sizeBytes: 3, createdAt: '2026-09-14T10:00:00' }
vi.mocked(uploadOverseasPurchaseFile).mockReset().mockResolvedValue(voucher)
vi.mocked(createOverseasPurchaseFee).mockReset().mockResolvedValue({ id: 'fee-1', feeNo: 'FEE-1', feeItemCode: 'COURIER', feeItemName: '快递费', amount: '25', currency: 'CNY', voucherFileId: 'file-1', voucherFileName: 'voucher.pdf', remark: null, walletStatus: 'FREEZE_PENDING', walletErrorMessage: null, createdAt: '2026-09-14T10:00:00' })
const result = procurementDetail()
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
let rejectDownload!: (error: Error) => void
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
const wrapper = mountPage(PurchaseOrderDetailView)
useAuthStore().permissions = ['platform:overseas-business:purchase:list']
await flushPromises()
const input = wrapper.findAll('input[type="file"]')[1]!
const file = new File(['pdf'], 'voucher.pdf', { type: 'application/pdf' })
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadOverseasPurchaseFile).toHaveBeenCalledWith('OP-100', '10001', 'VOUCHER', file)
const dialog = wrapper.findAllComponents(AppDialog)[1]!
expect(dialog.props('modelValue')).toBe(true)
expect(document.querySelector('.admin-density-dialog .density-form')).not.toBeNull()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
await wrapper.vm.$nextTick()
dialog.vm.$emit('confirm'); await flushPromises()
expect(createOverseasPurchaseFee).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ amount: 25, voucherFileId: 'file-1' }))
expect(dialog.props('modelValue')).toBe(false)
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === '下载')
expect(button).toBeDefined()
await button!.trigger('click'); await flushPromises()
expect(downloadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', '9007199254741001')
expect(button!.attributes('disabled')).toBeDefined()
await button!.trigger('click')
expect(downloadProcurementFile).toHaveBeenCalledTimes(1)
rejectDownload(new Error('TEST download unavailable')); await flushPromises()
expect(button!.attributes('disabled')).toBeUndefined()
expect(document.body.textContent).toContain('文件下载失败')
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待出库'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('renders estimate holds separately from actual payment without claiming inbound settlement', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
const result = procurementDetail()
result.customerView.fees = [{ feeNo: 'FEE-1', purpose: 'INTERNATIONAL_ESTIMATE', name: '国际运费预估', version: 1, active: true, determination: 'POSITIVE', amount: '25.00', currency: 'CNY', originalAmount: '25.00', originalCurrency: 'CNY', exchangeRate: '1', exchangeRateMonth: '2026-10', businessDate: '2026-10-09', parentFeeNo: null, walletStatus: 'FROZEN', sourceLinked: false }]
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises()
expect(wrapper.text()).toContain('已冻结,未扣款')
expect(wrapper.text()).toContain('预估不入池')
expect(wrapper.text()).not.toContain('订单全部入库后结算')
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const cells = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')[0]!.findAll('td')
expect(cells[0]!.classes()).toContain('el-table-fixed-column--left')
for (const [index, value] of [record.orderNo, record.customerName, record.purchaseWarehouseName, record.purchaseCategory, record.productName, record.productSpecification].entries()) {
expect(cells[index + 1]!.text()).toBe(value)
expect(cells[index + 1]!.classes()).not.toContain('data-table__single-line-cell')
expect(cells[index + 1]!.find('.cell').classes()).not.toContain('el-tooltip')
}
})
it('refreshes the cached procurement list after returning from detail or edit', () => { expect(purchaseListSource).toContain('onActivated'); expect(purchaseListSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
it('reviews a pending procurement from its detail page', async () => { const pending = { ...purchase, status: 'PENDING_REVIEW' as const, inboundQuantity: 0, inventoryQuantity: 0, version: 2 }; routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(getOverseasPurchase).mockResolvedValue(pending); vi.mocked(reviewOverseasPurchase).mockResolvedValue({ ...pending, status: 'APPROVED', version: 3 }); const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('通过'))?.trigger('click'); await wrapper.vm.$nextTick(); const reviewDialog = wrapper.findAllComponents(AppDialog)[0]; expect(reviewDialog.props('modelValue')).toBe(true); reviewDialog.vm.$emit('confirm'); await flushPromises(); expect(reviewOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 2, decision: 'APPROVE', idempotencyKey: expect.any(String) })); expect(wrapper.text()).toContain('已通过'); expect(wrapper.find('.header-actions').exists()).toBe(false); expect(wrapper.get('.density-back').text()).toBe('返回列表') })
it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() })
it('submits only the internal remark and preserves input after a version conflict', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409))
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
await wrapper.find('textarea').setValue('新备注')
await wrapper.findAll('button').find((button) => button.text().trim() === '保存')!.trigger('click'); await flushPromises()
expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: '新备注' })
expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试')
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('新备注')
expect(routing.push).not.toHaveBeenCalled()
})
it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') })
it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') })
it('blocks saving until the aggregate context has loaded successfully', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
vi.mocked(getOverseasPurchaseEditor).mockRejectedValue(new Error('unavailable'))
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
expect(wrapper.text()).toContain('采购订单暂时无法编辑')
expect(wrapper.find('textarea').exists()).toBe(false)
expect(updateOverseasPurchase).not.toHaveBeenCalled()
})
it('sends only one remark mutation while a save is pending', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
let complete!: (result: Awaited<ReturnType<typeof updateOverseasPurchase>>) => void
vi.mocked(updateOverseasPurchase).mockImplementation(() => new Promise(resolve => { complete = resolve }))
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
const button = wrapper.findAll('button').find((item) => item.text().trim() === '保存')!
await button.trigger('click'); await button.trigger('click')
expect(updateOverseasPurchase).toHaveBeenCalledTimes(1)
complete({ ...purchase, version: 4 }); await flushPromises()
expect(routing.push).toHaveBeenCalledTimes(1)
})
it('loads one aggregate editor request without warehouse queries and explicitly clears a remark', async () => {
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
expect(getOverseasPurchaseEditor).toHaveBeenCalledTimes(1)
expect(listOverseasPurchaseWarehouseOptions).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('OP-100')
expect(wrapper.text()).not.toContain('出库明细')
await wrapper.find('textarea').setValue('')
await wrapper.findAll('button').find((button) => button.text().trim() === '保存')!.trigger('click'); await flushPromises()
expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: null })
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchase-detail', params: { purchaseId: 'OP-100' }, query: { customerId: '10001' } })
})
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })
return (async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '承运安排')!
expect(dialog.props('modelValue')).toBe(matching); expect(dialog.props('showConfirm')).toBe(false)
await wrapper.findAll('button').find(item => item.text() === '返回原拼车登记')!.trigger('click')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: matching ? arrangement.bookingNo : 'FB-OTHER' } })
expect(updateUsTruckOrder).not.toHaveBeenCalled()
})()
})
})
@@ -1,136 +1,187 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, reactive, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Check, Upload, X } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createOverseasPurchaseFee, getOverseasPurchase, reviewOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
import { downloadProcurementFile } from '../api/procurementCommands'
import ProcurementNodeActions from '../components/ProcurementNodeActions.vue'
import { remainingLabels, decisionLabels } from '../procurementControlForm'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseFile, OverseasPurchaseFee, OverseasPurchaseStatus } from '../types'
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
const route = useRoute(); const router = useRouter()
const access = useAccessPreviewStore()
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
const outboundDetails = computed(() => detail.value?.outboundDetails || [])
const receiptInput = ref<HTMLInputElement | null>(null); const voucherInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false); const feeDialogOpen = ref(false); const savingFee = ref(false); const voucher = ref<OverseasPurchaseFile | null>(null)
const feeForm = reactive({ feeItemCode: 'COURIER', feeItemName: '快递费', amount: 0, remark: '' })
const reviewDialogOpen = ref(false); const reviewing = ref(false); const reviewDecision = ref<'APPROVE' | 'REJECT'>('APPROVE'); const reviewRemark = ref(''); const reviewKey = ref('')
const fileColumns: DataTableColumn[] = [{ key: 'category', label: '类型', width: 100, formatter: (row: OverseasPurchaseFile) => row.category === 'RECEIPT' ? '签收文件' : '费用凭证' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 220 }, { key: 'createdAt', label: '上传时间', minWidth: 155, formatter: (row: OverseasPurchaseFile) => formatDateTime(row.createdAt) }]
const feeColumns: DataTableColumn[] = [{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项', minWidth: 120 }, { key: 'amount', label: '金额', width: 120, formatter: (row: OverseasPurchaseFee) => formatAmount(row.amount, row.currency) }, { key: 'voucherFileName', prop: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'walletStatus', label: '资金状态', minWidth: 130, formatter: (row: OverseasPurchaseFee) => walletStatus(row.walletStatus) }, { key: 'walletErrorMessage', label: '异常说明', minWidth: 180, formatter: (row: OverseasPurchaseFee) => row.walletErrorMessage || '—' }]
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
{ key: 'status', label: '状态变化', minWidth: 150, formatter: (row: OverseasBusinessHistory) => row.fromStatus ? `${statusLabel(row.fromStatus)} → ${statusLabel(row.toStatus)}` : statusLabel(row.toStatus) },
{ key: 'changeDetail', prop: 'changeDetail', label: '变更明细', minWidth: 260 },
{ key: 'operator', label: '操作人', minWidth: 120, formatter: () => '—' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
const downloading = ref<string | null>(null)
const order = computed(() => detail.value?.customerView.order)
const fields = computed(() => [
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
['预计到海外仓', dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
] as const)
const purchaseFields = computed(() => [
['实际购买时间', dateTime(detail.value?.purchaseFacts.purchased_at)], ['实购商品', detail.value?.purchaseFacts.purchased_product_name],
['实购数量', detail.value?.purchaseFacts.purchased_quantity], ['原登记实付总额', money(detail.value?.purchaseFacts.purchase_amount, detail.value?.purchaseFacts.purchase_currency)],
['货款核定时间', dateTime(detail.value?.purchaseFacts.purchase_confirmed_at)], ['待处理事项', detail.value?.purchaseFacts.purchase_issue],
] as const)
const receiptColumns: DataTableColumn[] = [
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
]
const operationLabels: Record<string, string> = { CREATE: '创建', REVIEW: '审核', UPDATE: '编辑' }
function statusLabel(status?: string | null) { return status ? (statusLabels[status as OverseasPurchaseStatus] || status) : '—' }
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
function walletStatus(value: string) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[value] || value }
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `purchase-review-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function openReview(decision: 'APPROVE' | 'REJECT') {
reviewDecision.value = decision; reviewRemark.value = ''; reviewKey.value = idempotencyKey(); reviewDialogOpen.value = true
const dispositionColumns: DataTableColumn[] = [
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
{ key: 'active', label: '状态', width: 110, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.active ? '有效' : '已撤销或更正' },
]
const cartonColumns: DataTableColumn[] = [
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 160 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 },
{ key: 'measurements', label: '长 × 宽 × 高(cm)', minWidth: 220, formatter: (row: ProcurementCarton) => [row.lengthCm, row.widthCm, row.heightCm].map(value => measurement(value, 2)).join(' × ') },
{ key: 'weight', label: '毛重(kg)', width: 120, formatter: (row: ProcurementCarton) => measurement(row.grossWeightKg, 3) },
{ key: 'sources', label: '装货来源', minWidth: 240, formatter: (row: ProcurementCarton) => row.sources.map(source => `${detail.value?.physical.receipts.find(receipt => receipt.id === source.receiptId)?.receiptNo || '未关联实收'}:${source.quantity} 件`).join(';') },
]
const shipmentColumns: DataTableColumn[] = [
{ key: 'shipmentNo', prop: 'shipmentNo', label: '交寄编号', minWidth: 180 }, { key: 'leg', label: '运输段', width: 110, formatter: (row: ProcurementShipment) => row.leg === 'OVERSEAS' ? '国际交寄' : '寄客户' },
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 150 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 100 }, { key: 'trackingNo', prop: 'trackingNo', label: '实际运单', minWidth: 180 },
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
]
const feeColumns: DataTableColumn[] = [
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => feePurposes[row.purpose] || '待核对' },
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
{ key: 'walletStatus', label: '资金状态', minWidth: 150, formatter: (row: ProcurementFee) => feeStatus(row) },
{ key: 'sourceLinked', label: '费用来源关联', width: 130, formatter: (row: ProcurementFee) => row.purpose === 'INTERNATIONAL_ESTIMATE' ? '预估不入池' : row.purpose === 'REFUND' ? '退款不入应收' : ['ZERO', 'NOT_APPLICABLE'].includes(row.determination) ? '无需入池' : row.sourceLinked ? '已完成' : '待完成' },
{ key: 'active', label: '版本状态', width: 100, formatter: (row: ProcurementFee) => row.active ? '有效' : '历史版本' },
]
const labelGroups = computed(() => detail.value ? [
{ leg: 'OVERSEAS', title: '国际段', value: detail.value.transport.overseasLabels },
{ leg: 'DOMESTIC', title: '寄客户段', value: detail.value.transport.domesticLabels },
].filter(group => group.value) : [])
const labelColumns: DataTableColumn[] = [
{ key: 'cartonId', label: '原箱', minWidth: 150, formatter: (row: { cartonId: string }) => detail.value?.physical.cartons.find(box => box.id === row.cartonId)?.cartonNo || '未关联箱' },
{ key: 'fileId', label: '面单文件', minWidth: 200, formatter: (row: { fileId: string }) => detail.value?.files.find(file => file.fileId === row.fileId)?.fileName || '未关联文件' },
{ key: 'page', prop: 'page', label: '页码', width: 75 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 }, { key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 180 },
{ key: 'appliedAt', label: '实际贴单时间', minWidth: 160, formatter: (row: { appliedAt: string | null }) => dateTime(row.appliedAt) },
]
const fileLabels: Record<string, string> = { PURCHASE: '购买凭证', BUDGET: '预算授权', LABEL: '快递面单', EXCEPTION: '异常证据', RECEIPT: '收货凭证', VOUCHER: '费用凭证' }
const noteColumns: DataTableColumn[] = [
{ key: 'note_no', prop: 'note_no', label: '说明编号', minWidth: 180 },
{ key: 'target', label: '关联事实', minWidth: 180, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.target_type === 'PURCHASE' ? order.value?.orderNo || '—' : row.target_type === 'WAREHOUSE' ? row.original_value_json?.name || '实际仓记录' : row.target_type === 'RECEIPT' ? detail.value?.physical.receipts.find(item => item.id === row.target_id)?.receiptNo || '未关联实收' : detail.value?.transport.shipments.find(item => item.id === row.target_id)?.shipmentNo || '未关联交寄' },
{ key: 'reason', prop: 'reason', label: '内部事实说明', minWidth: 260 },
{ key: 'warehouseCorrection', label: '仓库更正留痕', minWidth: 190, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.corrected_value_json ? `${row.original_value_json?.name || '—'} → ${row.corrected_value_json.warehouseName || '—'}` : '—' },
{ key: 'block', label: '阻断状态', width: 140, formatter: (row: PlatformProcurement['controls']['notes'][number]) => !row.blocking ? '仅说明' : row.resolved_at ? '已解除阻断' : '待处理阻断' },
{ key: 'resolution_reason', prop: 'resolution_reason', label: '解除依据', minWidth: 220 },
{ key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 220 },
]
const remainingColumns: DataTableColumn[] = [{ key: 'businessNo', prop: 'businessNo', label: '货物来源', minWidth: 180 }, { key: 'kind', label: '剩余类型', minWidth: 130, formatter: (row: PlatformProcurement['controls']['remaining'][number]) => remainingLabels[row.kind] }, { key: 'quantity', prop: 'quantity', label: '待处理件数', width: 130 }]
const closureColumns: DataTableColumn[] = [{ key: 'closure_no', prop: 'closure_no', label: '关闭编号', minWidth: 180 }, { key: 'financial_conclusion', prop: 'financial_conclusion', label: '内部财务结论', minWidth: 250 }, { key: 'reason', prop: 'reason', label: '内部关闭原因', minWidth: 220 }, { key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 250 }, { key: 'created_at', label: '关闭时间', minWidth: 170, formatter: (row: PlatformProcurement['controls']['closures'][number]) => dateTime(row.created_at) }]
const closureItemColumns: DataTableColumn[] = [
{ key: 'source', label: '处置来源', minWidth: 180, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => row.source_kind === 'PURCHASE_REMAINDER' ? order.value?.orderNo || '—' : row.source_kind === 'TRANSIT_REMAINDER' ? detail.value?.physical.cartons.find(item => item.id === row.source_id)?.cartonNo || '未关联原箱' : detail.value?.physical.receipts.find(item => item.id === row.source_id)?.receiptNo || '未关联实收' },
{ key: 'source_kind', label: '来源类型', width: 130, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => remainingLabels[row.source_kind] },
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90 }, { key: 'decision', label: '实际处置', minWidth: 150, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => decisionLabels[row.decision] || '待核对' }, { key: 'destination', prop: 'destination', label: '实际去向与接收方', minWidth: 260 },
]
const correctionColumns: DataTableColumn[] = [
{ key: 'correctionNo', prop: 'correctionNo', label: '更正编号', minWidth: 190 }, { key: 'rootFeeNo', prop: 'rootFeeNo', label: '原费用编号', minWidth: 170 },
{ key: 'targetOriginalAmount', label: '正确原币总额', minWidth: 150, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetOriginalAmount, row.originalCurrency) },
{ key: 'targetAmount', label: '目标人民币', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetAmount, 'CNY') },
{ key: 'previousNetAmount', label: '更正前净实扣', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.previousNetAmount, 'CNY') },
{ key: 'differenceAmount', label: '本次差额', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.differenceAmount, 'CNY') },
{ key: 'feeNos', label: '关联实际费用或调整', minWidth: 240, formatter: (row: PlatformProcurement['feeCorrections'][number]) => row.feeNos.join(';') || '无资金差额' },
]
const fileColumns: DataTableColumn[] = [
{ key: 'actions', slotName: 'actions', label: '操作', width: 90, fixed: 'left' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 250 },
{ key: 'category', label: '用途', width: 120, formatter: (row: ProcurementFile) => fileLabels[row.category] || '待核对' },
{ key: 'createdAt', label: '上传时间', minWidth: 160, formatter: (row: ProcurementFile) => dateTime(row.createdAt) },
]
const historyColumns: DataTableColumn[] = [{ key: 'createdAt', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['reviewHistory'][number]) => dateTime(row.createdAt) }, { key: 'operation', label: '操作', width: 120, formatter: (row: PlatformProcurement['reviewHistory'][number]) => ({ CREATE: '创建', REVIEW: '审核', UPDATE: '编辑备注' } as Record<string, string>)[row.operationType || ''] || '采购变更' }, { key: 'remark', prop: 'remark', label: '说明', minWidth: 250 }]
const commandColumns: DataTableColumn[] = [{ key: 'created_at', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['operations'][number]) => dateTime(row.created_at) }, { key: 'reason', prop: 'reason', label: '履约操作说明', minWidth: 400 }]
let generation = 0; let inFlight: Promise<void> | null = null; let inFlightScope = ''
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
onBeforeUnmount(() => { generation++ })
function load() {
const currentScope = scope.value
if (inFlight && inFlightScope === currentScope) return inFlight
const current = ++generation; const number = purchaseId.value; const customer = customerId.value
detail.value = null; loadError.value = null; loading.value = false
if (!number || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return Promise.resolve() }
loading.value = true; inFlightScope = currentScope
const promise = getOverseasPurchase(number, customer).then(result => { if (current === generation) detail.value = result }).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
if (current === generation) loading.value = false
if (inFlight === promise) inFlight = null
})
inFlight = promise; return promise
}
function closeReview() {
if (reviewing.value) return
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
}
async function submitReview() {
if (!detail.value) return
reviewing.value = true
async function download(fileId: unknown) {
const file = detail.value?.files.find(item => item.fileId === fileId)
if (!file || !order.value || downloading.value) return
downloading.value = file.fileId; operationError.value = null; const current = scope.value
try {
detail.value = await reviewOverseasPurchase(detail.value.orderNo, customerId.value, {
version: detail.value.version,
decision: reviewDecision.value,
idempotencyKey: reviewKey.value || (reviewKey.value = idempotencyKey()),
remark: reviewRemark.value.trim() || undefined,
})
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
ElMessage.success(reviewDecision.value === 'APPROVE' ? '采购订单审核已通过' : '采购订单已拒绝')
} catch (error) { ElMessage.error(getApiErrorDetails(error, '采购订单审核失败').message) }
finally { reviewing.value = false }
}
async function upload(category: 'RECEIPT' | 'VOUCHER', event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
uploading.value = true
try { const saved = await uploadOverseasPurchaseFile(detail.value.orderNo, customerId.value, category, file); if (category === 'VOUCHER') { voucher.value = saved; feeDialogOpen.value = true } else { ElMessage.success('签收文件已上传'); await load() } }
catch (error) { ElMessage.error(getApiErrorDetails(error, '文件上传失败').message) } finally { uploading.value = false }
}
async function saveFee() {
if (!detail.value || !voucher.value || feeForm.amount <= 0 || !feeForm.feeItemName.trim()) { ElMessage.warning('请填写正确的费用名称和金额'); return }
savingFee.value = true
try { await createOverseasPurchaseFee(detail.value.orderNo, customerId.value, { feeItemCode: feeForm.feeItemCode.trim(), feeItemName: feeForm.feeItemName.trim(), amount: feeForm.amount, voucherFileId: voucher.value.fileId, remark: feeForm.remark.trim() || undefined }); feeDialogOpen.value = false; voucher.value = null; ElMessage.success('费用已登记并提交资金冻结'); await load() }
catch (error) { ElMessage.error(getApiErrorDetails(error, '费用登记失败').message) } finally { savingFee.value = false }
}
async function load() {
detail.value = null; loadError.value = null
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
loading.value = true
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, file.fileId)
if (scope.value !== current) return
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error) { if (scope.value === current) operationError.value = getApiErrorDetails(error, '文件下载失败') }
finally { downloading.value = null }
}
let activatedOnce = false
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
watch(scope, () => { void load() }, { immediate: true })
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage class="admin-density"><DetailPageLayout class="purchase-detail" aria-label="海外采购订单详情">
<template #header>
<div class="density-detail-header">
<div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.orderNo || '—' }}</h1><StatusBadge v-if="detail" :status="statusLabels[detail.status]" /></div>
<div v-if="detail?.status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="header-actions"><el-button type="danger" plain @click="openReview('REJECT')"><X :size="16" />拒绝</el-button><el-button type="primary" @click="openReview('APPROVE')"><Check :size="16" />通过</el-button></div>
<AdminPage class="admin-density"><OperationErrorToast :details="operationError" />
<DetailPageLayout class="purchase-detail density-detail-layout" aria-label="采购订单履约详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid">
<template v-if="detail">
<ProcurementNodeActions :detail="detail" :customer-id="customerId" @changed="load" />
<section><h3>订单及预算</h3><dl class="density-fields"><div v-for="field in fields" :key="field[0]" :class="{ wide: ['下单收货地址', '内部备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>购买事实</h3><dl class="density-fields"><div v-for="field in purchaseFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>履约进度</h3><dl class="density-fields"><div v-for="step in detail.customerView.progress" :key="step.key"><dt>{{ step.title }}</dt><dd>{{ step.completedAt ? dateTime(step.completedAt) : '未完成' }}</dd></div></dl></section>
<section><h3>分次实收记录</h3><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></section>
<section><h3>差异处置记录</h3><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></section>
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></section>
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></section>
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版</h3><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></section>
<section><h3>两段实际交寄</h3><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></section>
<section><h3>费用与资金结果</h3><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></section>
<section><h3>费用更正记录</h3><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></section>
<section><h3>事实说明与阻断</h3><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></section>
<section><h3>剩余货物去向核对</h3><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></section>
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></section>
<section><h3>本单文件</h3><DataTable :data="detail.files" :columns="fileColumns" row-key="fileId" empty-text="暂无文件"><template #actions="{ row }"><el-button class="density-action" :loading="downloading === row.fileId" :disabled="!!downloading" @click="download(row.fileId)">下载</el-button></template></DataTable></section>
<section><h3>操作流水</h3><DataTable :data="detail.reviewHistory" :columns="historyColumns" empty-text="暂无基础操作记录" /><DataTable :data="detail.operations" :columns="commandColumns" empty-text="暂无履约操作记录" /></section>
</template>
</div>
</template>
<template v-if="detail" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ display(detail.customerName) }}</dd></div><div><dt>采购仓库</dt><dd>{{ display(detail.purchaseWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ display(detail.purchaseQuantity) }}</dd></div><div><dt>入库量</dt><dd>{{ display(detail.inboundQuantity) }}</dd></div><div><dt>出库量</dt><dd>{{ display(detail.outboundQuantity) }}</dd></div><div><dt>库存量</dt><dd>{{ display(detail.inventoryQuantity) }}</dd></div></dl></template>
<LoadErrorState v-if="loadError" title="采购订单详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-surface">
<div class="detail-grid">
<section><h3>商品信息</h3><dl class="density-fields"><div v-for="field in productFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>库存信息</h3><dl class="density-fields"><div v-for="field in stockFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>订单信息</h3><dl class="density-fields"><div v-for="field in orderFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>快递信息</h3><dl class="density-fields"><div v-for="field in courierFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section class="full"><div class="section-heading"><h3>出库明细</h3><span>共 {{ outboundDetails.length }} 条,出库量 {{ outboundDetails.reduce((sum, item) => sum + item.quantity, 0) }}</span></div><DataTable :data="outboundDetails" :columns="[{ key: 'quantity', label: '出库数量', width: 110 }, { key: 'carrier', label: '承运商', minWidth: 140 }, { key: 'trackingNo', label: '运单号', minWidth: 160 }, { key: 'shippedAt', label: '出库时间', minWidth: 155, formatter: (row: any) => formatDateTime(row.shippedAt) }, { key: 'remark', label: '备注', minWidth: 180 }]" row-key="id" empty-text="暂无出库明细" /></section>
<section class="full"><div class="section-heading"><h3>签收文件与费用凭证</h3><div v-if="access.can(overseasBusinessPermissions.purchaseEdit)" class="section-actions"><input ref="receiptInput" hidden type="file" @change="upload('RECEIPT', $event)"><input ref="voucherInput" hidden type="file" @change="upload('VOUCHER', $event)"><el-button :loading="uploading" @click="receiptInput?.click()"><Upload :size="15" />上传签收文件</el-button><el-button type="primary" :loading="uploading" :disabled="!detail || ['PENDING_REVIEW', 'REJECTED'].includes(detail.status)" @click="voucherInput?.click()"><Upload :size="15" />登记费用</el-button></div></div><DataTable :data="detail?.files || []" :columns="fileColumns" row-key="id" empty-text="暂无签收文件或费用凭证" /></section>
<section class="full"><div class="section-heading"><h3>费用明细</h3><span>每条费用独立冻结,订单全部入库后结算。</span></div><DataTable :data="detail?.fees || []" :columns="feeColumns" row-key="id" empty-text="暂无费用明细" /></section>
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
</div>
</div>
</DetailPageLayout><AppDialog class="admin-density-dialog" v-model="reviewDialogOpen" :title="reviewDecision === 'APPROVE' ? '通过采购订单' : '拒绝采购订单'" width="min(520px, calc(100vw - 28px))" :confirm-text="reviewDecision === 'APPROVE' ? '确认通过' : '确认拒绝'" :confirm-loading="reviewing" @confirm="submitReview" @cancel="closeReview"><el-alert :title="reviewDecision === 'APPROVE' ? '通过后平台可维护采购入库、出库和费用信息。' : '拒绝后该采购订单将结束审核流程。'" :type="reviewDecision === 'APPROVE' ? 'success' : 'warning'" show-icon :closable="false" /><p class="review-reference">订单编号:{{ detail?.orderNo || '—' }}</p><el-form class="density-form review-form" label-position="top"><el-form-item label="审核备注"><el-input v-model="reviewRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="reviewDecision === 'APPROVE' ? '请输入通过说明(选填)' : '请输入拒绝原因(选填)'" /></el-form-item></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form class="density-form" label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.purchase-detail { gap: 0; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.header-actions, .section-actions { display: flex; align-items: center; gap: var(--admin-space-3); }
.detail-surface { min-width: 0; padding: var(--admin-space-5); }
.purchase-detail { border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
.detail-grid section { min-width: 0; }
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
.detail-grid .density-fields > .wide { grid-column: span 2; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
.section-heading h3 { margin: 0; }
.section-heading span, .review-reference { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.review-reference { margin: var(--admin-space-3) 0; }
:deep(.el-input-number) { width: 100%; }
.detail-grid p { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.density-fields > .wide { grid-column: span 2; }
</style>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../overseasBusinessDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { computed, reactive, ref, watch, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -13,87 +13,78 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getOverseasPurchase, listOverseasPurchaseWarehouseOptions, updateOverseasPurchase } from '../api/platformOverseasBusiness'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getOverseasPurchaseEditor, updateOverseasPurchase } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseOutboundDetail, OverseasPurchaseStatus, OverseasPurchaseWarehouseOption, UpdateOverseasPurchaseRequest } from '../types'
interface PurchaseEditModel {
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
inboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
estimatedArrivalAt: string | null; remark: string | null; version: number
outboundDetails: OverseasPurchaseOutboundDetail[]
}
const route = useRoute(); const router = useRouter()
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
const loading = ref(false); const saving = ref(false); const loaded = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehousesLoading = ref(false); const warehousesError = ref('')
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0, outboundDetails: [] })
const form = reactive<PurchaseEditModel>(initial())
const rules: FormRules<PurchaseEditModel> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
}
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
const inboundEditable = computed(() => ['APPROVED', 'PENDING_OUTBOUND', 'PARTIALLY_OUTBOUND', 'OUTBOUNDED'].includes(form.status))
const outboundQuantity = computed(() => form.outboundDetails.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0))
function addOutbound() { form.outboundDetails.push({ id: `new-${Date.now()}-${form.outboundDetails.length}`, quantity: 0, carrier: null, trackingNo: null, shippedAt: null, remark: null }) }
function removeOutbound(index: number) { form.outboundDetails.splice(index, 1) }
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
async function loadWarehouses() {
warehousesLoading.value = true; warehousesError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouses.value = []; warehousesError.value = '采购仓候选加载失败,请稍后重试' }
finally { warehousesLoading.value = false }
}
const initial = () => ({ orderNo: '', productName: '', purchaseQuantity: 0, remark: '', version: 0 })
const form = reactive(initial())
const canSave = computed(() => loaded.value && !loading.value && !saving.value && access.can(overseasBusinessPermissions.purchaseEdit))
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑采购内部备注'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
let generation = 0
onBeforeUnmount(() => { generation++ })
function back() { return router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: purchaseId.value }, query: { customerId: customerId.value } }) }
async function load() {
Object.assign(form, initial()); loadError.value = null; submitError.value = null
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; captureBaseline(); return }
const current = ++generation; const orderNo = purchaseId.value; const customer = customerId.value
loaded.value = false; loading.value = false; Object.assign(form, initial()); captureBaseline()
loadError.value = null; submitError.value = null
if (!orderNo || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
loading.value = true
try {
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version, outboundDetails: (detail.outboundDetails || []).map((item) => ({ ...item })) })
if (detail.purchaseWarehouseId && !warehouses.value.some((item) => item.warehouseId === detail.purchaseWarehouseId)) warehouses.value.push({ warehouseId: detail.purchaseWarehouseId, warehouseCode: '', warehouseName: detail.purchaseWarehouseName || '当前采购仓', countryCode: '' })
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
const detail = await getOverseasPurchaseEditor(orderNo, customer)
if (current !== generation) return
const order = detail.customerView.order
Object.assign(form, { orderNo: order.orderNo, productName: order.productName, purchaseQuantity: order.purchaseQuantity, version: order.version, remark: detail.internalRemark ?? '' })
loaded.value = true; captureBaseline()
} catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '采购订单加载失败') }
finally { if (current === generation) loading.value = false }
}
async function save() {
if (!await formRef.value?.validate().catch(() => false)) return
if (outboundQuantity.value > form.inboundQuantity) { ElMessage.error('出库总量不能超过入库量'); return }
if (!canSave.value) return
saving.value = true; submitError.value = null
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: form.productSpecification?.trim() || null, productUrl: form.productUrl?.trim() || null, inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: form.purchaseWarehouseId || null, domesticCarrier: form.domesticCarrier?.trim() || null, domesticTrackingNo: form.domesticTrackingNo?.trim() || null, overseasCarrier: form.overseasCarrier?.trim() || null, overseasTrackingNo: form.overseasTrackingNo?.trim() || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark?.trim() || null, outboundDetails: form.outboundDetails.map((item) => ({ ...item, quantity: Number(item.quantity) || 0, carrier: item.carrier?.trim() || null, trackingNo: item.trackingNo?.trim() || null, remark: item.remark?.trim() || null })) }
const current = generation; const orderNo = purchaseId.value; const customer = customerId.value
try {
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
await router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: updated.orderNo }, query: { customerId: updated.customerId } })
} catch (error) { submitError.value = getApiErrorDetails(error, '海外采购订单保存失败') } finally { saving.value = false }
const updated = await updateOverseasPurchase(orderNo, customer, { version: form.version, remark: form.remark.trim() || null })
if (current !== generation) return
form.version = updated.version; captureBaseline(); ElMessage.success('内部备注已保存')
await back()
} catch (error) { if (current === generation) submitError.value = getApiErrorDetails(error, '内部备注保存失败') }
finally { saving.value = false }
}
watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { immediate: true })
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" aria-label="编辑海外采购订单">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })"><ArrowLeft :size="16" />返回详情</el-button><h1 class="density-detail-title">编辑海外采购订单</h1><span class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<FormSection class="density-form-section" title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" placeholder="请输入商品规格" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" placeholder="请输入入库数量" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input :model-value="outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" placeholder="请输入国内承运商" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" placeholder="请输入国内物流单号" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" placeholder="请输入海外承运商" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" placeholder="请输入海外物流单号" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="出库明细"><div class="outbound-head"><span>出库总量:<b>{{ outboundQuantity }}</b> / {{ form.inboundQuantity }}</span><el-button class="density-action" data-tone="primary" size="small" @click="addOutbound">添加出库明细</el-button></div><div v-if="!form.outboundDetails.length" class="empty-outbound">暂无出库明细,可添加多条出库记录</div><div v-for="(item, index) in form.outboundDetails" :key="item.id" class="outbound-row"><el-form-item label="出库数量"><el-input-number v-model="item.quantity" :min="0" :max="form.inboundQuantity" controls-position="right" placeholder="请输入出库数量" /></el-form-item><el-form-item label="承运商"><el-input v-model="item.carrier" maxlength="128" placeholder="请输入快递或物流名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="item.trackingNo" maxlength="128" placeholder="请输入物流单号" /></el-form-item><el-form-item label="出库时间"><el-date-picker v-model="item.shippedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item class="outbound-remark" label="备注"><el-input v-model="item.remark" maxlength="500" placeholder="请输入本次出库备注(选填)" /></el-form-item><el-button class="remove-outbound density-action" data-tone="danger" @click="removeOutbound(index)" :aria-label="`删除第 ${index + 1} 条出库明细`">删除</el-button></div></FormSection>
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
</DetailPageLayout></AdminPage>
<AdminPage class="admin-density overseas-business-editor">
<OperationErrorToast :details="submitError" />
<DetailPageLayout class="overseas-business-form-layout" aria-label="编辑采购内部备注">
<template #header><div class="density-detail-header"><div class="density-detail-heading">
<el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回详情</el-button>
<h1 class="density-detail-title">编辑采购内部备注</h1><span class="form-reference">{{ form.orderNo || '—' }}</span>
</div></div></template>
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="form-panel">
<el-form class="density-form" :model="form" label-position="top" :disabled="!canSave">
<FormSection class="density-form-section" title="采购订单"><div class="form-grid">
<el-form-item label="订单编号"><el-input :model-value="form.orderNo || '—'" readonly /></el-form-item>
<el-form-item label="商品名称"><el-input :model-value="form.productName || '—'" readonly /></el-form-item>
<el-form-item label="采购数量"><el-input :model-value="loaded ? form.purchaseQuantity : '—'" readonly /></el-form-item>
<el-form-item class="span-3" label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="仅供平台内部使用" /></el-form-item>
</div></FormSection>
</el-form>
<footer class="form-actions"><el-button type="primary" :disabled="!canSave" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="back">取消</el-button></footer>
</section>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.span-2 { grid-column: span 2; }
.span-3 { grid-column: 1 / -1; }
.outbound-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-body); }
.outbound-head b { color: var(--cc-text); }
.empty-outbound { padding: var(--admin-space-5); color: var(--cc-muted); text-align: center; }
.outbound-row { display: grid; grid-template-columns: 110px minmax(0,1fr) minmax(0,1fr) 190px minmax(0,1fr) 60px; gap: var(--admin-space-3); align-items: start; padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); }
.outbound-row :deep(.el-form-item) { margin-bottom: 0; }
.outbound-row :deep(.el-input-number), .outbound-row :deep(.el-date-editor) { width: 100%; }
.remove-outbound { align-self: end; }
@media (max-width: 760px) { .form-grid { grid-template-columns: 1fr; } }
</style>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/core/auth/auth'
import { procurementStatuses, statusLabel, money } from '../procurementPresentation'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -14,43 +16,50 @@ import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../a
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
const router = useRouter(); const access = useAccessPreviewStore()
const router = useRouter(); const access = useAccessPreviewStore(); const auth = useAuthStore()
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '', status: '' }); const applied = ref({ ...filters.value })
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130 },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130, singleLine: false },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 220, singleLine: false },
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ key: 'budgetUnitPrice', label: '预算单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => money(row.budgetUnitPrice, row.currency) },
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
{ key: 'status', label: '状态', minWidth: 145, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
]
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
function purchaseStatusLabel(status: OverseasPurchaseStatus) { return statusLabels[status] || status }
function formatAmount(value: string | null, currency: string) { if (value == null) return '—'; const amount = Number(value); return `${Number.isFinite(amount) ? amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value} ${currency}` }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function load() {
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
onBeforeUnmount(() => { generation++ })
function load() {
const query: OverseasPurchaseQuery = { page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || ''), status: String(applied.value.status || '') as OverseasPurchaseStatus | '' }
const key = JSON.stringify([auth.user?.id, query])
if (inFlight && key === requestKey) return inFlight
requestKey = key; const current = ++generation
loading.value = true; loadError.value = null
try {
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
const promise = listOverseasPurchases(query).then(result => {
if (current !== generation) return
rows.value = result.records; total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外采购订单加载失败,请稍后重试') } finally { loading.value = false }
}).catch(error => { if (current === generation) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '采购订单加载失败,请稍后重试') } }).finally(() => {
if (current === generation) loading.value = false
if (inFlight === promise) inFlight = null
})
inFlight = promise; return promise
}
watch(() => auth.user?.id, () => { rows.value = []; total.value = 0; void load() })
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { TransportRouteDialog, routeLocation } from '@/modules/overseas-master-data'
import '../overseasBusinessDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -22,6 +23,7 @@ import type { FleetArrangement } from '../fleetArrangement'
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const routeOpen = ref(false)
const route = useRoute(); const router = useRouter(); const formRef = ref<FormInstance>()
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const routeCustomerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
@@ -124,8 +126,8 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
<FormSection class="density-form-section" title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
</DetailPageLayout></AdminPage>
</el-form><FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
</template>
<style scoped>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { inquiryReturnTarget, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -19,7 +20,14 @@ import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const router = useRouter(); const access = useAccessPreviewStore()
const routeOpen = ref(false)
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) {
routeOrigin.value = routeLocation(row.origin)
routeDestinations.value = [routeLocation(row.destination)]
routeOpen.value = true
}
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
@@ -28,12 +36,13 @@ const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const l
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
])
@@ -58,25 +67,38 @@ const looseboardColumns: DataTableColumn[] = [
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
]
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
const columns = computed(() => {
if (!isLooseboard.value) return truckColumns
const visible = [...looseboardColumns]
visible.splice(visible.findIndex(column => column.key === 'status'), 0,
{ key: 'routeMap', label: '路线地图', width: 120, slotName: 'routeMap' })
return visible
})
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function load() {
const sourceBooking = route.query.fleetBookingNo
const sourceOrder = route.query.orderNo
arrangementOpen.value = false; arrangement.value = null
rows.value = []; total.value = 0; loadError.value = null; loading.value = true
try {
if (isLooseboard.value) {
const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery)
const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery)
rows.value = result.records; total.value = result.total
} else {
const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
rows.value = result.records; total.value = result.total
}
if (inquiryReturnTarget(route.query) && route.query.fleetBookingNo === sourceBooking && route.query.orderNo === sourceOrder) {
const target = rows.value.find(row => row.orderNo === sourceOrder && row.fleetArrangement?.bookingNo === sourceBooking)
if (target) showArrangement(target)
}
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false }
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route.query.orderNo], () => { page.value = 1; applied.value = { orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
</script>
<template>
@@ -84,7 +106,7 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
<template #toolbar-start><h1>{{ domainLabel }}</h1></template>
<template #toolbar-start><el-button v-if="inquiryReturnTarget(route.query)" link @click="router.push(inquiryReturnTarget(route.query)!)">返回原拼车登记</el-button><h1>{{ domainLabel }}</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -92,8 +114,9 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
</DataTable></section>
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog></AdminPage>
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog><TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" /></AdminPage>
</template>
<style scoped>.table-panel { overflow:hidden; }</style>
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { buildControl, controlForm } from './procurementControlForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement, ProcurementControlNote, ProcurementFile } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'control-one', reason: '内部处理依据' }
const proof: ProcurementFile = { id: '51', fileId: '501', category: 'EXCEPTION', fileName: 'agreement.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }
function detail(): PlatformProcurement {
return {
customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '10001', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'OVERSEAS_RECEIVING', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [{ shipmentNo: 'PS-1', leg: 'OVERSEAS', cartonNo: 'C1', quantity: 5, shippedAt: '2020-01-03T10:00:00', carrier: 'UPS', trackingNo: 'TRACK-1' }], fees: [], exceptionSummaries: [] },
purchaseFacts: { actual_warehouse_id: '20', purchased_quantity: 10, purchased_at: '2020-01-01T10:00:00', overseas_shipped_at: '2020-01-03T10:00:00' },
physical: { receipts: [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 6, damagedQuantity: 1, extraQuantity: 2, receivedAt: '2020-01-02T10:00:00', active: true }, { id: '12', receiptNo: 'PR-12', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T12:00:00', active: true }], dispositions: [], packingVersion: 2, packingStatus: 'DRAFT', cartons: [{ id: '31', cartonNo: 'C1', quantity: 5, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 5 }] }, { id: '32', cartonNo: 'C2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }] }] },
transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [{ key: 'EXCEPTION_CLOSE', label: '异常关闭', permission: 'platform:overseas-business:purchase:manage', allowed: true, reason: null }], internalRemark: null,
}
}
function note(): ProcurementControlNote { return { id: '71', note_no: 'PN-71', target_type: 'PURCHASE', target_id: null, blocking: true, reason: '待处理', client_summary: null, created_at: '', resolved_at: null, resolution_reason: null, voucher_file_id: '501', resolution_voucher_id: null, original_value_json: null, corrected_value_json: null } }
function closure() { const value = detail(); value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-1', quantity: 3 }, { kind: 'GOOD', sourceId: '11', businessNo: 'PR-11', quantity: 6 }]; const form = controlForm(value); form.customerVoucherFileId = '501'; form.clientSummary = '客户同意停止寄送'; form.financialConclusion = '所有资金处理已结束'; for (const row of form.resolutions) Object.assign(row, { decision: row.kind === 'PURCHASE_REMAINDER' ? 'SUPPLIER_RETAINED' : 'CUSTOMER_AUTHORIZED_RETENTION', destination: '明确的接收方与留存地点', voucherFileId: '501' }); return { value, form } }
describe('procurement exception control requests', () => {
it('adds a purchase note without leaking the internal reason into a customer summary', () => {
const value = detail(); const form = controlForm(value); form.targetId = 'stale-receipt'
const payload = buildControl('control-notes', form, value, [proof], base, '', '501')
expect(payload).toEqual({ ...base, targetType: 'PURCHASE', blocking: true, voucherFileId: '501' })
expect(payload).not.toHaveProperty('clientSummary'); expect(payload).not.toHaveProperty('targetId')
expect(readPendingProcurement(JSON.stringify({ command: 'control-notes', payload }))).not.toBeNull()
})
it('requires actual order-owned facts and allows notes against historical receipts', () => {
const value = detail(); const form = controlForm(value); form.targetType = 'RECEIPT'; form.targetId = 'other'
expect(() => buildControl('control-notes', form, value, [proof], base, '', '501')).toThrow('本单实收')
form.targetId = '11'; value.physical.receipts[0].active = false; expect(buildControl('control-notes', form, value, [proof], base, '', '501').targetId).toBe('11')
value.transport.shipments = [{ id: '41', shipmentNo: 'PS-41', leg: 'OVERSEAS', shippedAt: '2020-01-03T10:00:00' }]; form.targetType = 'SHIPMENT'; form.targetId = '41'
expect(buildControl('control-notes', form, value, [proof], base, '', '501').targetId).toBe('41')
form.targetId = '42'; expect(() => buildControl('control-notes', form, value, [proof], base, '', '501')).toThrow('本单实际交寄')
})
it('only resolves a still-active blocker with order evidence', () => {
const value = detail(); value.controls.notes = [note()]; const form = controlForm(value); form.noteId = '71'
expect(buildControl('control-notes/resolve', form, value, [proof], base, '', '501')).toEqual({ ...base, noteId: '71', voucherFileId: '501' })
value.controls.notes[0].resolved_at = '2020-01-03T10:00:00'; expect(() => buildControl('control-notes/resolve', form, value, [proof], base, '', '501')).toThrow('尚未处理')
})
it('requires a distinct actual warehouse and sends no direct inventory mutation', () => {
const value = detail(); const form = controlForm(value)
expect(() => buildControl('actual-warehouse/correct', form, value, [proof], base, '20', '501')).toThrow('不同')
expect(buildControl('actual-warehouse/correct', form, value, [proof], base, '30', '501')).toEqual({ ...base, warehouseId: '30', voucherFileId: '501' })
})
it('closes with exact remaining sources, explicit destinations and separate private/public conclusions', () => {
const { value, form } = closure(); const payload = buildControl('exception-close', form, value, [proof], base, '', '')
expect(payload.resolutions?.map(row => [row.kind, row.sourceId, row.quantity])).toEqual([['PURCHASE_REMAINDER', null, 3], ['GOOD', '11', 6]])
expect(payload).toMatchObject({ customerVoucherFileId: '501', financialConclusion: '所有资金处理已结束', clientSummary: '客户同意停止寄送' })
expect(payload).not.toHaveProperty('amount'); expect(payload).not.toHaveProperty('status')
expect(readPendingProcurement(JSON.stringify({ command: 'exception-close', payload }))).toEqual({ command: 'exception-close', payload })
})
it('rejects omitted, duplicated or changed remaining quantities instead of forcing closure', () => {
let { value, form } = closure(); form.resolutions.pop(); expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('去向未明确')
;({ value, form } = closure()); form.resolutions.push({ ...form.resolutions[0] }); expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('不可重复')
;({ value, form } = closure()); form.resolutions[0].quantity = 2; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('改数量')
})
it('rejects wrong source dispositions, missing evidence and financial blockers', () => {
let { value, form } = closure(); form.resolutions[0].decision = 'DISPOSED'; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('来源不匹配')
;({ value, form } = closure()); form.customerVoucherFileId = ''; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('客户确认凭证')
;({ value, form } = closure()); value.actions[0].allowed = false; value.actions[0].reason = '未决资金请求'; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('未决资金请求')
;({ value, form } = closure()); value.controls.notes = [note()]; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('未解除')
})
it('allows an empty remaining list only with the required customer and financial conclusions', () => {
const value = detail(); const form = controlForm(value); form.customerVoucherFileId = '501'; form.financialConclusion = '无未决费用或冻结'; form.clientSummary = '履约已停止'
expect(buildControl('exception-close', form, value, [proof], base, '', '').resolutions).toEqual([])
form.financialConclusion = ''; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('财务处理结论')
})
})
@@ -0,0 +1,51 @@
import type { PlatformProcurement, ProcurementFile, ProcurementRemaining, ProcurementRemainingKind } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
export const controlCommands = new Set<ProcurementCommand>(['control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
export const remainingLabels: Record<ProcurementRemainingKind, string> = { PURCHASE_REMAINDER: '采购未到货', TRANSIT_REMAINDER: '国际段未到货', GOOD: '合格货物', DAMAGED: '破损货物', EXTRA: '多到货物' }
export const decisionLabels: Record<string, string> = { SUPPLIER_RETAINED: '供应商保留', RETURNED_TO_SUPPLIER: '已退回供应商', RETURNED_TO_SENDER: '已退回寄件方', LOSS_CONFIRMED: '已确认丢失', CUSTOMER_AUTHORIZED_RETENTION: '客户授权留存', DISPOSED: '已处置' }
export const allowedDecisions: Record<ProcurementRemainingKind, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
export interface ResolutionDraft extends ProcurementRemaining { decision: string; destination: string; voucherFileId: string }
export interface ControlForm { targetType: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId: string; blocking: boolean; clientSummary: string; noteId: string; customerVoucherFileId: string; financialConclusion: string; resolutions: ResolutionDraft[] }
export function controlForm(detail: PlatformProcurement): ControlForm { return { targetType: 'PURCHASE', targetId: '', blocking: true, clientSummary: '', noteId: '', customerVoucherFileId: '', financialConclusion: '', resolutions: detail.controls.remaining.map(row => ({ ...row, decision: '', destination: '', voucherFileId: '' })) } }
function proof(files: ProcurementFile[], id: string) { if (!files.some(row => row.fileId === id && row.category === 'EXCEPTION')) throw new Error('请选择本单异常或客户确认凭证') }
function sourceKey(row: { kind: string; sourceId: string | null }) { return `${row.kind}:${row.sourceId ?? ''}` }
export function buildControl(command: ProcurementCommand, form: ControlForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
if (command === 'control-notes') {
let targetId: string | undefined
if (form.targetType === 'PURCHASE') { if (!detail.purchaseFacts.purchased_at) throw new Error('尚无真实购买事实,不能关联购买说明') }
else if (form.targetType === 'WAREHOUSE') { if (!detail.purchaseFacts.actual_warehouse_id) throw new Error('尚无实际收货仓'); targetId = detail.purchaseFacts.actual_warehouse_id }
else if (form.targetType === 'RECEIPT') { if (!detail.physical.receipts.some(row => row.id === form.targetId)) throw new Error('请选择本单实收记录'); targetId = form.targetId }
else { if (!detail.transport.shipments.some(row => row.id === form.targetId)) throw new Error('请选择本单实际交寄记录'); targetId = form.targetId }
proof(files, voucherFileId)
if (form.clientSummary.trim().length > 500) throw new Error('对客摘要最多 500 字')
return { ...base, targetType: form.targetType, ...(targetId ? { targetId } : {}), blocking: form.blocking, voucherFileId, ...(form.clientSummary.trim() ? { clientSummary: form.clientSummary.trim() } : {}) }
}
if (command === 'control-notes/resolve') {
if (!detail.controls.notes.some(note => note.id === form.noteId && note.blocking && !note.resolved_at)) throw new Error('请选择本单尚未处理的阻断异常')
proof(files, voucherFileId); return { ...base, noteId: form.noteId, voucherFileId }
}
if (command === 'actual-warehouse/correct') {
if (!detail.purchaseFacts.actual_warehouse_id) throw new Error('尚无实际仓,请使用计划仓调整')
if (!warehouseId || warehouseId === detail.purchaseFacts.actual_warehouse_id) throw new Error('请选择与原实际仓不同的有效海外收货仓')
proof(files, voucherFileId); return { ...base, warehouseId, voucherFileId }
}
const action = detail.actions.find(row => row.key === 'EXCEPTION_CLOSE')
if (!action?.allowed) throw new Error(action?.reason || '尚不满足异常关闭条件')
if (!detail.purchaseFacts.purchased_at) throw new Error('购买前请使用取消订单')
if (detail.controls.notes.some(note => note.blocking && !note.resolved_at)) throw new Error('请先处理本单未解除的阻断异常')
proof(files, form.customerVoucherFileId)
const expected = new Map(detail.controls.remaining.map(row => [sourceKey(row), row]))
const resolutions = form.resolutions.map(row => {
const source = expected.get(sourceKey(row))
if (!source || !Number.isSafeInteger(row.quantity) || row.quantity <= 0 || source.quantity !== row.quantity) throw new Error('处置必须恰好覆盖每份剩余来源,不可重复、跨单或改数量')
expected.delete(sourceKey(row))
if (!allowedDecisions[row.kind].includes(row.decision)) throw new Error('处置方式与剩余货物来源不匹配')
if (!row.destination.trim() || row.destination.trim().length > 500) throw new Error('请填写每份货物的实际去向及接收方,最多 500 字')
proof(files, row.voucherFileId)
return { kind: row.kind, sourceId: row.sourceId, quantity: row.quantity, decision: row.decision, destination: row.destination.trim(), voucherFileId: row.voucherFileId }
})
if (expected.size) throw new Error('仍有剩余货物去向未明确')
if (!form.financialConclusion.trim() || form.financialConclusion.trim().length > 1000) throw new Error('请填写财务处理结论,最多 1000 字')
if (!form.clientSummary.trim() || form.clientSummary.trim().length > 500) throw new Error('请填写对客关闭摘要,最多 500 字')
return { ...base, resolutions, customerVoucherFileId: form.customerVoucherFileId, financialConclusion: form.financialConclusion.trim(), clientSummary: form.clientSummary.trim() }
}
@@ -0,0 +1,16 @@
export interface ProcurementCorrectionDraft {
feeId: string
targetOriginalAmount: string
reason: string
voucherFileId: string
orderVersion: number
}
export function readCorrectionDraft(raw: string | null): ProcurementCorrectionDraft | null {
if (!raw) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object') throw new Error('费用更正草稿无法读取')
const draft = value as Record<string, unknown>
if (!['feeId', 'targetOriginalAmount', 'reason', 'voucherFileId'].every(key => typeof draft[key] === 'string') || !Number.isSafeInteger(draft.orderVersion)) throw new Error('费用更正草稿无法读取')
return { feeId: draft.feeId as string, targetOriginalAmount: draft.targetOriginalAmount as string, reason: draft.reason as string, voucherFileId: draft.voucherFileId as string, orderVersion: draft.orderVersion as number }
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { buildFinance, financeForm, feeRetryPermission, refundAvailable, targetPreview, replacementCandidates, replacementPreview } from './procurementFinanceForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement, ProcurementInternalFee, ProcurementFile } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'finance-one', reason: '凭证核对完成' }
const proof: ProcurementFile = { id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }
function fee(id = '1', purpose = 'DUTY'): ProcurementInternalFee { return { id, feeNo: `PF-${id}`, purpose, confirmationOrigin: 'BUSINESS', feeItemCode: purpose, feeItemName: purpose, feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7.000000', exchangeRateMonth: '2020-01', businessDate: '2020-01-02', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'SETTLED', walletLedgerId: `900${id}`, walletErrorCode: null, walletErrorMessage: null, remark: null } }
function detail(fees: ProcurementInternalFee[] = []): PlatformProcurement {
return { customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '9', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: null, actualWarehouseName: null, actualWarehouseCode: null, status: 'WAIT_CHARGES', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: fees.map(row => ({ feeNo: row.feeNo, purpose: row.purpose, name: row.feeItemName, version: row.feeVersion, active: row.active, determination: row.determination, amount: row.amount, currency: row.currency, originalAmount: row.originalAmount, originalCurrency: row.originalCurrency, exchangeRate: row.exchangeRate, exchangeRateMonth: row.exchangeRateMonth, businessDate: row.businessDate, parentFeeNo: null, walletStatus: row.walletStatus, sourceLinked: ['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE'].includes(row.walletStatus) })), exceptionSummaries: [] }, purchaseFacts: { domestic_received_at: '2020-01-04T10:00:00' }, physical: { receipts: [], dispositions: [], packingVersion: 1, packingStatus: 'CONFIRMED', cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, files: [proof], fees, feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, reviewHistory: [], operations: [], actions: [], internalRemark: null }
}
function chain() { const root = fee(); const extra = { ...fee('2', 'ADJUSTMENT'), amount: '20.00', originalAmount: '20.00', originalCurrency: 'CNY', exchangeRate: '1', parentFeeId: '1' }; const refund = { ...fee('3', 'REFUND'), amount: '10.00', originalAmount: '10.00', originalCurrency: 'CNY', exchangeRate: '1', parentFeeId: '1', walletStatus: 'REFUNDED' }; return detail([root, extra, refund]) }
describe('procurement fee determination and correction rules', () => {
it('builds independent additional fees with a scoped voucher and recoverable payload', () => {
const value = detail(); value.purchaseFacts.purchased_at = '2020-01-02T10:00:00'
const form = financeForm(value)
Object.assign(form.additional, { name: '保管费', amount: '3.25', currency: 'USD', businessDate: '2020-01-03', voucherFileId: '501' })
const payload = buildFinance('fees/additional', form, value, [proof], base, '')
expect(payload).toEqual({ ...base, name: '保管费', amount: '3.25', currency: 'USD', businessDate: '2020-01-03', voucherFileId: '501' })
expect(readPendingProcurement(JSON.stringify({ command: 'fees/additional', payload }))).toEqual({ command: 'fees/additional', payload })
expect(() => buildFinance('fees/additional', form, value, [], base, '')).toThrow('凭证')
form.additional.businessDate = '2999-01-01'
expect(() => buildFinance('fees/additional', form, value, [proof], base, '')).toThrow('真实业务日期')
delete value.purchaseFacts.purchased_at
expect(() => buildFinance('fees/additional', form, value, [proof], base, '')).toThrow('已发生购买')
})
it('prevents ordinary creation after initial confirmation', () => {
const value = detail(); value.operations.push({ action: 'CONFIRM_CHARGES' } as PlatformProcurement['operations'][number])
expect(() => buildFinance('charges/confirm', financeForm(value), value, [proof], base, '')).toThrow('首次核费后')
expect(feeRetryPermission({ ...fee(), confirmationOrigin: 'FINANCIAL' })).toContain('finance-adjust')
})
it('requires every fixed determination and does not treat missing values as zero', () => {
const value = detail(); const form = financeForm(value)
expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('未知不能视为零元')
for (const row of form.fixed) Object.assign(row, { determination: 'ZERO', amount: '0.00', businessDate: '2020-01-04', reason: '明确免收' })
const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
expect(payload.internationalFreight?.value?.determination).toBe('ZERO'); expect(payload.otherDetermination).toBe('NOT_APPLICABLE'); expect(payload.otherFees).toEqual([])
expect(readPendingProcurement(JSON.stringify({ command: 'charges/confirm', payload }))).toEqual({ command: 'charges/confirm', payload })
})
it('requires evidence for a positive amount and validates zero and not-applicable amounts', () => {
const value = detail(); const form = financeForm(value)
for (const row of form.fixed) Object.assign(row, { determination: 'NOT_APPLICABLE', amount: '0.00', businessDate: '2020-01-04', reason: '不适用' })
Object.assign(form.fixed[0], { determination: 'POSITIVE', amount: '10.01' })
expect(() => buildFinance('charges/confirm', form, value, [], base, '')).toThrow('费用凭证')
form.fixed[0].voucherFileId = '501'; const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
expect(payload.internationalFreight?.value).toMatchObject({ amount: '10.01', voucherFileId: '501' })
form.fixed[1].amount = '2.00'; expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('0.00')
})
it('reuses immutable existing fee versions without sending new charge values', () => {
const value = detail([fee('1', 'INTERNATIONAL_FREIGHT'), fee('2', 'DUTY'), fee('3', 'DOMESTIC_FREIGHT'), fee('4', 'OTHER')]); const form = financeForm(value)
const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
expect(payload.internationalFreight).toEqual({ feeId: '1' }); expect(payload.duty).toEqual({ feeId: '2' }); expect(payload.domesticFreight).toEqual({ feeId: '3' }); expect(payload.otherFees?.[0].selection).toEqual({ feeId: '4' })
form.others = []; form.otherDetermination = 'NOT_APPLICABLE'; expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('省略有效费用')
})
it('calculates a target using the original frozen rate and net successful chain only', () => {
const value = chain(); expect(targetPreview(value, value.fees[0], '12.00')).toEqual({ target: '84.00', net: '80.00', difference: '4.00' })
const form = financeForm(value); form.feeId = '1'; form.targetOriginalAmount = '12.00'
expect(buildFinance('fees/correct-target', form, value, [proof], base, '501')).toEqual({ ...base, feeId: '1', targetOriginalAmount: '12.00', voucherFileId: '501' })
expect(() => targetPreview(value, value.fees[1], '12.00')).toThrow('原实际费用')
})
it('treats actual international freight as the correction root even when it references the original estimate', () => {
const actual = { ...fee('2', 'INTERNATIONAL_FREIGHT'), parentFeeId: '1' }
const estimate = { ...fee('1', 'INTERNATIONAL_ESTIMATE'), amount: '100.00', walletStatus: 'SETTLED' }
const value = detail([estimate, actual]); const form = financeForm(value); form.feeId = '2'; form.targetOriginalAmount = '12.00'
expect(targetPreview(value, actual, '12.00')).toEqual({ target: '84.00', net: '70.00', difference: '14.00' })
expect(buildFinance('fees/correct-target', form, value, [proof], base, '501').feeId).toBe('2')
})
it('replaces only the latest terminated unpaid version using its frozen rate and preserves original amounts', () => {
const original = { ...fee('1', 'INTERNATIONAL_FREIGHT'), active: false, walletStatus: 'TERMINATED', walletLedgerId: null }
const value = detail([original]); const form = financeForm(value); form.feeId = '1'; form.targetOriginalAmount = '12.00'
expect(replacementPreview(value, original, '12.00')).toEqual({ target: '84.00', net: '0.00', difference: '84.00' })
const payload = buildFinance('fees/replace-failed', form, value, [proof], base, '501')
expect(payload).toEqual({ ...base, feeId: '1', targetOriginalAmount: '12.00', voucherFileId: '501' }); expect(readPendingProcurement(JSON.stringify({ command: 'fees/replace-failed', payload }))).not.toBeNull()
value.fees.push({ ...fee('2', 'INTERNATIONAL_FREIGHT'), feeVersion: 2, walletStatus: 'FREEZE_PENDING' })
expect(replacementCandidates(value)).toEqual([]); expect(original.amount).toBe('70.00')
})
it('keeps sample replacement within customer authorization and makes financial replacements retry under finance permission', () => {
const original = { ...fee('1', 'SAMPLE_PAYMENT'), active: false, walletStatus: 'RELEASED' }; const value = detail([original])
expect(() => replacementPreview(value, original, '12.00')).toThrow('客户授权预算')
value.customerView.budget.totalAmount = '100.00'; expect(replacementPreview(value, original, '12.00').target).toBe('84.00')
const replacement = { ...fee('2', 'SAMPLE_PAYMENT'), confirmationOrigin: 'FINANCIAL' as const, walletStatus: 'FREEZE_FAILED', feeVersion: 2 }; value.fees.push(replacement)
value.feeCorrections = [{ correctionNo: 'PC-1', rootFeeNo: 'PF-1', targetOriginalAmount: '12.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', targetAmount: '84.00', previousNetAmount: '0.00', differenceAmount: '84.00', feeNos: ['PF-2'] }]
expect(feeRetryPermission(replacement)).toBe('platform:overseas-business:purchase:finance-adjust')
})
it('preserves cent accuracy beyond JavaScript safe integers and rounds half up', () => {
const value = detail([fee()]); value.fees[0].exchangeRate = '1.000000'
expect(targetPreview(value, value.fees[0], '90071992547409.93')).toEqual({ target: '90071992547409.93', net: '70.00', difference: '90071992547339.93' })
value.fees[0].exchangeRate = '7.000000'; expect(() => targetPreview(value, value.fees[0], '90071992547409.93')).toThrow('超出支持范围')
value.fees[0].exchangeRate = '0.500000'; expect(targetPreview(value, value.fees[0], '0.01').target).toBe('0.01')
})
it('blocks a new target while any related adjustment remains unsettled or unlinked', () => {
const value = chain(); value.customerView.fees[1].sourceLinked = false
expect(() => targetPreview(value, value.fees[0], '12.00')).toThrow('未完成的调整')
})
it('caps refunds against the selected original consumption without borrowing another charge balance', () => {
const value = chain(); expect(refundAvailable(value, value.fees[0])).toBe(6000n); expect(refundAvailable(value, value.fees[1])).toBe(2000n)
const form = financeForm(value); form.feeId = '1'; form.refundAmount = '60.00'
const payload = buildFinance('fees/refund', form, value, [proof], base, '501'); expect(payload.amount).toBe('60.00')
expect(readPendingProcurement(JSON.stringify({ command: 'fees/refund', payload }))).not.toBeNull()
form.refundAmount = '60.01'; expect(() => buildFinance('fees/refund', form, value, [proof], base, '501')).toThrow('剩余可退')
form.refundAmount = '0.00'; expect(() => buildFinance('fees/refund', form, value, [proof], base, '501')).toThrow('正额')
})
it('selects retry permission by fee purpose and rejects unknown outcomes', () => {
const value = detail([{ ...fee('1', 'SAMPLE_PAYMENT'), walletStatus: 'FREEZE_FAILED' }]); const form = financeForm(value); form.feeId = '1'
expect(feeRetryPermission(value.fees[0])).toBe('platform:overseas-business:purchase:buy')
expect(buildFinance('fees/retry', form, value, [], base, '')).toEqual({ ...base, feeId: '1' })
value.fees[0].walletStatus = 'FREEZE_PENDING'; expect(() => buildFinance('fees/retry', form, value, [], base, '')).toThrow('未知结果')
expect(feeRetryPermission(fee('2', 'INTERNATIONAL_ESTIMATE'))).toContain(':estimate'); expect(feeRetryPermission(fee('3', 'REFUND'))).toContain(':finance-adjust')
})
it('only releases an effective estimate and does not construct a refund request', () => {
const value = detail([{ ...fee('1', 'INTERNATIONAL_ESTIMATE'), walletStatus: 'FROZEN' }]); const form = financeForm(value); form.feeId = '1'
expect(buildFinance('fees/release-estimate', form, value, [], base, '')).toEqual({ ...base, feeId: '1' })
value.fees[0].walletStatus = 'SETTLED'; expect(() => buildFinance('fees/release-estimate', form, value, [], base, '')).toThrow('有效预估')
})
it('rejects a persisted selection that mixes an existing reference and a new amount', () => {
const selection = { feeId: '1', value: { determination: 'ZERO', amount: '0.00', currency: 'CNY', businessDate: '2020-01-04', reason: '免费' } }
expect(() => readPendingProcurement(JSON.stringify({ command: 'charges/confirm', payload: { ...base, internationalFreight: selection, duty: selection, domesticFreight: selection, otherDetermination: 'NOT_APPLICABLE', otherFees: [] } }))).toThrow('ambiguous')
})
})
@@ -0,0 +1,113 @@
import type { PlatformProcurement, ProcurementInternalFee, ProcurementFile } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody, ProcurementFeeSelection } from './api/procurementCommands'
import { cents, decimal } from './procurementPresentation'
export const financeCommands = new Set<ProcurementCommand>(['charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional'])
export type ChargePurpose = 'INTERNATIONAL_FREIGHT' | 'DUTY' | 'DOMESTIC_FREIGHT' | 'OTHER'
export interface ChargeDraft { purpose: ChargePurpose; code: string; name: string; mode: 'NEW' | 'EXISTING'; feeId: string; determination: '' | 'POSITIVE' | 'ZERO' | 'NOT_APPLICABLE'; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string; reason: string }
export interface AdditionalDraft { name: string; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string }
export interface FinanceForm { additional: AdditionalDraft; fixed: ChargeDraft[]; otherDetermination: 'NOT_APPLICABLE' | 'ITEMIZED'; others: ChargeDraft[]; feeId: string; targetOriginalAmount: string; refundAmount: string }
const titles: Record<ChargePurpose, string> = { INTERNATIONAL_FREIGHT: '实际国际运费', DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用' }
export function chargeDraft(purpose: ChargePurpose, fee?: ProcurementInternalFee, code = ''): ChargeDraft { return { purpose, code: fee?.feeItemCode || code || purpose, name: fee?.feeItemName || titles[purpose], mode: fee ? 'EXISTING' : 'NEW', feeId: fee?.id || '', determination: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '', reason: '' } }
export function financeForm(detail: PlatformProcurement): FinanceForm { const fees = detail.fees.filter(row => row.active); const others = fees.filter(row => row.purpose === 'OTHER').map(row => chargeDraft('OTHER', row)); return { additional: { name: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '' }, fixed: (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => chargeDraft(purpose, fees.find(row => row.purpose === purpose))), otherDetermination: others.length ? 'ITEMIZED' : 'NOT_APPLICABLE', others, feeId: '', targetOriginalAmount: '', refundAmount: '' } }
export function feeRetryPermission(fee: ProcurementInternalFee) { return `platform:overseas-business:purchase:${fee.confirmationOrigin === 'FINANCIAL' ? 'finance-adjust' : fee.purpose === 'SAMPLE_PAYMENT' ? 'buy' : fee.purpose === 'INTERNATIONAL_ESTIMATE' ? 'estimate' : ['ADJUSTMENT', 'REFUND'].includes(fee.purpose) ? 'finance-adjust' : 'charge'}` }
export function linked(detail: PlatformProcurement, fee: ProcurementInternalFee) { return detail.customerView.fees.some(row => row.feeNo === fee.feeNo && row.sourceLinked) }
function feeAmount(fee: ProcurementInternalFee) { const amount = cents(fee.amount); if (amount === null || amount < 0n || fee.currency !== 'CNY') throw new Error('原费用人民币金额缺失,请先核对'); return amount }
export function feeChain(detail: PlatformProcurement, selected: ProcurementInternalFee) {
const root = selected.purpose === 'ADJUSTMENT' ? detail.fees.find(row => row.id === selected.parentFeeId) : selected
if (!root || ['REFUND', 'ADJUSTMENT', 'INTERNATIONAL_ESTIMATE'].includes(root.purpose) || !root.active || !linked(detail, root)) throw new Error('原实际费用及来源关联尚未完成')
const originals = new Set([root.id, ...detail.fees.filter(row => row.purpose === 'ADJUSTMENT' && row.parentFeeId === root.id).map(row => row.id)])
const chain = detail.fees.filter(row => originals.has(row.id) || (row.purpose === 'REFUND' && !!row.parentFeeId && originals.has(row.parentFeeId)))
if (chain.some(row => row.active && !linked(detail, row))) throw new Error('该费用链仍有未完成的调整或退款,请先完成或终止原请求')
return chain
}
export function refundAvailable(detail: PlatformProcurement, fee: ProcurementInternalFee) {
feeChain(detail, fee)
if (fee.walletStatus !== 'SETTLED' || !fee.walletLedgerId || fee.purpose === 'INTERNATIONAL_ESTIMATE') throw new Error('退款必须关联已成功实扣的实际费用')
return feeAmount(fee) - detail.fees.filter(row => row.purpose === 'REFUND' && row.parentFeeId === fee.id && row.walletStatus === 'REFUNDED').reduce((sum, row) => sum + feeAmount(row), 0n)
}
export function replacementCandidates(detail: PlatformProcurement) {
return detail.fees.filter(fee => !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) && !fee.sourceFeeId && !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)
&& !detail.fees.some(other => other.purpose === fee.purpose && other.feeItemCode === fee.feeItemCode && (other.active || other.feeVersion > fee.feeVersion)))
}
function frozenTarget(fee: ProcurementInternalFee, amount: string) {
const original = cents(amount); const rate = /^(\d+)(?:\.(\d{1,12}))?$/.exec(String(fee.exchangeRate))
if (original === null || original < 0n || !rate) throw new Error('请填写正确原币总额,并核对原汇率快照')
const denominator = 10n ** BigInt((rate[2] || '').length); const numerator = BigInt(rate[1] + (rate[2] || ''))
if (numerator <= 0n) throw new Error('原汇率快照无效')
const target = (original * numerator * 2n + denominator) / (2n * denominator)
if (target > 99999999999999999999n) throw new Error('折算人民币金额超出支持范围')
return target
}
export function replacementPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
if (!replacementCandidates(detail).some(row => row.id === fee.id)) throw new Error('须选择最新、已终止或释放且未实扣的实际费用')
const target = frozenTarget(fee, amount)
if (target > 9999999999999999n || cents(amount)! > 9999999999999999n) throw new Error('折算人民币或原币金额超出支持范围')
if (fee.purpose === 'SAMPLE_PAYMENT') {
const budget = detail.customerView.budget; const limit = cents(budget.totalAmount)
if (limit === null) throw new Error('客户预算尚未明确')
if ((budget.currency === fee.originalCurrency && cents(amount)! > limit) || (budget.currency === 'CNY' && target > limit)) throw new Error('替代货款超过客户授权预算,请先完成预算核验')
}
return { target: decimal(target), net: '0.00', difference: decimal(target) }
}
export function targetPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
if (['ADJUSTMENT', 'REFUND', 'INTERNATIONAL_ESTIMATE'].includes(fee.purpose)) throw new Error('目标更正须选择原实际费用')
const chain = feeChain(detail, fee); const target = frozenTarget(fee, amount)
const net = chain.reduce((sum, row) => row.walletStatus === 'SETTLED' ? sum + feeAmount(row) : row.walletStatus === 'REFUNDED' ? sum - feeAmount(row) : sum, 0n)
if (target - net > 9999999999999999n) throw new Error('追加人民币金额超出支持范围')
return { target: decimal(target), net: decimal(net), difference: decimal(target - net) }
}
function amount(value: string, positive = false) { const parsed = cents(value.trim()); if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(value.trim()) || parsed === null || (positive ? parsed <= 0n : parsed < 0n)) throw new Error(positive ? '请填写正额金额,最多两位小数' : '请明确填写非负金额,最多两位小数'); return value.trim() }
function select(draft: ChargeDraft, detail: PlatformProcurement, files: ProcurementFile[]): ProcurementFeeSelection {
if (draft.mode === 'NEW' && detail.operations.some(row => row.action === 'CONFIRM_CHARGES')) throw new Error('首次核费后只可引用已有费用,新发生或替代费用请使用财务操作')
if (draft.mode === 'EXISTING') {
if (!detail.fees.some(row => row.id === draft.feeId && row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}引用的费用不是本单该用途的有效版本`)
return { feeId: draft.feeId }
}
if (detail.fees.some(row => row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}已有有效费用,请引用原版本或先按权限更正`)
if (!draft.determination) throw new Error(`${draft.name}尚未明确核定,未知不能视为零元`)
const value = amount(draft.amount, draft.determination === 'POSITIVE')
if (draft.determination !== 'POSITIVE' && cents(value) !== 0n) throw new Error('零元或不适用必须明确记为 0.00')
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.businessDate) || !Number.isFinite(Date.parse(draft.businessDate))) throw new Error(`请选择${draft.name}的业务日期`)
if (!draft.reason.trim() || draft.reason.trim().length > 500) throw new Error(`请填写${draft.name}的核定说明,最多 500 字`)
if (draft.determination === 'POSITIVE' && !files.some(file => file.fileId === draft.voucherFileId && file.category === 'VOUCHER')) throw new Error(`${draft.name}须关联本单费用凭证`)
return { value: { determination: draft.determination, amount: value, currency: draft.currency, businessDate: draft.businessDate, ...(draft.voucherFileId ? { voucherFileId: draft.voucherFileId } : {}), reason: draft.reason.trim() } }
}
export function buildFinance(command: ProcurementCommand, form: FinanceForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, voucherFileId: string): ProcurementCommandBody {
if (command === 'fees/additional') {
if (!detail.purchaseFacts.purchased_at) throw new Error('独立新增费用须关联已发生购买的订单')
const row = form.additional; const value = amount(row.amount, true)
if (!row.name.trim() || row.name.trim().length > 128) throw new Error('请填写独立费用名称,最多 128 字')
const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
if (!/^\d{4}-\d{2}-\d{2}$/.test(row.businessDate) || !Number.isFinite(Date.parse(row.businessDate)) || row.businessDate > today) throw new Error('请选择已发生费用的真实业务日期')
if (!files.some(file => file.fileId === row.voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单新增费用凭证')
if ((base.reason?.length || 0) > 500) throw new Error('新增费用说明最多 500 字')
return { ...base, name: row.name.trim(), amount: value, currency: row.currency, businessDate: row.businessDate, voucherFileId: row.voucherFileId }
}
if (command === 'charges/confirm') {
if (!detail.purchaseFacts.domestic_received_at) throw new Error('须完成国内收货后确认费用')
const fixed = (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => { const rows = form.fixed.filter(row => row.purpose === purpose); if (rows.length !== 1) throw new Error('固定费用项目不完整'); return select(rows[0], detail, files) })
if ((form.otherDetermination === 'NOT_APPLICABLE') !== (form.others.length === 0)) throw new Error('其他费用适用时须逐项核定,不适用时不保留收费行')
const codes = new Set<string>()
const otherFees = form.others.map(row => { if (!/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || !row.name.trim() || row.name.trim().length > 128) throw new Error('其他费用名称必填且项目不可重复'); codes.add(row.code); return { code: row.code, name: row.name.trim(), selection: select(row, detail, files) } })
const selected = new Set([...fixed, ...otherFees.map(row => row.selection)].map(row => row.feeId).filter(Boolean))
if (detail.fees.some(row => row.active && ['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT', 'OTHER'].includes(row.purpose) && !selected.has(row.id))) throw new Error('不能通过省略有效费用隐藏原收费')
return { ...base, internationalFreight: fixed[0], duty: fixed[1], domesticFreight: fixed[2], otherDetermination: form.otherDetermination, otherFees }
}
const fee = detail.fees.find(row => row.id === form.feeId); if (!fee) throw new Error('请选择本单费用')
const common = { ...base, feeId: fee.id }
if (command === 'fees/retry' || command === 'fees/terminate') {
if (!fee.active || !fee.walletStatus.endsWith('_FAILED')) throw new Error('仅能处理资金结果已明确失败的有效费用,未知结果不能新建重试')
return common
}
if (command === 'fees/release-estimate') {
if (!fee.active || fee.purpose !== 'INTERNATIONAL_ESTIMATE' || !['FROZEN', 'ZERO'].includes(fee.walletStatus)) throw new Error('仅能释放已冻结或已确认零元的有效预估')
return common
}
if (!files.some(file => file.fileId === voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单财务更正凭证')
if ((base.reason?.length || 0) > 500) throw new Error('财务更正说明最多 500 字')
if (command === 'fees/replace-failed') { const targetOriginalAmount = amount(form.targetOriginalAmount); replacementPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
if (command === 'fees/correct-target') { const targetOriginalAmount = amount(form.targetOriginalAmount); targetPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
const refund = amount(form.refundAmount, true)
if (cents(refund)! > refundAvailable(detail, fee)) throw new Error('退款超过该原扣款的剩余可退金额')
return { ...common, amount: refund, voucherFileId }
}
@@ -0,0 +1,132 @@
import { isAxiosError } from 'axios'
import { ApiResponseError } from '@/shared/api/http'
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
export interface PendingProcurement { command: ProcurementCommand; payload: ProcurementCommandBody }
const commands = new Set(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
export function commandKey() {
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
}
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
if (raw === null) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
const payload = value.payload
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
const body = payload as Record<string, unknown>
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
if (value.command === 'review') {
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
} else if (!nonempty('reason')) throw new Error('missing pending reason')
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
}
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
}
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
}
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
if (value.command === 'dispositions') {
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
}
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
if (value.command === 'packing') {
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
const names = new Set<string>()
for (const item of body.cartons) {
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
names.add(item.cartonNo)
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
}
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
if (!source || typeof source !== 'object') return true
const row = source as Record<string, unknown>
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
})) throw new Error('invalid packing source')
}
}
if (value.command === 'labels') {
const receiver = body.receiver
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
const cartonIds = new Set<string>()
for (const row of body.labels) {
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
cartonIds.add(row.cartonId)
}
}
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
}
if (value.command === 'domestic-repack') {
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
}
}
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
if (!positiveId('feeId')) throw new Error('invalid original fee')
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
}
if (value.command === 'charges/confirm') {
const selection = (input: unknown) => {
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
const item = input as Record<string, unknown>
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
const value = item.value as Record<string, unknown>
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
}
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
const codes = new Set<string>()
for (const row of body.otherFees) {
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
codes.add(row.code); selection(row.selection)
}
}
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
if (value.command === 'exception-close') {
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
const sources = new Set<string>()
for (const row of body.resolutions) {
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
}
}
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
return value as PendingProcurement
}
export function definitelyRejected(error: unknown) {
const status = error instanceof ApiResponseError ? error.status : isAxiosError(error) ? error.response?.status : undefined
const code = error instanceof ApiResponseError ? error.code : ''
return [400, 401, 403, 404, 409, 422].includes(status ?? 0) || ['A0400', 'A0401', 'A0403', 'A0404', 'A0409', 'A0422'].includes(code)
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { buildPacking, buildPhysical, physicalForm, shippedCartons, managementReason, receiptCorrectionReason } from './procurementPhysicalForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'physical-one', reason: '现场核对' }
function detail(): PlatformProcurement {
return {
customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '10001', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'OVERSEAS_RECEIVING', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [{ shipmentNo: 'PS-1', leg: 'OVERSEAS', cartonNo: 'C1', quantity: 5, shippedAt: '2020-01-03T10:00:00', carrier: 'UPS', trackingNo: 'TRACK-1' }], fees: [], exceptionSummaries: [] },
purchaseFacts: { actual_warehouse_id: '20', purchased_quantity: 10, purchased_at: '2020-01-01T10:00:00', overseas_shipped_at: '2020-01-03T10:00:00' },
physical: { receipts: [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 6, damagedQuantity: 1, extraQuantity: 2, receivedAt: '2020-01-02T10:00:00', active: true }, { id: '12', receiptNo: 'PR-12', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T12:00:00', active: true }], dispositions: [], packingVersion: 2, packingStatus: 'DRAFT', cartons: [{ id: '31', cartonNo: 'C1', quantity: 5, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 5 }] }, { id: '32', cartonNo: 'C2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }] }] },
transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [], internalRemark: null,
}
}
describe('procurement physical request validation', () => {
it('builds multi-carton allocations with exact dimensions, retaining independent source references', () => {
const value = detail(); const form = physicalForm(value)
const payload = buildPhysical('packing', form, value, base, '', '')
expect(payload.cartons?.[1].sources).toEqual([{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }])
expect(payload.cartons?.[0].grossWeightKg).toBe('1.234')
expect(readPendingProcurement(JSON.stringify({ command: 'packing', payload }))).toEqual({ command: 'packing', payload })
form.cartons[0].sources[0].quantity = 1
expect(payload.cartons?.[0].sources[0].quantity).toBe(5)
})
it('rejects repeated boxes, duplicate same-box sources, missing sources and cross-box over-allocation', () => {
const value = detail(); let boxes = physicalForm(value).cartons
boxes[1].cartonNo = 'C1'; expect(() => buildPacking(boxes, value)).toThrow('箱号')
boxes = physicalForm(value).cartons; boxes[0].sources.push({ receiptId: '11', quantity: 1 }); expect(() => buildPacking(boxes, value)).toThrow('同箱来源不可重复')
boxes = physicalForm(value).cartons; boxes[0].sources = []; expect(() => buildPacking(boxes, value)).toThrow('每箱必须')
boxes = physicalForm(value).cartons; boxes[0].sources[0].quantity = 6; expect(() => buildPacking(boxes, value)).toThrow('超过实收 PR-11')
})
it('rejects fractional pieces, inactive receipts and excessive measurement precision', () => {
const value = detail(); let boxes = physicalForm(value).cartons
boxes[0].sources[0].quantity = 1.5; expect(() => buildPacking(boxes, value)).toThrow('整数')
boxes = physicalForm(value).cartons; boxes[0].lengthCm = '10.001'; expect(() => buildPacking(boxes, value)).toThrow('最多 2 位')
boxes = physicalForm(value).cartons; boxes[0].grossWeightKg = '1.0001'; expect(() => buildPacking(boxes, value)).toThrow('最多 3 位')
value.physical.receipts[0].active = false; expect(() => buildPacking(physicalForm(value).cartons, value)).toThrow('有效海外实收')
})
it('requires domestic receipts to use an actually shipped original carton and excludes unshipped boxes', () => {
const value = detail(); const form = physicalForm(); form.leg = 'DOMESTIC'; form.receivedAt = '2020-01-04T10:00:00'; form.goodQuantity = 3
expect(shippedCartons(value).map(row => row.cartonNo)).toEqual(['C1'])
form.sourceCartonId = '32'; expect(() => buildPhysical('domestic-receipts', form, value, base, '', '')).toThrow('已国际交寄')
form.sourceCartonId = '31'; const payload = buildPhysical('domestic-receipts', form, value, base, '', '')
expect(payload).toMatchObject({ leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0 }); expect(payload).not.toHaveProperty('warehouseId')
expect(readPendingProcurement(JSON.stringify({ command: 'domestic-receipts', payload }))).not.toBeNull()
value.physical.receipts.push({ id: '13', receiptNo: 'PR-13', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 4, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-04T10:00:00', active: true })
expect(() => buildPhysical('domestic-receipts', form, value, base, '', '')).toThrow('超过来源件数')
})
it('preserves the fixed overseas warehouse, rejects future receipts and keeps extras separate', () => {
const value = detail(); value.physical.receipts = []; const form = physicalForm(); form.goodQuantity = 8; form.damagedQuantity = 2; form.extraQuantity = 3; form.receivedAt = '2020-01-02T10:00:00'
expect(() => buildPhysical('overseas-receipts', form, value, base, 'other', '')).toThrow('已冻结')
expect(buildPhysical('overseas-receipts', form, value, base, '20', '')).toMatchObject({ goodQuantity: 8, damagedQuantity: 2, extraQuantity: 3, warehouseId: '20' })
form.receivedAt = '2999-01-01T10:00:00'; expect(() => buildPhysical('overseas-receipts', form, value, base, '20', '')).toThrow('真实实收时间')
})
it('binds a damaged disposition to its receipt and prevents repeat disposal', () => {
const value = detail(); const form = physicalForm(); form.kind = 'DAMAGED'; form.receiptId = '11'; form.quantity = 1
const payload = buildPhysical('dispositions', form, value, base, '', '901')
expect(payload).toMatchObject({ kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', receiptId: '11', quantity: 1, voucherFileId: '901' })
expect(readPendingProcurement(JSON.stringify({ command: 'dispositions', payload }))).not.toBeNull()
value.physical.dispositions.push({ id: '71', leg: 'OVERSEAS', source_carton_id: null, receipt_id: '11', kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', quantity: 1, voucher_file_id: '901', reason: '已处置', active: true, created_at: '' })
expect(() => buildPhysical('dispositions', form, value, base, '', '901')).toThrow('未处理差异')
})
it('binds shortage to the purchase instead of fabricating a receipt', () => {
const value = detail(); value.physical.receipts.pop(); const form = physicalForm(); form.kind = 'SHORTAGE'; form.quantity = 3; form.receiptId = 'stale'
const payload = buildPhysical('dispositions', form, value, base, '', '901')
expect(payload).toMatchObject({ kind: 'SHORTAGE', decision: 'ACCEPT_SHORTAGE', quantity: 3 }); expect(payload).not.toHaveProperty('receiptId')
})
it('corrects an original receipt without counting it twice or moving its origin', () => {
const value = detail(); value.purchaseFacts.overseas_shipped_at = null; const form = physicalForm(); form.receiptId = '11'; form.leg = 'DOMESTIC'; form.sourceCartonId = '31'; form.goodQuantity = 5; form.damagedQuantity = 1; form.receivedAt = '2020-01-02T10:00:00'
const payload = buildPhysical('receipts/correct', form, value, base, '20', '901')
expect(payload).toMatchObject({ receiptId: '11', leg: 'OVERSEAS', goodQuantity: 5 }); expect(payload).not.toHaveProperty('sourceCartonId')
expect(readPendingProcurement(JSON.stringify({ command: 'receipts/correct', payload }))).not.toBeNull()
})
it('blocks shipped receipt corrections while preserving domestic corrections before customer shipment', () => {
const value = detail()
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('本段已实际交寄')
expect(receiptCorrectionReason(value, 'DOMESTIC')).toBeNull()
value.purchaseFacts.customer_shipped_at = '2020-01-05T10:00:00'
expect(receiptCorrectionReason(value, 'DOMESTIC')).toContain('本段已实际交寄')
value.purchaseFacts.overseas_shipped_at = null
value.physical.packingStatus = 'CONFIRMED'
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('先撤回')
value.physical.packingStatus = 'DRAFT'
expect(receiptCorrectionReason(value, 'OVERSEAS')).toBeNull()
})
it.each(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])('blocks physical changes for %s but leaves evidence and warehouse correction available', status => {
const value = detail(); value.customerView.order.status = status
expect(managementReason('receipts/correct', value)).toContain('订单已结束')
expect(managementReason('dispositions', value)).toContain('订单已结束')
expect(managementReason('packing/withdraw', value)).toContain('订单已结束')
expect(managementReason('control-notes', value)).toBeNull()
expect(managementReason('actual-warehouse/correct', value)).toBeNull()
const form = physicalForm(); form.receiptId = '11'
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('订单已结束')
})
it('requires the correct physical source and unfinished receipt before recording differences', () => {
const value = detail(); value.purchaseFacts = {}
expect(managementReason('dispositions', value)).toContain('交寄来源')
value.purchaseFacts.purchased_at = '2020-01-01'
expect(managementReason('dispositions', value)).toBeNull()
value.purchaseFacts.overseas_received_at = '2020-01-02'
expect(managementReason('dispositions', value)).not.toBeNull()
value.purchaseFacts.overseas_shipped_at = '2020-01-03'
expect(managementReason('dispositions', value)).toBeNull()
value.purchaseFacts.domestic_received_at = '2020-01-04'
expect(managementReason('dispositions', value)).toContain('本段收货已完成')
})
it('rejects a shipped receipt even when another leg still offers a correctable receipt', () => {
const value = detail()
value.physical.receipts.push({ ...value.physical.receipts[0]!, id: '13', leg: 'DOMESTIC', sourceCartonId: '31' })
expect(managementReason('receipts/correct', value)).toBeNull()
const form = physicalForm(); form.receiptId = '11'
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('本段已实际交寄')
})
it('confirms the exact draft version and rejects malformed persisted packing before replay', () => {
const value = detail(); const form = physicalForm(value)
expect(buildPhysical('packing/confirm', form, value, base, '', '')).toEqual({ ...base, packingVersion: 2 })
value.physical.packingStatus = 'CONFIRMED'; expect(() => buildPhysical('packing/confirm', form, value, base, '', '')).toThrow('装箱草稿')
const payload = { ...base, cartons: [{ cartonNo: 'C1', lengthCm: '1', widthCm: '1', heightCm: '1', grossWeightKg: '1', sources: [{ receiptId: '11', quantity: 0 }] }] }
expect(() => readPendingProcurement(JSON.stringify({ command: 'packing', payload }))).toThrow('packing source')
})
})
@@ -0,0 +1,120 @@
import type { PlatformProcurement } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands'
export type Leg = 'OVERSEAS' | 'DOMESTIC'
const closedStatuses = new Set(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])
export function receiptCorrectionReason(detail: PlatformProcurement, leg: Leg): string | null {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,只能追加事实说明,不能更正实收数量'
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) return '本段已实际交寄,只能追加更正说明,不能改写数量'
if (leg === 'OVERSEAS' && detail.physical.packingStatus === 'CONFIRMED') return '须先撤回尚未交寄的确认清单,再更正海外实收'
return null
}
export function dispositionReason(detail: PlatformProcurement, leg: Leg): string | null {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能修改差异处置'
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_received_at' : 'domestic_received_at']) return '本段收货已完成,须先受控更正,不能直接改差异结果'
if (leg === 'OVERSEAS' && !detail.purchaseFacts.purchased_at) return '尚无本单真实购买来源'
if (leg === 'DOMESTIC' && !detail.purchaseFacts.overseas_shipped_at) return '尚无本单国际交寄来源'
return null
}
export const managementOptions: { value: ProcurementCommand; label: string }[] = [
{ value: 'dispositions', label: '记录差异处置' }, { value: 'receipts/correct', label: '更正原实收数量' },
{ value: 'dispositions/revoke', label: '撤销原差异处置' }, { value: 'packing/withdraw', label: '撤回未交寄的确认清单' },
{ value: 'control-notes', label: '追加事实说明或阻断' }, { value: 'control-notes/resolve', label: '解除已处理的阻断' },
{ value: 'actual-warehouse/correct', label: '实际仓受控更正' },
]
export function managementReason(command: ProcurementCommand, detail: PlatformProcurement): string | null {
if (command === 'receipts/correct' || command === 'dispositions/revoke') {
const records = command === 'receipts/correct' ? detail.physical.receipts : detail.physical.dispositions
const active = records.filter(row => row.active)
if (!active.length) return command === 'receipts/correct' ? '暂无有效实收记录' : '暂无有效差异处置'
return active.some(row => !receiptCorrectionReason(detail, row.leg)) ? null : receiptCorrectionReason(detail, active[0]!.leg)
}
if (command === 'dispositions') return dispositionReason(detail, 'OVERSEAS') && dispositionReason(detail, 'DOMESTIC')
if (command === 'packing/withdraw') {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能撤回装箱事实'
if (detail.purchaseFacts.overseas_shipped_at) return '国际段已交寄,不能撤回装箱事实'
if (detail.physical.packingStatus !== 'CONFIRMED') return '暂无已确认清单可撤回'
}
if (command === 'control-notes/resolve' && !detail.controls.notes.some(row => row.blocking && !row.resolved_at)) return '暂无待处理阻断'
if (command === 'actual-warehouse/correct' && !detail.purchaseFacts.actual_warehouse_id) return '尚无实际收货仓,请修改计划仓'
return null
}
export const physicalCommands = new Set<ProcurementCommand>(['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke'])
export interface PackingDraft { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number | undefined }[] }
export interface PhysicalForm { leg: Leg; sourceCartonId: string; receiptId: string; dispositionId: string; goodQuantity: number | undefined; damagedQuantity: number | undefined; extraQuantity: number | undefined; receivedAt: string; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number | undefined; cartons: PackingDraft[] }
export function newCarton(): PackingDraft { return { cartonNo: '', lengthCm: '', widthCm: '', heightCm: '', grossWeightKg: '', sources: [{ receiptId: '', quantity: undefined }] } }
export function physicalForm(detail?: PlatformProcurement): PhysicalForm { return { leg: 'OVERSEAS', sourceCartonId: '', receiptId: '', dispositionId: '', goodQuantity: 0, damagedQuantity: 0, extraQuantity: 0, receivedAt: '', kind: 'SHORTAGE', quantity: undefined, cartons: detail?.physical.cartons.length ? detail.physical.cartons.map(row => ({ cartonNo: row.cartonNo, lengthCm: String(row.lengthCm), widthCm: String(row.widthCm), heightCm: String(row.heightCm), grossWeightKg: String(row.grossWeightKg), sources: row.sources.map(source => ({ ...source })) })) : [newCarton()] } }
export function shippedCartons(detail: PlatformProcurement) { const numbers = new Set(detail.customerView.shipments.filter(row => row.leg === 'OVERSEAS').map(row => row.cartonNo)); return detail.physical.cartons.filter(row => numbers.has(row.cartonNo)) }
export function whole(value: number | undefined, label: string, min = 0): number { if (value === undefined || !Number.isSafeInteger(value) || value < min) throw new Error(`${label}必须为${min ? '正' : '非负'}整数`); return value }
export function dimension(value: string, label: string, scale: number, digits: number) { if (!new RegExp(`^\\d{1,${digits}}(?:\\.\\d{1,${scale}})?$`).test(value.trim()) || Number(value) <= 0) throw new Error(`${label}必须为正数,最多 ${scale} 位小数`); return value.trim() }
export function buildPacking(cartons: PackingDraft[], detail: PlatformProcurement): ProcurementPackingCarton[] {
if (!cartons.length) throw new Error('请至少建立一个装货箱')
const used = new Map<string, number>(); const names = new Set<string>()
return cartons.map(carton => {
const cartonNo = carton.cartonNo.trim(); if (!cartonNo || cartonNo.length > 64 || names.has(cartonNo)) throw new Error('箱号必填、不可重复且不超过 64 个字符'); names.add(cartonNo)
if (!carton.sources.length) throw new Error('每箱必须分配实收来源')
const inCarton = new Set<string>()
const sources = carton.sources.map(source => {
const receipt = detail.physical.receipts.find(row => row.id === source.receiptId && row.active && row.leg === 'OVERSEAS')
if (!receipt || inCarton.has(source.receiptId)) throw new Error('请选择有效海外实收来源,同箱来源不可重复')
inCarton.add(source.receiptId)
const quantity = whole(source.quantity, '装箱件数', 1); const total = (used.get(source.receiptId) || 0) + quantity
if (!Number.isSafeInteger(total) || total > receipt.goodQuantity) throw new Error(`装箱件数超过实收 ${receipt.receiptNo} 的合格数量`)
used.set(source.receiptId, total); return { receiptId: source.receiptId, quantity }
})
return { cartonNo, lengthCm: dimension(carton.lengthCm, '长', 2, 10), widthCm: dimension(carton.widthCm, '宽', 2, 10), heightCm: dimension(carton.heightCm, '高', 2, 10), grossWeightKg: dimension(carton.grossWeightKg, '毛重', 3, 9), sources }
})
}
export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, detail: PlatformProcurement, base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
const blocked = managementReason(command, detail)
if (blocked) throw new Error(blocked)
const closed = ['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'].includes(detail.customerView.order.status)
if (closed) throw new Error('订单已结束,不能改写实物记录')
if (command === 'packing') return { ...base, cartons: buildPacking(form.cartons, detail) }
if (command === 'packing/confirm') {
if (detail.physical.packingStatus !== 'DRAFT' || !detail.physical.packingVersion) throw new Error('请先保存当前装箱草稿')
return { ...base, packingVersion: detail.physical.packingVersion }
}
if (command === 'packing/withdraw') {
if (detail.physical.packingStatus !== 'CONFIRMED' || detail.purchaseFacts.overseas_shipped_at) throw new Error('仅允许撤回尚未交寄的已确认清单')
return base
}
if (command === 'dispositions/revoke') {
const row = detail.physical.dispositions.find(row => row.id === form.dispositionId && row.active)
if (!row) throw new Error('请选择有效差异处置')
const reason = receiptCorrectionReason(detail, row.leg)
if (reason) throw new Error(reason)
return { ...base, dispositionId: form.dispositionId, voucherFileId }
}
if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' }
const correction = command === 'receipts/correct'
const old = correction ? detail.physical.receipts.find(row => row.id === form.receiptId && row.active) : undefined
if (correction && !old) throw new Error('请选择有效原实收记录')
const leg: Leg = old?.leg ?? (command === 'overseas-receipts' ? 'OVERSEAS' : command === 'domestic-receipts' ? 'DOMESTIC' : form.leg)
const reason = correction ? receiptCorrectionReason(detail, leg) : command === 'dispositions' ? dispositionReason(detail, leg) : null
if (reason) throw new Error(reason)
const sourceCartonId = old?.sourceCartonId || form.sourceCartonId
if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱')
if (command === 'dispositions') {
const quantity = whole(form.quantity, '处置件数', 1)
const source = leg === 'DOMESTIC' ? sourceCartonId : null
const receipt = detail.physical.receipts.find(row => row.id === form.receiptId && row.active && row.leg === leg && row.sourceCartonId === source)
const records = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source)
const resolved = detail.physical.dispositions.filter(row => row.active && row.leg === leg && row.source_carton_id === source && row.kind === form.kind && (form.kind === 'SHORTAGE' || row.receipt_id === form.receiptId)).reduce((sum, row) => sum + row.quantity, 0)
const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === source)?.quantity
const available = form.kind === 'SHORTAGE' ? Number(expected) - records.reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0) - resolved : receipt ? (form.kind === 'DAMAGED' ? receipt.damagedQuantity : receipt.extraQuantity) - resolved : -1
if (!Number.isSafeInteger(available) || quantity > available) throw new Error('处置件数超过当前来源的未处理差异')
const decisions = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' } as const
return { ...base, leg, ...(leg === 'DOMESTIC' ? { sourceCartonId } : {}), ...(form.kind === 'SHORTAGE' ? {} : { receiptId: form.receiptId }), kind: form.kind, quantity, decision: decisions[form.kind], voucherFileId }
}
const goodQuantity = whole(form.goodQuantity, '合格件数'), damagedQuantity = whole(form.damagedQuantity, '破损件数'), extraQuantity = whole(form.extraQuantity, '多货件数')
if (!Number.isSafeInteger(goodQuantity + damagedQuantity + extraQuantity) || goodQuantity + damagedQuantity + extraQuantity <= 0) throw new Error('本次实收总件数必须大于零')
if (!form.receivedAt || !Number.isFinite(Date.parse(form.receivedAt)) || Date.parse(form.receivedAt) > Date.now()) throw new Error('请填写不晚于当前时间的真实实收时间')
const origin = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_at : detail.purchaseFacts.overseas_shipped_at
if (!origin || Date.parse(form.receivedAt) < Date.parse(origin)) throw new Error('实收时间不能早于购买或国际交寄时间')
const source = leg === 'DOMESTIC' ? sourceCartonId : null
const counted = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source && row.id !== old?.id).reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0)
const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === sourceCartonId)?.quantity
if (expected == null || goodQuantity + damagedQuantity + counted > expected) throw new Error('合格及破损实收超过来源件数,多到货物请填入多货')
if (leg === 'OVERSEAS' && (!warehouseId || (detail.purchaseFacts.actual_warehouse_id && detail.purchaseFacts.actual_warehouse_id !== warehouseId))) throw new Error('实际收货仓必须与本单已冻结的仓库一致')
return { ...base, leg, ...(leg === 'OVERSEAS' ? { warehouseId } : { sourceCartonId }), goodQuantity, damagedQuantity, extraQuantity, receivedAt: form.receivedAt, ...(correction ? { receiptId: form.receiptId, voucherFileId } : {}) }
}
@@ -0,0 +1,65 @@
import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = {
PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中',
WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中',
DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户',
REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭',
}
export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
}
export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
if (status === 'CANCELLED') return 'info'
if (status === 'INTERNATIONAL_TRANSIT') return 'primary'
return 'warning'
}
export function cents(value: Amount | null | undefined): bigint | null {
if (value === null || value === undefined) return null
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(value).trim())
if (!match) return null
const result = BigInt(match[2]) * 100n + BigInt((match[3] || '').padEnd(2, '0'))
return match[1] ? -result : result
}
export function decimal(value: bigint) {
const absolute = value < 0n ? -value : value
return `${value < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`
}
export function money(value: Amount | null | undefined, currency: string | null | undefined = 'CNY') {
const amount = cents(value)
return amount === null ? '—' : `${currency || '—'} ${decimal(amount)}`
}
export function dateTime(value: string | null | undefined) { return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '—' }
export function measurement(value: Amount | null | undefined, scale: number) {
if (value === null || value === undefined) return '—'
const parts = /^(\d+)(?:\.(\d+))?$/.exec(String(value))
if (!parts || (parts[2] || '').length > scale) return '待核对'
return `${parts[1]}.${(parts[2] || '').padEnd(scale, '0')}`
}
export function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
export function productHref(value: string | null | undefined) {
if (!value) return undefined
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined } catch { return undefined }
}
export function feeStatus(fee: ProcurementFee) {
if (fee.purpose === 'INTERNATIONAL_ESTIMATE') {
const label: Record<string, string> = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' }
if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus]
}
const labels: Record<string, string> = {
FREEZE_PENDING: '冻结处理中', FREEZE_FAILED: '冻结未成功', FROZEN: '等待扣款', SETTLE_PENDING: '扣款处理中', SETTLE_ACTUAL_PENDING: '实际费用结算中',
SETTLE_FAILED: '扣款未成功', SETTLE_ACTUAL_FAILED: '实际费用未结清', SETTLED: '已扣款', RELEASE_PENDING: '释放处理中', RELEASE_FAILED: '释放未成功',
RELEASED: '已释放冻结', REFUND_PENDING: '退款处理中', REFUND_FAILED: '退款未成功', REFUNDED: '已退款', ZERO: '已确认零元', NOT_APPLICABLE: '不适用', TERMINATED: '已终止',
}
return fee.walletStatus ? labels[fee.walletStatus] || '待核定' : '待核定'
}
export function feeTone(fee: ProcurementFee): 'success' | 'warning' | 'danger' | 'info' {
if (fee.walletStatus?.endsWith('_FAILED')) return 'danger'
if (['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE', 'RELEASED'].includes(fee.walletStatus || '')) return 'success'
if (fee.walletStatus === 'TERMINATED') return 'info'
return 'warning'
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { buildTransport, transportForm } from './procurementTransportForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement, ProcurementFile, ProcurementLabelSet } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'transport-one', reason: '逐箱核对' }
const files: ProcurementFile[] = [{ id: '51', fileId: '501', category: 'LABEL', fileName: 'labels.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }, { id: '52', fileId: '502', category: 'EXCEPTION', fileName: 'customer.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }, { id: '53', fileId: '503', category: 'RECEIPT', fileName: 'repack.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' }]
function labels(): ProcurementLabelSet { return { id: '71', version: 2, manifestId: '61', cargoHash: 'cargo', sender: { name: '海外发件人', phone: '13800000000', address: 'US warehouse' }, receiver: { name: '国内收货人', phone: '13800000001', address: '上海接收地址' }, labels: [{ cartonId: '31', fileId: '501', page: 1, carrier: 'carrier', trackingNo: 'TRACK-1', appliedAt: '2020-01-02T12:00:00' }, { cartonId: '32', fileId: '501', page: 2, carrier: 'carrier', trackingNo: 'TRACK-2', appliedAt: '2020-01-02T13:00:00' }] } }
function detail(): PlatformProcurement {
const cargo = [{ cartonId: '31', cartonNo: 'BOX-1', quantity: 6, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', packagingRecordId: null, sources: [{ receiptId: '11', quantity: 6 }] }, { cartonId: '32', cartonNo: 'BOX-2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.111', packagingRecordId: null, sources: [{ receiptId: '12', quantity: 4 }] }]
return { customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '9', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳客户地址', contactName: '客户收件人', contactPhone: '13800000002', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'WAIT_OVERSEAS_SHIPMENT', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: [], exceptionSummaries: [] }, purchaseFacts: { overseas_received_at: '2020-01-02T10:00:00', domestic_received_at: '2020-01-04T10:00:00' }, physical: { receipts: [], dispositions: [], packingVersion: 3, packingStatus: 'CONFIRMED', cartons: [] }, transport: { overseasCargo: cargo, domesticCargo: [{ ...cargo[0], quantity: 5, lengthCm: '15.00', grossWeightKg: '1.987', packagingRecordId: '81', sources: [{ receiptId: '21', quantity: 5 }] }], overseasLabels: labels(), domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [...files], reviewHistory: [], operations: [], actions: [{ key: 'SHIP_OVERSEAS', label: '海外交寄', permission: 'ship-overseas', allowed: true, reason: null }, { key: 'SHIP_CUSTOMER', label: '寄客户', permission: 'ship-customer', allowed: true, reason: null }], internalRemark: null }
}
describe('procurement transport form rules', () => {
it('binds the full batch to files, pages and the exact packing version', () => {
const value = detail(); const form = transportForm(value)
const payload = buildTransport('labels', form, value, files, base, '')
expect(payload).toMatchObject({ leg: 'OVERSEAS', packingVersion: 3, labels: [{ cartonId: '31', fileId: '501', page: 1 }, { cartonId: '32', fileId: '501', page: 2 }] })
expect(payload).not.toHaveProperty('sender')
expect(readPendingProcurement(JSON.stringify({ command: 'labels', payload }))).toEqual({ command: 'labels', payload })
form.labels[0].trackingNo = 'changed-after-submit'; expect(payload.labels?.[0].trackingNo).toBe('TRACK-1')
})
it('rejects missing or duplicate cartons, wrong-purpose files and invalid image pages', () => {
const value = detail(); let form = transportForm(value); form.labels.pop(); expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('完整覆盖')
form = transportForm(value); form.labels[1].cartonId = '31'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('完整覆盖')
form = transportForm(value); form.labels[0].fileId = '502'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('每箱选择本单')
form = transportForm(value); expect(() => buildTransport('labels', form, value, [{ ...files[0], contentType: 'image/png' }], base, '')).toThrow('第 1 页')
})
it('uses domestic good quantities and replacement measurements, requiring proof for recipient changes', () => {
const value = detail(); const form = transportForm(value, 'DOMESTIC'); form.labels[0] = { cartonId: '31', fileId: '501', page: 1, carrier: 'SF', trackingNo: 'SF-1' }
expect(form.receiver).toEqual({ name: '客户收件人', phone: '13800000002', address: '深圳客户地址' })
expect(value.transport.domesticCargo[0]).toMatchObject({ quantity: 5, lengthCm: '15.00', grossWeightKg: '1.987' })
expect(buildTransport('labels', form, value, files, base, '')).not.toHaveProperty('addressVoucherFileId')
form.receiver.address = '客户确认的新地址'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('客户确认凭证')
form.addressVoucherFileId = '502'; expect(buildTransport('labels', form, value, files, base, '').addressVoucherFileId).toBe('502')
})
it('requires confirmed packing, domestic receipt completion and an unshipped leg for label changes', () => {
const value = detail(); const form = transportForm(value); value.physical.packingStatus = 'DRAFT'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('确认装箱')
value.physical.packingStatus = 'CONFIRMED'; value.purchaseFacts.overseas_shipped_at = '2020-01-03T10:00:00'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('本段已交寄')
value.purchaseFacts.domestic_received_at = null; expect(() => buildTransport('labels', transportForm(value, 'DOMESTIC'), value, files, base, '')).toThrow('国内收货完成')
})
it('makes zero estimate explicit without sending actual-charge fields', () => {
const value = detail(); const form = transportForm(value); form.businessDate = '2020-01-02'
expect(() => buildTransport('international-estimate', form, value, files, base, '')).toThrow('明确填写')
form.amount = '0.00'; const payload = buildTransport('international-estimate', form, value, files, base, '')
expect(payload).toEqual({ ...base, amount: '0.00', businessDate: '2020-01-02', labelVersion: 2 })
expect(readPendingProcurement(JSON.stringify({ command: 'international-estimate', payload }))).not.toBeNull()
})
it('allows partial application only for current unapplied labels with a real time', () => {
const value = detail(); value.transport.overseasLabels!.labels[0].appliedAt = null; const form = transportForm(value); form.cartonIds = ['31']; form.occurredAt = '2020-01-02T11:00:00'
expect(buildTransport('labels/apply', form, value, files, base, '')).toEqual({ ...base, labelVersion: 2, cartonIds: ['31'], appliedAt: '2020-01-02T11:00:00' })
form.cartonIds = ['32']; expect(() => buildTransport('labels/apply', form, value, files, base, '')).toThrow('尚未确认贴单')
form.cartonIds = ['31']; form.occurredAt = '2020-01-01T10:00:00'; expect(() => buildTransport('labels/apply', form, value, files, base, '')).toThrow('早于上一节点')
})
it('ships the entire authoritative batch and checks label time and server readiness', () => {
const value = detail(); const form = transportForm(value); form.cartonIds = ['31']; form.occurredAt = '2020-01-03T10:00:00'
const payload = buildTransport('overseas-shipment', form, value, files, base, '')
expect(payload).toEqual({ ...base, leg: 'OVERSEAS', labelVersion: 2, cartonIds: ['31', '32'], shippedAt: '2020-01-03T10:00:00' })
expect(readPendingProcurement(JSON.stringify({ command: 'overseas-shipment', payload }))).not.toBeNull()
form.occurredAt = '2020-01-02T12:30:00'; expect(() => buildTransport('overseas-shipment', form, value, files, base, '')).toThrow('早于上一节点')
value.actions[0].allowed = false; value.actions[0].reason = '预估冻结未完成'; expect(() => buildTransport('overseas-shipment', form, value, files, base, '')).toThrow('预估冻结未完成')
})
it('keeps repacking one-to-one and never changes quantity or the original source', () => {
const value = detail(); const form = transportForm(value, 'DOMESTIC'); Object.assign(form, { cartonId: '31', lengthCm: '16.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '2.123' })
const payload = buildTransport('domestic-repack', form, value, files, base, '503')
expect(payload).toEqual({ ...base, cartonId: '31', lengthCm: '16.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '2.123', voucherFileId: '503' })
expect(readPendingProcurement(JSON.stringify({ command: 'domestic-repack', payload }))).not.toBeNull()
form.cartonId = 'other'; expect(() => buildTransport('domestic-repack', form, value, files, base, '503')).toThrow('可寄原箱')
})
it('rejects corrupted recovered shipment identity before any request', () => {
expect(() => readPendingProcurement(JSON.stringify({ command: 'customer-shipment', payload: { ...base, labelVersion: 1, cartonIds: ['31', '31'], leg: 'DOMESTIC', shippedAt: '2020-01-03T10:00:00' } }))).toThrow('transport cartons')
})
})
@@ -0,0 +1,75 @@
import type { PlatformProcurement, ProcurementAddress, ProcurementFile } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
import { dimension, whole, type Leg } from './procurementPhysicalForm'
export const transportCommands = new Set<ProcurementCommand>(['labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment'])
export interface TransportForm {
leg: Leg; receiver: ProcurementAddress; addressVoucherFileId: string
labels: { cartonId: string; fileId: string; page: number | undefined; carrier: string; trackingNo: string }[]
amount: string; businessDate: string; occurredAt: string; cartonIds: string[]; cartonId: string
lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string
}
export function transportCargo(detail: PlatformProcurement, leg: Leg) { return leg === 'OVERSEAS' ? detail.transport.overseasCargo : detail.transport.domesticCargo }
export function transportForm(detail: PlatformProcurement, leg: Leg = detail.purchaseFacts.overseas_shipped_at ? 'DOMESTIC' : 'OVERSEAS'): TransportForm {
const previous = leg === 'OVERSEAS' ? detail.transport.overseasLabels : detail.transport.domesticLabels
const order = detail.customerView.order
const receiver = previous?.receiver ?? (leg === 'DOMESTIC' ? { name: order.contactName, phone: order.contactPhone, address: order.receivingAddress } : { name: '', phone: '', address: '' })
return { leg, receiver: { ...receiver }, addressVoucherFileId: '', labels: transportCargo(detail, leg).map(box => {
const old = previous?.labels.find(row => row.cartonId === box.cartonId)
return { cartonId: box.cartonId, fileId: old?.fileId ?? '', page: old?.page ?? 1, carrier: old?.carrier ?? '', trackingNo: old?.trackingNo ?? '' }
}), amount: '', businessDate: '', occurredAt: '', cartonIds: [], cartonId: '', lengthCm: '', widthCm: '', heightCm: '', grossWeightKg: '' }
}
function requireText(value: string, max: number, label: string) { const result = value.trim(); if (!result || result.length > max) throw new Error(`请填写${label},最多 ${max} 个字符`); return result }
function realTime(value: string, earliest?: string | null) {
const time = Date.parse(value)
if (!value || !Number.isFinite(time) || time > Date.now()) throw new Error('请填写不晚于当前时间的实际操作时间')
if (earliest && time < Date.parse(earliest)) throw new Error('实际操作时间不能早于上一节点')
return value
}
export function buildTransport(command: ProcurementCommand, form: TransportForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, voucherFileId: string): ProcurementCommandBody {
const leg = command === 'customer-shipment' || command === 'domestic-repack' ? 'DOMESTIC' : command === 'labels' ? form.leg : 'OVERSEAS'
const cargo = transportCargo(detail, leg)
if (!cargo.length) throw new Error('当前运输段没有可寄货物')
const labels = leg === 'OVERSEAS' ? detail.transport.overseasLabels : detail.transport.domesticLabels
if (command === 'labels') {
if (detail.physical.packingStatus !== 'CONFIRMED' || !detail.physical.packingVersion) throw new Error('请先确认装箱清单')
if (leg === 'DOMESTIC' && !detail.purchaseFacts.domestic_received_at) throw new Error('国内收货完成后才能制作寄客户面单')
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) throw new Error('本段已交寄,不能修改原面单')
const receiver = { name: requireText(form.receiver.name, 128, '收件人'), phone: form.receiver.phone.trim(), address: requireText(form.receiver.address, 1000, '完整收件地址') }
if (!/^[+0-9 ()-]{5,64}$/.test(receiver.phone)) throw new Error('请填写有效快递联系电话')
const original = detail.customerView.order
const changed = leg === 'DOMESTIC' && (receiver.name !== original.contactName || receiver.phone !== original.contactPhone || receiver.address !== original.receivingAddress)
if (changed && !files.some(file => file.fileId === form.addressVoucherFileId && file.category === 'EXCEPTION')) throw new Error('最终收件信息已变更,请关联本单客户确认凭证')
const unique = new Set(form.labels.map(row => row.cartonId))
if (unique.size !== cargo.length || form.labels.length !== cargo.length || cargo.some(box => !unique.has(box.cartonId))) throw new Error('面单必须完整覆盖本批全部原箱,不可重复或跨单')
const rows = form.labels.map(row => {
const file = files.find(file => file.fileId === row.fileId && file.category === 'LABEL')
if (!file || !['application/pdf', 'image/png', 'image/jpeg'].includes(file.contentType || '')) throw new Error('请为每箱选择本单 PDF、JPG 或 PNG 面单')
const page = whole(row.page, '面单页码', 1)
if (file.contentType !== 'application/pdf' && page !== 1) throw new Error('图片面单只能对应第 1 页')
return { cartonId: row.cartonId, fileId: row.fileId, page, carrier: requireText(row.carrier, 128, '承运商'), trackingNo: requireText(row.trackingNo, 128, '运单号') }
})
return { ...base, leg, packingVersion: detail.physical.packingVersion, receiver, ...(changed ? { addressVoucherFileId: form.addressVoucherFileId } : {}), labels: rows }
}
if (command === 'domestic-repack') {
if (!cargo.some(box => box.cartonId === form.cartonId)) throw new Error('请选择本单国内可寄原箱')
if (!files.some(file => file.fileId === voucherFileId && ['RECEIPT', 'EXCEPTION'].includes(file.category))) throw new Error('请关联本单换包装凭证')
return { ...base, cartonId: form.cartonId, lengthCm: dimension(form.lengthCm, '长', 2, 10), widthCm: dimension(form.widthCm, '宽', 2, 10), heightCm: dimension(form.heightCm, '高', 2, 10), grossWeightKg: dimension(form.grossWeightKg, '毛重', 3, 9), voucherFileId }
}
if (!labels) throw new Error('请先保存本段面单')
if (command === 'international-estimate') {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写人民币预估金额,包括零元')
if (!/^\d{4}-\d{2}-\d{2}$/.test(form.businessDate) || !Number.isFinite(Date.parse(form.businessDate))) throw new Error('请选择预估业务日期')
if ((base.reason?.length || 0) > 500) throw new Error('预估核对说明最多 500 个字符')
return { ...base, labelVersion: labels.version, amount: form.amount.trim(), businessDate: form.businessDate }
}
if (command === 'labels/apply') {
if (!form.cartonIds.length || new Set(form.cartonIds).size !== form.cartonIds.length || form.cartonIds.some(id => !labels.labels.some(row => row.cartonId === id && !row.appliedAt))) throw new Error('请选择当前面单中尚未确认贴单的箱')
return { ...base, labelVersion: labels.version, cartonIds: [...form.cartonIds], appliedAt: realTime(form.occurredAt, detail.purchaseFacts.overseas_received_at) }
}
const action = detail.actions.find(row => row.key === (leg === 'OVERSEAS' ? 'SHIP_OVERSEAS' : 'SHIP_CUSTOMER'))
if (!action?.allowed) throw new Error(action?.reason || '当前不满足交寄条件')
if (labels.labels.length !== cargo.length || cargo.some(box => !labels.labels.some(label => label.cartonId === box.cartonId))) throw new Error('当前面单与可寄原箱不一致,请重新核对')
const latestApplied = labels.labels.reduce<string | null>((latest, row) => row.appliedAt && (!latest || row.appliedAt > latest) ? row.appliedAt : latest, null)
if (leg === 'OVERSEAS' && labels.labels.some(row => !row.appliedAt)) throw new Error('本批全部箱须完成贴单')
return { ...base, leg, labelVersion: labels.version, cartonIds: cargo.map(box => box.cartonId), shippedAt: realTime(form.occurredAt, leg === 'OVERSEAS' ? latestApplied : detail.purchaseFacts.domestic_received_at) }
}
@@ -0,0 +1,119 @@
export type Amount = number | string
export interface ProcurementSummary {
orderNo: string
purchaseCategory: string
productName: string
productSpecification: string | null
purchaseQuantity: number
budgetUnitPrice: Amount | null
currency: string | null
status: string
version: number
createdAt: string
updatedAt: string
}
export interface ProcurementOrder extends ProcurementSummary {
id: string
customerId: string
customerName: string | null
productUrl: string | null
receivingAddress: string
contactName: string
contactPhone: string
estimatedArrivalAt: string | null
plannedWarehouseName: string | null
actualWarehouseCode: string | null
actualWarehouseName: string | null
}
export interface ProcurementFee {
feeNo: string
purpose: string
name: string
version: number | null
active: boolean
determination: string
amount: Amount
currency: string
originalAmount: Amount | null
originalCurrency: string | null
exchangeRate: Amount | null
exchangeRateMonth: string | null
businessDate: string | null
parentFeeNo: string | null
walletStatus: string | null
sourceLinked: boolean
}
export interface ProcurementBox {
cartonNo: string
quantity: number
lengthCm: Amount
widthCm: Amount
heightCm: Amount
grossWeightKg: Amount
}
export interface ProcurementShipment {
shipmentNo: string
leg: 'OVERSEAS' | 'DOMESTIC'
shippedAt: string
cartonNo: string
quantity: number
carrier: string
trackingNo: string
}
export interface ProcurementDetail {
order: ProcurementOrder
budget: { totalAmount: Amount | null; currency: string; authorizedAt: string | null }
purchased: boolean
purchaseConfirmed: boolean
purchaseNeedsAttention: boolean
progress: { key: string; title: string; completedAt: string | null }[]
boxes: ProcurementBox[]
shipments: ProcurementShipment[]
fees: ProcurementFee[]
exceptionSummaries: string[]
}
export interface ProcurementAction { key: string; label: string; permission: string; allowed: boolean; reason: string | null }
export type ProcurementFileCategory = 'PURCHASE' | 'BUDGET' | 'LABEL' | 'EXCEPTION' | 'RECEIPT' | 'VOUCHER'
export interface ProcurementFile { id: string; fileId: string; category: ProcurementFileCategory; fileName: string; contentType: string | null; sizeBytes: number; createdAt: string }
export interface ProcurementReceipt { id: string; receiptNo: string; leg: 'OVERSEAS' | 'DOMESTIC'; sourceCartonId: string | null; goodQuantity: number; damagedQuantity: number; extraQuantity: number; receivedAt: string; active: boolean }
export interface ProcurementDisposition { id: string; leg: 'OVERSEAS' | 'DOMESTIC'; source_carton_id: string | null; receipt_id: string | null; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number; decision: string; voucher_file_id: string; reason: string; active: boolean | number; created_at: string }
export interface ProcurementCarton extends ProcurementBox { id: string; sources: { receiptId: string; quantity: number }[] }
export interface ProcurementCargo extends ProcurementBox { cartonId: string; packagingRecordId: string | null; sources: { receiptId: string; quantity: number }[] }
export interface ProcurementAddress { name: string; phone: string; address: string }
export interface ProcurementLabelSet { id: string; version: number; manifestId: string; cargoHash: string; sender: ProcurementAddress; receiver: ProcurementAddress; labels: { cartonId: string; fileId: string; page: number; carrier: string; trackingNo: string; appliedAt: string | null }[] }
export interface ProcurementInternalFee {
id: string; feeNo: string; purpose: string; confirmationOrigin: 'BUSINESS' | 'FINANCIAL'; feeItemCode: string; feeItemName: string; feeVersion: number; active: boolean; determination: string
amount: Amount; currency: string; originalAmount: Amount; originalCurrency: string; exchangeRate: Amount; exchangeRateMonth: string; businessDate: string
parentFeeId: string | null; sourceFeeId: string | null; voucherFileId: string | null; voucherFileName: string | null
walletStatus: string; walletLedgerId: string | null; walletErrorCode: string | null; walletErrorMessage: string | null; remark: string | null
}
export interface ProcurementFeeCorrection {
correctionNo: string; rootFeeNo: string; targetOriginalAmount: Amount; originalCurrency: string; exchangeRate: Amount; exchangeRateMonth: string
targetAmount: Amount; previousNetAmount: Amount; differenceAmount: Amount; feeNos: string[]
}
export type ProcurementRemainingKind = 'PURCHASE_REMAINDER' | 'TRANSIT_REMAINDER' | 'GOOD' | 'DAMAGED' | 'EXTRA'
export interface ProcurementRemaining { kind: ProcurementRemainingKind; sourceId: string | null; businessNo: string; quantity: number }
export interface ProcurementControlNote {
id: string; note_no: string; target_type: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; target_id: string | null; blocking: boolean | number
reason: string; client_summary: string | null; created_at: string; resolved_at: string | null; resolution_reason: string | null
voucher_file_id: string; resolution_voucher_id: string | null
original_value_json: { name?: string; code?: string } | null
corrected_value_json: { warehouseName?: string; warehouseCode?: string } | null
}
export interface ProcurementClosure { id: string; closure_no: string; financial_conclusion: string; reason: string; client_summary: string; created_at: string }
export interface ProcurementClosureItem { id: string; closure_id: string; source_kind: ProcurementRemainingKind; source_id: string | null; quantity: number; decision: string; destination: string; voucher_file_id: string }
export interface PlatformProcurement {
customerView: ProcurementDetail
purchaseFacts: { overseas_received_at?: string | null; domestic_received_at?: string | null; actual_warehouse_id?: string | null; overseas_shipped_at?: string | null; customer_shipped_at?: string | null; purchased_at?: string | null; purchased_product_name?: string | null; purchased_specification?: string | null; purchased_quantity?: number | null; purchase_amount?: Amount | null; purchase_currency?: string | null; purchase_issue?: string | null; purchase_confirmed_at?: string | null }
physical: { dispositions: ProcurementDisposition[]; receipts: ProcurementReceipt[]; packingVersion: number | null; packingStatus: string | null; cartons: ProcurementCarton[] }
transport: { overseasCargo: ProcurementCargo[]; domesticCargo: ProcurementCargo[]; overseasLabels: ProcurementLabelSet | null; domesticLabels: ProcurementLabelSet | null; shipments: { id: string; shipmentNo: string; leg: string; shippedAt: string }[] }
controls: { notes: ProcurementControlNote[]; remaining: ProcurementRemaining[]; closures: ProcurementClosure[]; closureItems: ProcurementClosureItem[] }
files: ProcurementFile[]
fees: ProcurementInternalFee[]
feeCorrections: ProcurementFeeCorrection[]
reviewHistory: import('./types').OverseasBusinessHistory[]
operations: { action: string; operator_id: string; reason: string; detail_json: unknown; created_at: string }[]
actions: ProcurementAction[]
internalRemark: string | null
}
+6 -26
View File
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'PENDING_OUTBOUND' | 'PARTIALLY_OUTBOUND' | 'OUTBOUNDED'
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'WAIT_PURCHASE' | 'WAIT_OVERSEAS_RECEIPT' | 'OVERSEAS_RECEIVING' | 'WAIT_PACKING' | 'WAIT_LABELS' | 'WAIT_OVERSEAS_SHIPMENT' | 'INTERNATIONAL_TRANSIT' | 'DOMESTIC_RECEIVING' | 'WAIT_CHARGES' | 'WAIT_CUSTOMER_SHIPMENT' | 'SHIPPED_TO_CUSTOMER' | 'REJECTED' | 'CANCELLED' | 'EXCEPTION_CLOSED'
export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
@@ -16,36 +16,16 @@ export interface OverseasPurchaseSummary {
purchaseCategory: string; productName: string; productSpecification: string | null
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
inboundQuantity: number; outboundQuantity: number; inventoryQuantity: number
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
}
export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
productUrl: string | null; receivingAddress: string | null; contactName: string | null; contactPhone: string | null
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
estimatedArrivalAt: string | null; remark: string | null; createdBy: string | null; reviewedBy: string | null
reviewRemark: string | null; reviewedAt: string | null; histories: OverseasBusinessHistory[]
outboundDetails?: OverseasPurchaseOutboundDetail[]
files: OverseasPurchaseFile[]; fees: OverseasPurchaseFee[]
}
export interface OverseasPurchaseFile { id: string; category: 'RECEIPT' | 'VOUCHER'; fileId: string; fileName: string; contentType: string | null; sizeBytes: number; createdAt: string }
export interface OverseasPurchaseFee { id: string; feeNo: string; feeItemCode: string; feeItemName: string; amount: string; currency: string; voucherFileId: string; voucherFileName: string; remark: string | null; walletStatus: string; walletErrorMessage: string | null; createdAt: string }
export interface OverseasPurchaseOutboundDetail { id: string; quantity: number; carrier: string | null; trackingNo: string | null; shippedAt: string | null; remark: string | null }
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
export interface UpdateOverseasPurchaseRequest {
version: number; purchaseCategory?: string; productName?: string; productSpecification?: string | null
productUrl?: string | null; inboundQuantity?: number; purchaseWarehouseId?: string | null
domesticCarrier?: string | null; domesticTrackingNo?: string | null; overseasCarrier?: string | null
overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
outboundDetails?: OverseasPurchaseOutboundDetail[]
export interface UpdateOverseasPurchaseRequest { version: number; remark?: string | null }
/** Fields consumed by the standalone remark editor from the aggregate platform detail. */
export interface OverseasPurchaseEditorDetail {
customerView: { order: { orderNo: string; customerId: string; productName: string; purchaseQuantity: number; version: number } }
internalRemark: string | null
}
export interface ReviewOverseasPurchaseRequest {
version: number
decision: 'APPROVE' | 'REJECT'
idempotencyKey: string
remark?: string
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
export interface LocalTransportQuery {
@@ -1,5 +1,7 @@
# 海外通用资料与车辆管理
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
- 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。
@@ -35,3 +37,5 @@
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
@@ -1,5 +1,21 @@
# 海外通用资料原型核对矩阵
## 运输路线地图
本轮最新要求:使用已有起点、目的地展示规划路线,后续另接GPS。唯一组件为`components/TransportRouteDialog.vue`,经`index.ts`公开导出;无固定样例、无定位采集、不产生业务记录。
| 页面 | 操作 | 原型证据 | 当前承载与结论 |
| --- | --- | --- | --- |
| 排车列表 | 查看路线 | 用户提供`C:/Users/BY-OY/Desktop/新版.html:18627`,轨迹列在车辆状态前 | 装车后车辆信息分区增加路线地图列;起运仓按排车响应warehouseId查询详情;目的仓由服务端route字段拆分后精确查FBA仓码再读详情,多目的仓显式选择 |
| 本土散板列表 | 查看路线 | 同原型27221、27248行 | 在状态前放路线地图列,使用本行origin/destination,FBA仓码先解析为仓库完整地址;当前没有装车/返程分栏,不新增状态、不提供伪造返程轨迹 |
| 本土卡车新增/编辑 | 查看路线 | 同原型7373、7474行,车辆与派车分区 | 表单末尾车辆与派车区域打开,起终点分别组合地点与详细地址,FBA仓码改查主档地址,不要求先保存订单、不补造装箱日期字段 |
统一1100px居中、58vh地图。`routeLocations.ts`区分明确地址与仓库引用,复用Warehouse的列表/详情接口(`platform:warehouse:list`),不变更服务端权限或API。仓码查询限定FBA并做精确匹配;仓库ID直读详情;只采用唯一运营地址(无运营地址时也必须唯一),必须有街道、城市、国家,再拼州、邮编等字段。缺失、不唯一、码与详情不符及接口错误均不发地图请求,不回退仓码或名称查询地图。弹框明确展示解析后的地址及错误诊断,重试重新查询。
仅显式打开/选择目的仓时解析地址,同次打开缓存已查询地点;关闭/换记录丢弃缓存并忽略旧响应,加载时移除旧地图。内嵌与外部打开使用同一完整地址,列表不逐行请求主档。非仓码的用户填写地点文本保持订单语义,不新增地理编码或GPS服务。
原型中的轨迹名称按最新用户要求改为路线,撤销车辆主档顶部临时演示入口。新增/编辑/审核/上传/删除行为不变。1920×1080、100%缩放视觉待人工截图确认;真实地图联网效果待人工确认。
## 内部拼车需求池首版
依据:用户授权按现有车次样式整合;唯一范围为 [内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7、9节,没有另引入外部原型。
@@ -114,3 +130,21 @@
| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 |
| 出车运营应收/实收读取 | `新版.html:8941–8987` 出车记录列表;车辆规划第4.1节及本轮明确确认的Finance权威口径 | 沿原运营列表应收/实收列,点击读取;金额来源在620px只读浮层展示,不新增菜单或财务写表单 | 单车次按需读取,未分配明确显示待分配,缺权限不请求;真实1920×1080、100%视觉及请求量待总控验收 |
## 拼车接货操作简化开发矩阵(2026-10-09)
依据:用户要求按最新版《拼车数据入口与操作简化评估草案》第七、八节开发,沿既有居中1080px表单和独立发布详情,不引入外部原型。
| 页面 | 操作 | 承载与尺寸 | 调整及验收边界 |
|---|---|---|---|
| 拼车接货 | 可拼车次、我的登记、待办 | integrated列表,操作列左置 | 直接登记,销售隐藏容量计算过程,服务端返回处理进度;待1920×1080人工截图 |
| 我的登记 | 多动作与客户名称显示 | 沿上述列表及RowActions | 2026-10-10 TEST截图`minimal-inquiry-saved-1920.jpg`显示230px操作列裁掉第三动作及更多;改320px,客户列210px允许换行,保留原动作和权限;发布后原FB记录复验 |
| 客户货物登记 | 新增、补充资料、只读跟进 | 1080px居中三列 | 首屏客户/目的地/已知货量;确认前必填折叠但显示缺项;始终选填独立;单站自动带入 |
| 客户货物登记 | 确认承运、取消 | 既有居中确认框 | 展示对象及保位/商业订单影响,保存成功不冒充确认成功 |
| 客户货物登记 | 安排来货、核量、排入本车 | 1080px居中,复用履约表单 | 原登记连续处理,按服务端资格;关联业务按权限跳转 |
| 客户货物登记 | 查看排车详情、前往车次审核 | 只读跟进弹框跳转既有独立详情 | 排车与车次分别按各自list权限展示;审核仍由车次独立写权限控制,不在排车只读页暗示可审核;待TEST发布后1920×1080复验 |
| 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 |
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。
@@ -1,3 +1,4 @@
import { normalizeBooking, normalizeIncoming, normalizeBookingResult } from './tripBookings'
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes'
import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates'
@@ -5,7 +6,7 @@ import type { TripBookingQuoteCriteria } from './tripBookings'
const base = '/platform/v1/last-leg/fleet/capacity-offers'
const path = (value: string) => `${base}/${encodeURIComponent(value)}`
const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}`
const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
const number = (value: number | string) => { const n = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
const nullable = (value: number | null) => value == null ? null : number(value)
const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }
export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } }
@@ -26,3 +27,13 @@ export async function saveCapacityInquiry(offerNo: string, bookingNo: string | n
export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/${action}`, body)) }
export const listOfferCustomers = (keyword?: string) => get<OfferCustomerOption[]>(`${base}/customer-options`, { keyword })
export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get<WireQuotePage>(`${path(offerNo)}/quote-candidates`, query)) }
export async function getInquiryFulfillment(bookingNo: string) {
const value = await get<{ booking: import('./tripBookings').WireBooking; preview: { tripRecordVersion: number | string; recordVersion: number | string; incoming: import('./tripBookings').WireIncoming | null }; plannedDepartureAt: string }>(`${inquiryPath(bookingNo)}/fulfillment`)
return { booking: normalizeBooking(value.booking), plannedDepartureAt: value.plannedDepartureAt, preview: { ...value.preview, tripRecordVersion: number(value.preview.tripRecordVersion), recordVersion: number(value.preview.recordVersion), incoming: value.preview.incoming ? normalizeIncoming(value.preview.incoming) : null } }
}
export async function commandInquiryFulfillment(bookingNo: string, action: 'incoming' | 'receipt-confirmation' | 'arrange-dispatch', body: import('../bookingTypes').TripIncomingStart | import('../bookingTypes').TripIncomingAccept | import('../bookingTypes').TripBookingBind | import('../bookingTypes').TripBookingCommand) {
return normalizeBookingResult(await post<import('./tripBookings').WireResult>(`${inquiryPath(bookingNo)}/${action}`, body))
}
export async function saveAndConfirmInquiry(bookingNo: string, body: InquiryWrite) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/save-and-confirm`, body)) }
@@ -6,16 +6,16 @@ const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings
type N = number | string
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
export type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
export type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
const nullable = (value: N | null) => value == null ? null : number(value)
const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
export const normalizeIncoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
export const normalizeBookingResult = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) {
return normalizeFleetQuotePage(unwrapApiResponse(await http.get<ApiEnvelope<WireQuotePage>, ApiEnvelope<WireQuotePage>>(`${path(tripNo)}/quote-candidates`, { params: criteria })))
@@ -28,13 +28,13 @@ export async function listTripBookings(tripNo: string): Promise<TripBookingList>
return { tripRecordVersion: integer(value.tripRecordVersion), records: value.records.map(normalizeBooking) }
}
export async function createTripBooking(tripNo: string, body: TripBookingCreate): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
return normalizeBookingResult(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
}
export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
return normalizeBookingResult(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
}
async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
return normalizeBookingResult(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
}
export const confirmTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'confirm', body)
export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'cancel', body)
@@ -42,10 +42,10 @@ export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripB
const bookingPath = (tripNo: string, bookingNo: string) => `${path(tripNo)}/${encodeURIComponent(bookingNo)}`
export async function getTripIncoming(tripNo: string, bookingNo: string): Promise<TripIncomingPreview> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>, ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>>(`${bookingPath(tripNo, bookingNo)}/incoming`))
return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : incoming(value.incoming) }
return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : normalizeIncoming(value.incoming) }
}
async function incomingCommand(tripNo: string, bookingNo: string, action: string, body: TripIncomingStart | TripIncomingAccept | TripBookingBind): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
return normalizeBookingResult(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
}
export const startTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingStart) => incomingCommand(tripNo, bookingNo, 'incoming', body)
export const acceptTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingAccept) => incomingCommand(tripNo, bookingNo, 'receipt-confirmation', body)
@@ -111,3 +111,13 @@ export async function listTripHistory(tripNo: string, currentPage = 1, size = 20
completion: row.snapshot.completion == null ? null : { ...row.snapshot.completion, outcomes: row.snapshot.completion.outcomes.map(outcome) },
} }))
}
export interface DispatchCandidate { dispatchNo: string; customerId: string; warehouseId: string; packageCount: number; route: string | null }
export async function listTripDispatchCandidates(tripNo: string, keyword?: string) {
const value = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<DispatchCandidate>>, ApiEnvelope<WirePage<DispatchCandidate>>>(`${path(tripNo)}/dispatch-candidates`, { params: { keyword, page: 1, size: 100 } }))
return { ...value, total: integer(value.total), records: value.records.map(row => ({ ...row, packageCount: integer(row.packageCount) })) }
}
export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
export async function prepareTrip(body: TripPreparation): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
}
@@ -12,7 +12,11 @@ export interface CapacityOffer {
destinations: OfferDestination[]; segments: OfferSegment[]; capacityKnown: boolean; canCreateInquiry: boolean; canConfirm: boolean; blockReason: string | null
actions: { canEdit: boolean; canPublish: boolean; canPause: boolean; canResume: boolean; canClose: boolean }
}
export interface InquiryProgress { stage: string; label: string; reservation: string; owner: string; primaryAction: string; message: string; missingFields: string[] }
export interface AppointmentCoverage { status: string; appointments: { appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }[]; missingFields: string[]; sourceVersion: string | null; changeReviewRequired: boolean }
export interface CapacityInquiry {
appointmentCoverage: AppointmentCoverage | null
progress: InquiryProgress
bookingNo: string; offerNo: string; tripNo: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'
recordVersion: number; offerRecordVersion: number; tripRecordVersion: number; salesOwnerUserId: string
customerId: string; customerCode: string; customerName: string; businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
@@ -23,7 +27,7 @@ export interface CapacityInquiry {
}
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage<CapacityInquiry> }
export interface OfferQuery { scope?: 'AVAILABLE' | 'ALL'; page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' }
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' | 'TODO' }
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
export interface InquiryWrite {
@@ -36,6 +40,10 @@ export interface OfferResult { offer: CapacityOffer; replayed: boolean }
export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
export interface OfferCustomerOption { id: string; code: string; name: string }
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
export const inquiryStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' }
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
export const inquiryCargoLabel = (status?: CapacityInquiry['status']) => status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
export const appointmentCoverageLabels: Record<string, string> = { SOURCE_NOT_READY: '待确认承运后筹划', SOURCE_NOT_FOUND: '来源待核对', SOURCE_NOT_MATERIALIZED: '待形成真实货件资料', DESTINATION_MISMATCH: '目的地变化待核对', REVIEW_REQUIRED: '货量待复核', UNASSIGNED: '尚未关联收货预约', PARTIAL: '部分货物未覆盖', PENDING: '等待收货方确认', CONFIRMED: '预约记录已确认' }
export const appointmentMissingLabels: Record<string, string> = { CARRIER_CONFIRMATION: '承运确认', REAL_CARGO_MATERIALS: '真实货件资料', DESTINATION: '目的地核对', CARGO_QUANTITY_REVIEW: '货量复核', RECEIVING_APPOINTMENT: '收货预约', UNCOVERED_CARGO: '未覆盖货物的预约', RECEIVING_CONFIRMATION: '收货方确认', APPOINTMENT_TIMEZONE_UNVERIFIED: '预约时间所属地区尚未核实', SOURCE_CHANGED_REVIEW: '货源变化后重新核对预约' }
@@ -11,19 +11,21 @@ import type { CapacityOffer } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { getCapacityOffer, listCapacityOffers } from '../api/capacityOffers'
import { getTrip, getTripCapacityPlan } from '../api/trips'
import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/trips', () => ({ getTrip: vi.fn(), getTripCapacityPlan: vi.fn(), getTripLoading: vi.fn(), confirmTripCapacityPlan: vi.fn(), confirmTripLoading: vi.fn(), updateTripPlan: vi.fn() }))
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), listCapacityInquiries: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn(), commandCapacityInquiry: vi.fn() }))
const offer: CapacityOffer = { offerNo: 'CO-CLOSE', tripNo: 'TR-CLOSE', status: 'OPEN', recordVersion: 1, tripRecordVersion: 2, warehouseId: '9', warehouseName: '测试仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待核对', actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
const wrappers: VueWrapper[] = []
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getTrip).mockResolvedValue({ trip: { tripNo: offer.tripNo, recordVersion: 2, plannedDepartureAt: offer.plannedDepartureAt, stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } } as Awaited<ReturnType<typeof getTrip>>); vi.mocked(getTripCapacityPlan).mockResolvedValue({ tripNo: offer.tripNo, recordVersion: 2, cargo: [], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) })
afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = ''; vi.restoreAllMocks() })
function mountDialog(kind: 'inquiry' | 'settings') {
const pinia = createPinia()
const wrapper = kind === 'inquiry'
? mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [pinia, ElementPlus] } })
: mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus] } })
: mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus, createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div/>' } }] })] } })
wrappers.push(wrapper)
return { wrapper, pinia }
}
@@ -38,7 +40,8 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
const { wrapper, pinia } = mountDialog(kind)
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
await flushPromises()
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
await wrapper.get('details summary').trigger('click')
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="填写货物名称(确认承运前必填)"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
expect(input.exists()).toBe(true)
await input.setValue('UI-未保存检查'); await flushPromises()
const assertPreserved = () => {
@@ -78,8 +81,9 @@ it('blocks browser back with the real router, cached detail and native dialog, w
await router.push('/pool'); await router.isReady()
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
const input = wrapper.get<HTMLInputElement>('input[placeholder="选填"]')
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.get('details summary').trigger('click')
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
await input.setValue('UI-修复后保留输入'); await flushPromises()
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing()
confirms.mockClear()
@@ -96,8 +100,8 @@ it('blocks browser back with the real router, cached detail and native dialog, w
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
await wrapper.get<HTMLInputElement>('input[placeholder="选填"]').setValue('另一次修改'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
await continueEditing(); await navigation
@@ -31,7 +31,7 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 })
vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => {
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
return { offer, inquiry, replayed: false }
})
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
@@ -54,8 +54,41 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
await price.get('input').setValue('21.75'); await flushPromises()
expect(price.props('modelValue')).toBe(21.75)
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises()
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存登记')!.trigger('click'); await flushPromises()
expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) }))
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('locks the destination in readonly follow-up even without offer detail, while allowing multi-stop editing', async () => {
permissions.values = new Set(['platform:fleet:capacity-offer:manage'])
const multi = { ...offer, destinations: [...offer.destinations, { destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2, remaining: null }] }
const row: CapacityInquiry = {
bookingNo: 'FB-READONLY', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1,
offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17',
customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'LOCAL_LOOSEBOARD', deliveryMode: 'SELF_DELIVERY',
estimatedArrivalAt: null, expectedPalletCount: null,
cargo: { destinationKey: 'FBA:ACY2', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null },
destinationLabel: 'ACY2', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null,
updatedAt: '2026-10-10T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null,
progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '待补资料', missingFields: [] },
}
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer: null, inquiry: row, readonly: true }, global: { plugins: [createPinia(), ElementPlus] } })
wrappers.push(wrapper); await flushPromises()
const destination = () => formItem(wrapper, '本车卸货站').findComponent({ name: 'ElSelect' })
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
await destination().find('.el-select__wrapper').trigger('click'); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('aria-expanded')).toBe('false')
expect(destination().props('modelValue')).toBe('FBA:ACY2')
await wrapper.setProps({ offer: multi, readonly: false }); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeUndefined()
await destination().find('.el-select__wrapper').trigger('click'); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('aria-expanded')).toBe('true')
await select(wrapper, '本车卸货站', 'FBA:ONT8')
expect(destination().props('modelValue')).toBe('FBA:ONT8')
await wrapper.setProps({ inquiry: null }); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeUndefined()
await wrapper.setProps({ offer }); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
expect(saveCapacityInquiry).not.toHaveBeenCalled()
})
@@ -6,25 +6,51 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import { inquiryOrderTarget } from '../inquiryNavigation'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const followupActions = computed(() => {
const row = props.inquiry
if (!row) return []
const manage = access.can('platform:fleet:capacity-offer:manage')
return [
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && (manage || access.can('platform:fleet:capacity-offer:inquiry-edit')) },
{ key: 'confirm', label: '核对并确认', visible: manage && (row.actions.canEdit || row.actions.canConfirm) },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit') },
{ key: 'receipt', label: '核对实收与运费', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm') },
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch') },
{ key: 'fulfillment', label: '查看收货进度', visible: !!row.incomingNo },
{ key: 'dispatch', label: '查看排车详情', visible: !!row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list') },
{ key: 'trip', label: '前往车次审核', visible: !!row.dispatchNo && access.can('platform:fleet:trip:list') },
].filter(action => action.visible)
})
const primaryFollowup = computed(() => {
const key = props.inquiry?.progress.primaryAction
if (key === 'VIEW') return followupActions.value.find(action => action.key === (props.inquiry?.dispatchNo ? 'dispatch' : 'fulfillment'))
return followupActions.value.find(action => action.key.toUpperCase() === key)
})
const secondaryFollowups = computed(() => followupActions.value.filter(action => action !== primaryFollowup.value))
const canReadOrder = computed(() => props.inquiry && inquiryOrderTarget(props.inquiry, access.can))
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null), customerSearched = ref(false)
const quoteRows = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
const pending = ref<{ target: string; body: InquiryWrite } | null>(null)
const pending = ref<{ target: string; confirm: boolean; body: InquiryWrite } | null>(null)
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
const lock = computed(() => !writable.value || busy.value || !!pending.value)
const cargoLabel = computed(() => inquiryCargoLabel(props.inquiry?.status))
const selectedCapacity = computed(() => props.offer?.capacityKnown ? props.offer.destinations.find(stop => stop.destinationKey === form.cargo.destinationKey)?.remaining : null)
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
let generation = 0, customerSequence = 0, quoteSequence = 0
@@ -40,6 +66,7 @@ watch(() => [open.value, target.value], async () => {
else {
const row = props.inquiry
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
if (!row && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
}
await nextTick(); guard.capture()
@@ -69,30 +96,51 @@ async function save() {
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
const current = generation; busy.value = true; error.value = null
try {
pending.value ||= { target: target.value, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success('意向已保存,尚未占用运力') }
pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = pending.value.confirm && props.inquiry ? await saveAndConfirmInquiry(props.inquiry.bookingNo, pending.value.body) : await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success(result.inquiry.status === 'CONFIRMED' ? '已保留货位并关联本土订单,尚未安排来货' : '登记已保存,调度可见,尚未保留货位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
<section v-if="confirmMode" aria-label="本目的站可接余位">
<strong>本目的站可接余位</strong>
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
<p v-else>待调度核对,当前没有可靠的可接余位。</p>
<p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p>
</section>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.customerName }} · {{ inquiry.destinationLabel }}</strong><p>车次:{{ inquiry.tripNo }} · 登记:{{ inquiry.bookingNo }}</p><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
<p v-else>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p>
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
<section v-if="inquiry && readonly" aria-label="继续处理本票货物">
<el-button v-if="primaryFollowup" type="primary" @click="emit('action', primaryFollowup.key, inquiry)">{{ primaryFollowup.label }}</el-button>
<details v-if="secondaryFollowups.length"><summary>其他操作与关联单据</summary><div class="density-actions">
<el-button v-for="action in secondaryFollowups" :key="action.key" @click="emit('action', action.key, inquiry)">{{ action.label }}</el-button>
</div></details>
</section>
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="选填" /></el-form-item>
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}</summary><div class="capacity-inquiry-form">
<el-form-item :label="`${cargoLabel}箱数`"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item :label="`${cargoLabel}标准托位`"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item :label="`${cargoLabel}重量(kg)`"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item :label="`${cargoLabel}体积(m³)`"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
</div></details>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="填写货物名称(确认承运前必填)" /></el-form-item>
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
<el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="计价方式"><el-select v-model="form.cargo.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
@@ -100,16 +148,24 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<el-form-item label="币种"><el-select v-model="form.cargo.currency" filterable allow-create><el-option v-for="currency in ['USD', 'CNY', 'CAD', 'EUR', 'GBP']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.cargo.contactName" placeholder="确认承运前补充" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.cargo.contactPhone" placeholder="确认承运前补充" /></el-form-item>
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item>
</div></details>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>其他说明(选填)</summary>
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item></details>
</el-form>
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
<details><summary>参考报价(可选)</summary><section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
<template v-if="canQuote && writable"><el-button :disabled="lock || !quoteReady" :loading="quoteLoading" @click="queryQuotes()">查询报价库</el-button><el-button v-if="form.quoteRef" :disabled="lock" @click="form.quoteRef = null; quoteSource = null">改为手工询价</el-button>
<el-select v-if="quoteRows.length" :model-value="form.quoteRef ? sourceKey(form.quoteRef) : ''" :disabled="lock" placeholder="选择报价,可另行议价" @update:model-value="selectQuote"><el-option v-for="quote in quoteRows" :key="sourceKey(quote)" :value="sourceKey(quote)" :label="`${quote.quoteNo} · ${quote.quotedUnitPrice} ${quote.currency}`" /></el-select>
<p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
</section></details>
<section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖">
<h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p>
<p v-if="inquiry.appointmentCoverage.missingFields.length">待处理:{{ inquiry.appointmentCoverage.missingFields.map(key => appointmentMissingLabels[key] || '请预约人员核对来源资料').join('、') }}</p>
<p v-for="appointment in inquiry.appointmentCoverage.appointments" :key="appointment.appointmentNo">预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}</p>
<p v-if="inquiry.appointmentCoverage.changeReviewRequired">货源或数量发生变化,请负责预约的人员重新核对覆盖。</p>
</section>
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
<p v-if="inquiry">本土订单:<el-button v-if="canReadOrder" link @click="emit('action', 'business-order', inquiry)">{{ inquiry.businessOrderNo }}</el-button><span v-else>{{ inquiry.businessOrderNo || '尚未成交' }}</span> · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
</AppDialog>
</template>
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getInquiryFulfillment } from '../api/capacityOffers'
import type { CapacityInquiry } from '../capacityOfferTypes'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
const dialog = ref<InstanceType<typeof TripBookingFulfillmentDialog> | null>(null)
const activeMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ inquiry: CapacityInquiry | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [] }>()
const loaded = ref<Awaited<ReturnType<typeof getInquiryFulfillment>> | null>(null)
const error = ref<ApiErrorDetails | null>(null), loading = ref(false)
let generation = 0
async function load() {
if (!props.inquiry || !open.value) return
const current = ++generation; loading.value = true; error.value = null
try { const value = await getInquiryFulfillment(props.inquiry.bookingNo); if (current === generation) loaded.value = value }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本票履约进度读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(() => [open.value, props.inquiry?.bookingNo, props.mode], () => { generation++; if (!open.value || dialog.value?.hasPending) return; activeMode.value = props.mode; loaded.value = null; void load() }, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog class="admin-density-dialog" v-if="!loaded" v-model="open" title="读取本票处理进度" width="1080px" :show-confirm="false">
<p v-if="loading">正在读取本票真实履约资料…</p>
<LoadErrorState v-if="error" title="读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</AppDialog>
<TripBookingFulfillmentDialog v-else ref="dialog" v-model="open" pool :booking="loaded.booking" :initial-preview="loaded.preview" :mode="activeMode"
:arrival-at="inquiry?.estimatedArrivalAt" :instructions="inquiry?.cargo.remark"
:trip="{ tripNo: loaded.booking.tripNo, recordVersion: loaded.preview.tripRecordVersion, plannedDepartureAt: loaded.plannedDepartureAt }"
@saved="emit('saved')" />
</template>
@@ -2,7 +2,7 @@
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
import { inquiryCargoLabel, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
@@ -10,16 +10,23 @@ const access = useAccessPreviewStore()
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
const manage = access.can('platform:fleet:capacity-offer:manage')
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
return [{ key: 'view', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
{ key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
const items: RowActionItem[] = [{ key: 'view', label: '查看进度', priority: 10 },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
{ key: 'fulfillment', label: '收货进度', visible: !!row.incomingNo, disabled: busy },
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
const primary = items.find(action => action.visible !== false && action.key.toUpperCase() === row.progress.primaryAction)
if (primary) { primary.priority = 20; primary.tone = 'primary' }
return items
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false },
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '货量', minWidth: 270, slotName: 'cargo', singleLine: false },
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
]
@@ -28,9 +35,9 @@ const columns: DataTableColumn[] = [
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
<template #status="{ row }">{{ inquiryStatusLabels[(row as CapacityInquiry).status] }}<small v-if="row.actions.blockReason" class="inquiry-reason">{{ row.actions.blockReason }}</small></template>
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #actions="{ row }"><RowActions :max-visible="1" :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
<template #cargo="{ row }"><div>{{ inquiryCargoLabel(row.status) }}:{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</div><div>预计实托数:{{ row.expectedPalletCount ?? '—' }}</div></template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
</DataTable></template>
@@ -0,0 +1,77 @@
import { shallowMount, flushPromises } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import Dialog from './CapacityOfferSettingsDialog.vue'
import * as offers from '../api/capacityOffers'
import * as trips from '../api/trips'
import type { FleetTrip } from '../tripTypes'
const state = vi.hoisted(() => ({ plan: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
vi.mock('../api/trips', () => ({ getTrip: vi.fn(), getTripLoading: vi.fn(), updateTripPlan: vi.fn(), getTripCapacityPlan: vi.fn(), confirmTripCapacityPlan: vi.fn(), confirmTripLoading: vi.fn() }))
const trip = { tripNo: 'TR-1', recordVersion: 5, plannedDepartureAt: '2099-01-01T12:00:00Z', stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey: 'FBA:ACY2' }] } as FleetTrip
const draft = { offerNo: 'FO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, tripRecordVersion: 6, actions: { canEdit: true } }
const wrappers: ReturnType<typeof shallowMount>[] = []
function mount(realPlanner = false) {
const wrapper = shallowMount(Dialog, { props: { modelValue: true, trip }, global: { plugins: [createPinia()], stubs: {
AppDialog: { name: 'AppDialog', props: ['confirmText'], emits: ['confirm'], template: '<div><slot/><button @click="$emit(\'confirm\')">{{confirmText}}</button></div>' },
TripLoadingPanel: realPlanner ? false : defineComponent({ name: 'TripLoadingPanel', setup(_, { expose }) { expose({ save: state.plan }); return {} }, template: '<div/>' }),
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="palletNo" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' },
ElInputNumber: { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input/>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElButton: { template: '<button><slot/></button>' }, ElDatePicker: true, ElInput: true, LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(trips.getTrip).mockResolvedValue({ trip } as Awaited<ReturnType<typeof trips.getTrip>>)
vi.mocked(offers.listCapacityOffers).mockResolvedValue({ records: [], total: 0, page: 1, size: 1, pages: 0 })
state.plan.mockResolvedValue({ tripNo: 'TR-1', recordVersion: 6 })
vi.mocked(offers.saveCapacityOffer).mockResolvedValue({ offer: draft, replayed: false } as Awaited<ReturnType<typeof offers.saveCapacityOffer>>)
vi.mocked(offers.commandCapacityOffer).mockResolvedValue({ offer: { ...draft, status: 'OPEN', recordVersion: 2 }, replayed: false } as Awaited<ReturnType<typeof offers.commandCapacityOffer>>)
})
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('confirms capacity, saves metadata and publishes from one click using returned versions', async () => {
const wrapper = mount(); await flushPromises()
expect(wrapper.findComponent({ name: 'ElDatePicker' }).attributes('modelvalue')).toBe(trip.plannedDepartureAt)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(state.plan).toHaveBeenCalledOnce()
expect(offers.saveCapacityOffer).toHaveBeenCalledWith(null, expect.objectContaining({ tripRecordVersion: 6, cutoffAt: trip.plannedDepartureAt }))
expect(offers.commandCapacityOffer).toHaveBeenCalledWith('FO-1', 'publish', expect.objectContaining({ tripRecordVersion: 6, offerRecordVersion: 1 }))
expect(wrapper.text()).toContain('查看需求池发布')
})
it('does not publish if capacity validation is incomplete', async () => {
state.plan.mockResolvedValue(undefined)
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(offers.saveCapacityOffer).not.toHaveBeenCalled(); expect(offers.commandCapacityOffer).not.toHaveBeenCalled()
})
it('retries only the uncertain publish command with the original key', async () => {
vi.mocked(offers.commandCapacityOffer).mockRejectedValueOnce(new Error('network'))
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(offers.commandCapacityOffer).mock.calls[0]!
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmText')).toBe('重试原请求')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(offers.commandCapacityOffer).toHaveBeenNthCalledWith(2, ...first)
expect(state.plan).toHaveBeenCalledOnce(); expect(offers.saveCapacityOffer).toHaveBeenCalledOnce()
})
it('submits measurements from the embedded panel before saving and publishing', async () => {
const basis = { tripNo: 'TR-1', recordVersion: 5, cargo: [{ dispatchNo: 'DP-1', customerId: '1', dispatchItemId: '2', warehousePalletId: '3', palletNo: 'PLT-USLAX01-261010-G0RD3LYHGD8H', packages: 1, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
vi.mocked(trips.getTripCapacityPlan).mockResolvedValue(basis)
vi.mocked(trips.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 6, planValid: true })
const wrapper = mount(true); await flushPromises()
expect(wrapper.text()).toContain('PLT-USLAX01-261010-G0RD3LYHGD8H')
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string; singleLine?: boolean; width?: number; minWidth?: number }[]
expect(columns.filter(column => ['palletNo', 'spaces', 'weight', 'volume', 'stop'].includes(column.key)).every(column => column.singleLine === false)).toBe(true)
expect(columns.reduce((sum, column) => sum + (column.width || column.minWidth || 0), 0)).toBeLessThanOrEqual(1048)
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 2); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(trips.confirmTripCapacityPlan).toHaveBeenCalledWith('TR-1', expect.objectContaining({ recordVersion: 5, pallets: [{ warehousePalletId: '3', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }] }))
expect(offers.commandCapacityOffer).toHaveBeenCalledOnce()
})
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import TripLoadingPanel from './TripLoadingPanel.vue'
import { getTrip, getTripLoading, updateTripPlan } from '../api/trips'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -12,51 +15,121 @@ import type { FleetTrip } from '../tripTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip }>()
const emit = defineEmits<{ saved: [version: number] }>()
const emit = defineEmits<{ saved: [version: number]; tripUpdated: [trip: FleetTrip] }>()
const access = useAccessPreviewStore(), offer = ref<CapacityOffer | null>(null), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const loaded = ref(false)
const loaded = ref(false), publishAfterSave = ref(false)
const router = useRouter()
const currentTrip = ref<FleetTrip>(props.trip)
const planner = ref<InstanceType<typeof TripLoadingPanel> | null>(null)
const planState = ref('')
let planInitialized = false
const canPlan = computed(() => access.can('platform:fleet:trip:edit'))
const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark: '' })
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
let generation = 0
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value))
const guard = useFleetDraftGuard('capacity-offer-settings', open, reactive({ form, planState }), busy, computed(() => !!pending.value))
async function load() {
if (!writable.value || !open.value || pending.value) return
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
try { const value = await listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
try {
const [value, detail] = await Promise.all([listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }), getTrip(props.trip.tripNo)])
if (current !== generation) return
currentTrip.value = detail.trip
offer.value = value.records[0] || null
planInitialized = false; planState.value = ''
Object.assign(form, { cutoffAt: offer.value?.cutoffAt || detail.trip.plannedDepartureAt, contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' })
loaded.value = true; await nextTick(); guard.capture()
}
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(open, value => { generation++; if (value) { if (pending.value) return; void load() } })
watch(open, value => { generation++; if (value) { if (pending.value) return; loaded.value = false; void load() } }, { immediate: true })
async function execute() {
if (!pending.value || busy.value || !writable.value) return
const current = generation; busy.value = true; error.value = null
try { const p = pending.value; const result = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body); if (current !== generation) return; offer.value = result.offer; pending.value = null; emit('saved', result.offer.tripRecordVersion); guard.capture(); ElMessage.success('发布设置已更新') }
try {
while (pending.value) {
const p: NonNullable<typeof pending.value> = pending.value
const result: Awaited<ReturnType<typeof saveCapacityOffer>> = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body)
if (current !== generation) return
offer.value = result.offer
currentTrip.value = { ...currentTrip.value, recordVersion: result.offer.tripRecordVersion }
emit('saved', result.offer.tripRecordVersion)
pending.value = null
if (p.action === 'save' && publishAfterSave.value) {
pending.value = { action: result.offer.status === 'DRAFT' ? 'publish' : 'resume', offerNo: result.offer.offerNo,
body: { tripRecordVersion: result.offer.tripRecordVersion, offerRecordVersion: result.offer.recordVersion, idempotencyKey: crypto.randomUUID() } }
}
}
guard.capture()
if (publishAfterSave.value) { publishAfterSave.value = false; ElMessage.success('拼车余位已发布,可在需求池登记意向') }
else ElMessage.success('发布设置已更新')
}
catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '发布操作未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
async function matchExistingStops() {
if (busy.value || !canPlan.value || pending.value) return
busy.value = true; error.value = null
try {
const cargo = await getTripLoading(currentTrip.value.tripNo)
const stops = currentTrip.value.stops
const next = stops.length === 2 && !stops[1]?.destinationKey && stops[1]?.name.startsWith('待确定卸货点') ? [stops[0]!] : [...stops]
for (const item of cargo.cargo) {
if (!item.destinationKey) throw new Error('排车货物目的地未确认,请先完善排车单')
if (!next.some(stop => stop.destinationKey === item.destinationKey)) next.push({ name: item.destinationLabel || '卸货站', destinationKey: item.destinationKey })
}
const result = await updateTripPlan(currentTrip.value.tripNo, { recordVersion: cargo.recordVersion, plannedDepartureAt: currentTrip.value.plannedDepartureAt, expectedDriverEndAt: currentTrip.value.expectedDriverEndAt, stops: next, remark: currentTrip.value.remark, reason: '发布前按货物匹配卸货站' })
currentTrip.value = result.trip; emit('tripUpdated', result.trip); emit('saved', result.trip.recordVersion)
if (offer.value) offer.value = { ...offer.value, tripRecordVersion: result.trip.recordVersion }
} catch (cause) { error.value = getApiErrorDetails(cause, '卸货站匹配未完成') }
finally { busy.value = false }
}
function planChanged(value: string) {
planState.value = value
if (!planInitialized) { planInitialized = true; void nextTick(() => guard.capture()) }
}
async function save() {
if (busy.value) return
if (pending.value) { await execute(); return }
if (loading.value || !loaded.value || !writable.value || offer.value && !offer.value.actions.canEdit) return
if (!form.cutoffAt) { ElMessage.warning('请填写交货截止时间'); return }
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: props.trip.tripNo, tripRecordVersion: offer.value?.tripRecordVersion ?? props.trip.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
if (new Date(form.cutoffAt).getTime() <= Date.now() || new Date(form.cutoffAt).getTime() > new Date(currentTrip.value.plannedDepartureAt).getTime()) { ElMessage.warning('交货截止须晚于现在且不晚于计划发车'); return }
busy.value = true
let plan
try { plan = await planner.value?.save() } finally { busy.value = false }
if (!plan) return
currentTrip.value = { ...currentTrip.value, recordVersion: plan.recordVersion }
emit('saved', plan.recordVersion)
publishAfterSave.value = true
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: currentTrip.value.tripNo, tripRecordVersion: currentTrip.value.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
await execute()
}
async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
if (!offer.value || loading.value || !loaded.value || busy.value || pending.value || !writable.value) return
if (guard.dirty.value) { ElMessage.warning('请先保存发布信息,再执行发布状态操作'); return }
if (guard.dirty.value) { ElMessage.warning('有未保存修改,请先确认发布或关闭后重新打开再操作'); return }
publishAfterSave.value = false
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
}
onBeforeUnmount(() => { generation++ })
</script>
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布拼车余位" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '确认发布'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason && offer.status !== 'DRAFT'">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<template v-if="loaded && open && (!offer || offer.actions.canEdit)">
<h3>本车货物与余位</h3>
<p>卸货站已按货物匹配。核对下方数据,确认发布时一并保存容量;多个目的地的先后顺序请在车次计划中确认。</p>
<el-button v-if="canPlan && planner?.destinationIssue" :disabled="busy || !!pending" @click="matchExistingStops">按货物补齐卸货站</el-button>
<TripLoadingPanel ref="planner" :trip="currentTrip" :editable="canPlan && !busy && !pending" planning embedded @changed="planChanged" />
</template>
<p v-if="!canPlan">已有有效容量可直接发布;需要修改容量时请联系有车次编辑权限的调度。</p>
<el-button v-if="offer?.status === 'OPEN'" :disabled="busy || !!pending" type="primary" link @click="open = false; router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: offer.offerNo } })">查看需求池发布</el-button>
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /></el-form-item><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item>
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /><span>默认与计划发车时间一致,可提前截止。</span></el-form-item><details class="offer-settings-wide"><summary>联系人与交货说明(选填)</summary><div class="offer-settings-form"><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item></div></details>
</el-form>
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPublish" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('publish')">发布运力</el-button><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canResume" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('resume')">重新核对并恢复</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
</AppDialog></template>
<style scoped>.offer-settings-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.offer-settings-wide{grid-column:1/-1}.offer-settings-form :deep(.el-date-editor){width:100%}</style>
@@ -0,0 +1,67 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TransportRouteDialog from './TransportRouteDialog.vue'
import { resolveRouteLocation, routeLocation, RouteAddressError, type RouteLocation } from '../routeLocations'
vi.mock('../routeLocations', async importOriginal => ({ ...await importOriginal<object>(), resolveRouteLocation: vi.fn() }))
const global = { stubs: {
AppDialog: { template: '<div><slot /></div>' }, ElButton: { template: '<button><slot /></button>' }, ElLink: { template: '<a><slot /></a>' },
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true,
ElSelect: { props: ['modelValue'], emits: ['update:modelValue'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', Number($event.target.value))"><slot /></select>' },
ElOption: { props: ['label', 'value'], template: '<option :value="value">{{ label }}</option>' },
} }
const endpoints = { origin: { kind: 'warehouse', label: '洛杉矶仓', warehouseId: '123' } as RouteLocation, destinations: [routeLocation('ABE3'), routeLocation('ONT8')] }
const addresses: Record<string, string> = { '洛杉矶仓': '100 Example Street, Los Angeles, CA, US', ABE3: '200 Example Road, Example City, PA, 18000, US', ONT8: '300 Example Avenue, Ontario, CA, US' }
beforeEach(() => { vi.mocked(resolveRouteLocation).mockReset().mockImplementation(async location => location.kind === 'address' ? location.address : addresses[location.label]!) })
describe('transport route address resolution', () => {
it('does not query until opened and sends full addresses to both map URLs', async () => {
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: false }, global })
expect(resolveRouteLocation).not.toHaveBeenCalled()
await wrapper.setProps({ modelValue: true }); await flushPromises()
const embedded = new URL(wrapper.get('iframe').attributes('src')!)
const external = new URL(wrapper.get('a').attributes('href')!)
expect(embedded.searchParams.get('saddr')).toBe(addresses['洛杉矶仓'])
expect(embedded.searchParams.get('daddr')).toBe(addresses.ABE3)
expect(external.searchParams.get('destination')).toBe(addresses.ABE3)
expect(wrapper.text()).toContain('目的地地址:' + addresses.ABE3)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
const iframe = wrapper.get('iframe').element
await wrapper.get('button').trigger('click')
expect(wrapper.get('iframe').element).not.toBe(iframe)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
await wrapper.setProps({ modelValue: false })
expect(wrapper.find('iframe').exists()).toBe(false)
wrapper.unmount()
})
it('resolves only the selected warehouse and reuses the origin for another destination', async () => {
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global }); await flushPromises()
await wrapper.get('select').setValue('1'); await flushPromises()
expect(new URL(wrapper.get('iframe').attributes('src')!).searchParams.get('daddr')).toBe(addresses.ONT8)
expect(resolveRouteLocation).toHaveBeenCalledTimes(3)
wrapper.unmount()
})
it('never leaves the previous map visible while resolving a new destination, and ignores late results', async () => {
let finish!: (value: string) => void
vi.mocked(resolveRouteLocation).mockImplementation(location => location.label === 'ABE3' ? new Promise(resolve => { finish = resolve }) : Promise.resolve(addresses[location.label]!))
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global })
expect(wrapper.find('iframe').exists()).toBe(false)
await wrapper.get('select').setValue('1'); await flushPromises()
finish('stale address'); await flushPromises()
expect(new URL(wrapper.get('iframe').attributes('src')!).searchParams.get('daddr')).toBe(addresses.ONT8)
await wrapper.setProps({ modelValue: false }); await flushPromises()
expect(wrapper.find('iframe').exists()).toBe(false)
wrapper.unmount()
})
it('shows missing address errors instead of falling back to a warehouse code', async () => {
vi.mocked(resolveRouteLocation).mockRejectedValue(new RouteAddressError('ABE3缺少完整仓库地址'))
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global }); await flushPromises()
expect(wrapper.text()).toContain('ABE3缺少完整仓库地址')
expect(wrapper.find('iframe').exists()).toBe(false); expect(wrapper.find('a').exists()).toBe(false)
wrapper.unmount()
})
it('does not request missing endpoints', async () => {
const wrapper = mount(TransportRouteDialog, { props: { origin: routeLocation(''), destinations: [], modelValue: true }, global }); await flushPromises()
expect(resolveRouteLocation).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少起点或目的地')
wrapper.unmount()
})
})
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { resolveRouteLocation, RouteAddressError, type RouteLocation } from '../routeLocations'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ origin: RouteLocation; destinations: RouteLocation[] }>()
const mapRevision = ref(0)
const destinationIndex = ref(0)
const selected = computed(() => props.destinations[destinationIndex.value])
const origin = ref(''), destination = ref(''), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let generation = 0
let cache = new Map<string, Promise<string>>()
function resolve(location: RouteLocation) {
const key = JSON.stringify(location)
if (!cache.has(key)) cache.set(key, resolveRouteLocation(location))
return cache.get(key)!
}
async function load() {
const current = ++generation
origin.value = ''; destination.value = ''; error.value = null; loading.value = false
if (!open.value) return
const valid = (location?: RouteLocation) => location && !['', '—', '-', '仓库名称待补充'].includes(location.label.trim())
if (!valid(props.origin) || !valid(selected.value)) {
error.value = { message: '缺少起点或目的地,请先补充地点或详细地址' }; return
}
loading.value = true
try {
const [start, end] = await Promise.all([resolve(props.origin), resolve(selected.value!)])
if (current === generation && open.value) { origin.value = start; destination.value = end; mapRevision.value++ }
} catch (cause) {
if (current === generation) error.value = cause instanceof RouteAddressError ? { message: cause.message } : getApiErrorDetails(cause, '仓库地址查询失败,请确认具有仓库查看权限后重试')
} finally { if (current === generation) loading.value = false }
}
function retry() { cache = new Map(); void load() }
watch([open, () => props.origin, () => props.destinations], () => {
destinationIndex.value = 0; cache = new Map(); void load()
}, { immediate: true })
watch(destinationIndex, load)
onBeforeUnmount(() => { generation++ })
const ready = computed(() => !loading.value && !error.value && Boolean(origin.value && destination.value))
const mapUrl = computed(() => `https://maps.google.com/maps?${new URLSearchParams({ saddr: origin.value, daddr: destination.value, dirflg: 'd', output: 'embed' })}`)
const externalUrl = computed(() => `https://www.google.com/maps/dir/?${new URLSearchParams({ api: '1', origin: origin.value, destination: destination.value, travelmode: 'driving' })}`)
const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.length > 2048)
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" title="查看路线"
width="min(1100px, 96vw)" align-center :show-confirm="false" cancel-text="关闭">
<section v-if="open" class="route-map" aria-label="起点至目的地路线">
<strong>{{ props.origin.label || '起点未填写' }} → {{ selected?.label || '目的地未填写' }}</strong>
<el-select v-if="props.destinations.length > 1" v-model="destinationIndex" aria-label="选择目的仓">
<el-option v-for="(item, index) in props.destinations" :key="index" :label="item.label" :value="index" />
</el-select>
<p class="route-note">根据起点与目的地地址规划驾车路线,尚未接入 GPS,不显示车辆实时位置或实际行驶轨迹。</p>
<p v-if="loading" role="status">正在查询仓库详细地址…</p>
<LoadErrorState v-else-if="error" title="无法加载路线" :message="error.message" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else-if="tooLong" role="status">地点信息过长,请核对起点和目的地地址后重试。</p>
<template v-else-if="ready"><div class="route-addresses"><div>起点地址:{{ origin }}</div><div>目的地地址:{{ destination }}</div></div>
<iframe :key="mapRevision" :src="mapUrl" :title="`${props.origin.label}至${selected?.label}路线地图`"
referrerpolicy="no-referrer" />
</template>
<div v-if="ready && !tooLong" class="route-help">
<span>地图需联网加载。如未显示,可重新加载或在新窗口打开。</span>
<el-button @click="mapRevision++">重新加载地图</el-button>
<el-link :href="externalUrl" target="_blank" rel="noopener noreferrer" type="primary">在新窗口打开地图</el-link>
</div>
</section>
</AppDialog>
</template>
<style scoped>
.route-map { display: grid; gap: var(--admin-space-3); }
.route-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.route-map iframe { width: 100%; height: 58vh; min-height: 320px; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.route-help { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); color: var(--cc-muted); }
</style>
@@ -1,5 +1,10 @@
import { ElMessage } from 'element-plus'
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { mount as mountFull, shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
@@ -14,11 +19,11 @@ const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, i
const wrappers: VueWrapper[] = []
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
LoadErrorState: true, ApiErrorMeta: true,
ElButton: { template: '<button><slot/></button>' }, LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
@@ -26,10 +31,12 @@ afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises()
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')).toContainEqual(expect.objectContaining({ key: 'spaces', singleLine: false }))
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[0]!.vm.$emit('update:modelValue', 2); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 1 }] }))
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }] }))
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
expect(ElMessage.success).toHaveBeenCalledWith('实收货量及约定运费已核实')
})
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
@@ -43,3 +50,68 @@ it('retries the same incoming registration after an uncertain result without cre
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
})
it('keeps an uncertain receipt command retryable after closing and reopening without reloading or changing its hash', async () => {
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
const wrapper=mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const original=vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(false)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
expect(api.getTripIncoming).toHaveBeenCalledOnce()
})
it.each([
{ bookingStatus: 'BOUND' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,已排入本车' },
{ bookingStatus: 'RECEIVED' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,可安排排车' },
{ bookingStatus: 'BOUND' as const, incomingStatus: 'OUTBOUNDED', expected: '已出库' },
])('shows truthful receipt guidance for $bookingStatus / $incomingStatus', async ({ bookingStatus, incomingStatus, expected }) => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: incomingStatus } })
const wrapper = mount('view')
await wrapper.setProps({ booking: { ...booking, status: bookingStatus, dispatchNo: bookingStatus === 'BOUND' ? 'DS-1' : null } })
await flushPromises()
expect(wrapper.text()).toContain(expected)
if (bookingStatus === 'BOUND') expect(wrapper.text()).not.toContain('可安排排车')
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(preview.incoming!.pallets)
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
})
it('anchors receipt control sizing inside the real DataTable slot and disables cell ellipsis', async () => {
const wrapper = mountFull(TripBookingFulfillmentDialog, {
props: { modelValue: true, trip, booking, mode: 'receipt' },
global: { plugins: [createPinia()], components: { ElTable, ElTableColumn, ElInputNumber, ElInput }, directives: { loading: () => undefined }, stubs: {
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElButton: { template: '<button><slot/></button>' },
AppDialog: { template: '<div><slot/></div>' }, LoadErrorState: true, ApiErrorMeta: true,
} },
})
wrappers.push(wrapper); await flushPromises()
const control = wrapper.get('.el-table__body .receipt-spaces-control')
const scope = control.element.getAttributeNames().find(name => name.startsWith('data-v-'))
expect(scope).toBeDefined()
expect(control.find('.el-input-number').exists()).toBe(true)
expect(control.element.closest('td')?.classList.contains('data-table__single-line-cell')).toBe(false)
expect(control.element.closest('.cell')?.classList.contains('el-tooltip')).toBe(false)
// DataTable forwards classes to its inner table, which does not carry this SFC's scope.
expect(wrapper.find(`.receipt-pallet-table[${scope}]`).exists()).toBe(false)
const input = control.get('input')
await input.setValue('2'); await input.trigger('change'); await flushPromises()
expect(wrapper.text()).toContain('2 托位')
})
it('compares real receipt with the forecast, preserves unknown values, and does not invent historical forecasts', async () => {
const wrapper = mount('receipt'); await flushPromises()
const rows = () => wrapper.get('[aria-label="预报与实收对照"]').findAll('tbody tr').map(row => row.findAll('td').map(cell => cell.text()))
expect(rows()).toEqual([
['箱数', '20', '22', '+2'], ['实托数', '—', '1', '—'], ['标准托位(待核对)', '2', '1', '-1'],
['重量(kg)', '100', '150', '+50'], ['体积(m³)', '1', '1.5', '+0.5'],
])
await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } })
expect(rows().every(row => row[1] === '—' && row[3] === '—')).toBe(true)
expect(wrapper.text()).toContain('无法还原原始预报')
await wrapper.setProps({ mode: 'view' }); await flushPromises()
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -7,52 +7,84 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
import { getInquiryFulfillment, commandInquiryFulfillment } from '../api/capacityOffers'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
defineExpose({ hasPending: computed(() => !!pending.value) })
let generation = 0
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '关联外接排车单', view: '外接来货详情' }
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '确认排入本车', view: '外接来货详情' }
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const receiptComparison = computed(() => {
const incoming = preview.value?.incoming
const pallets = incoming?.pallets || []
const forecast = props.booking?.status === 'CONFIRMED' ? props.booking.cargo : null
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => row[key] != null && Number.isFinite(row[key]))
? pallets.reduce((total, row) => total + row[key]!, 0) : null
const values = [
{ label: '箱数', expected: forecast?.packageCount, actual: incoming?.receivedPackages },
{ label: '实托数', expected: forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
{ label: '标准托位(待核对)', expected: forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
]
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
})
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
const incomingStatusLabel = computed(() => {
const status = preview.value?.incoming?.status
if (status === 'READY_FOR_DISPATCH' && props.booking?.status === 'BOUND') return '已确认实收,已排入本车'
return statuses[status || ''] || '等待读取'
})
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
async function load() {
if (!props.booking) return
const current = generation; loading.value = true; error.value = null
try {
const value = await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
const value = props.pool ? (await getInquiryFulfillment(props.booking.bookingNo)).preview : await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
if (current !== generation) return
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
await nextTick(); guard.capture()
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(() => [open.value, target.value], () => {
generation++; busy.value = false; error.value = null; preview.value = null
if (!open.value || !props.booking) return
if (pending.value) return
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
Object.assign(form, { estimatedArrivalAt: new Date(new Date(props.trip.plannedDepartureAt).getTime() - 7200000).toISOString(), deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (pending.value?.target !== target.value) pending.value = null
if (props.mode === 'receipt' || props.mode === 'view') void load()
Object.assign(form, { estimatedArrivalAt: props.arrivalAt || '', deliveryInstructions: props.instructions || props.booking.cargo.remark || '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (props.initialPreview && (props.mode === 'receipt' || props.mode === 'view')) {
preview.value = props.initialPreview; Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of props.initialPreview.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
void nextTick(() => guard.capture())
} else if (props.mode === 'receipt' || props.mode === 'view') void load()
else void nextTick(() => guard.capture())
}, { immediate: true })
async function save() {
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
const current = generation, booking = props.booking, mode = props.mode
if (!pending.value) {
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
if (mode === 'incoming' && !form.estimatedArrivalAt) { ElMessage.warning('请确认预计到仓时间'); return }
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
else if (mode === 'bind') {
if (!dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
if (!props.pool && !dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
} else {
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
@@ -63,33 +95,40 @@ async function save() {
busy.value = true; error.value = null
try {
const body = pending.value.body
const value = mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
const value = props.pool ? await commandInquiryFulfillment(booking.bookingNo, mode === 'incoming' ? 'incoming' : mode === 'receipt' ? 'receipt-confirmation' : 'arrange-dispatch', mode === 'bind' ? { tripRecordVersion: (body as TripBookingBind).tripRecordVersion, recordVersion: (body as TripBookingBind).recordVersion, idempotencyKey: (body as TripBookingBind).idempotencyKey } : body) : mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
if (current === generation) { emit('saved', value); pending.value = null; open.value = false; ElMessage.success('外接履约记录已更新') }
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success(value.booking.status === 'BOUND' ? '已排入本车,尚未装车或发车' : value.booking.status === 'RECEIVED' ? '实收货量及约定运费已核实' : value.booking.incomingNo ? '已安排来货,等待仓库实际收货' : '履约命令已完成,请查看最新进度') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { busy.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '关联排车单' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (!pending && mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="booking" v-loading="loading">
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
</el-form>
<template v-if="mode === 'receipt' || mode === 'view'">
<p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template></DataTable>
<section v-if="mode === 'receipt'" aria-label="预报与实收对照">
<p>预报与实收对照;差异仅供核对,容量与费用仍需确认。</p>
<p v-if="booking.status !== 'CONFIRMED'">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
<table class="density-table"><thead><tr><th>项目</th><th>承运预报</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
</table>
</section>
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
</template>
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p v-if="pool">将本票全部实收货物排入原车次。系统复用合格排车单,没有时生成并关联;来源冲突或货量不完整时停止。成功后仍需核对容量并完成审核,不代表已装车或发车。</p><p v-else>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item v-if="!pool" label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}</style>
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}.receipt-spaces-control{display:flex;width:100%;min-width:0}.receipt-spaces-control :deep(.el-input-number){width:100%;min-width:0;max-width:100%}</style>
@@ -1,3 +1,5 @@
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingPanel from './TripBookingPanel.vue'
@@ -45,7 +47,6 @@ it('confirms using both current versions and updates the parent version without
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
})
it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => {
const { ElInputNumber, ElInput } = await vi.importActual<typeof import('element-plus')>('element-plus')
const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } }
const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 }
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] })
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import { listTripDispatchCandidates, type DispatchCandidate } from '../api/trips'
import { getApiErrorDetails } from '@/shared/api/http'
const value = defineModel<string>({ default: '' })
const props = defineProps<{ tripNo: string; disabled?: boolean }>()
const rows = ref<DispatchCandidate[]>([]), loading = ref(false), error = ref(''), total = ref(0)
let sequence = 0, timer: ReturnType<typeof setTimeout> | undefined
function search(keyword = '') {
clearTimeout(timer); const current = ++sequence
timer = setTimeout(async () => {
loading.value = true; error.value = ''
try { const result = await listTripDispatchCandidates(props.tripNo, keyword.trim() || undefined); if (current === sequence) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (current === sequence) { rows.value = []; error.value = getApiErrorDetails(cause, '待运货物加载失败').message } }
finally { if (current === sequence) loading.value = false }
}, 250)
}
watch(() => props.tripNo, () => { sequence++; clearTimeout(timer); rows.value = []; value.value = ''; loading.value = false })
onBeforeUnmount(() => { sequence++; clearTimeout(timer) })
</script>
<template><div><el-select v-model="value" aria-label="选择本仓待运排车单" filterable remote :remote-method="search" :loading="loading" :disabled="disabled" placeholder="按排车单号或目的地搜索" @focus="search()"><el-option v-for="row in rows" :key="row.dispatchNo" :label="`${row.dispatchNo} · ${row.route || '路线待核对'} · ${row.packageCount}箱`" :value="row.dispatchNo" /></el-select><p v-if="error" role="alert">{{ error }} <el-button link @click="search()">重试</el-button></p><small v-if="total > rows.length">候选较多,请输入排车单号缩小范围。</small></div></template>
@@ -8,15 +8,21 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripLoading, confirmTripLoading, getTripCapacityPlan, confirmTripCapacityPlan } from '../api/trips'
import type { FleetTrip, TripLoading, TripCapacityPlanning, TripMeasurement } from '../tripTypes'
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean }>()
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning] }>()
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean; embedded?: boolean }>()
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning]; changed: [value: string] }>()
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; destinationKeys: string[]; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<PalletRow[]>([]), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let sequence = 0
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 },
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 160 }, { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 175 },
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 190 }, { key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 190 },
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 200 }])
const baseline = ref('')
watch(rows, value => emit('changed', JSON.stringify(value)), { deep: true })
const columns = computed<DataTableColumn[]>(() => [
{ key: 'palletNo', label: '实物托盘', slotName: 'palletNo', minWidth: 210, singleLine: false },
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 140, singleLine: false },
{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 160, singleLine: false },
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 180, singleLine: false },
{ key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 180, singleLine: false },
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 160, singleLine: false },
])
const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad))
const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || [])
const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid)
@@ -45,6 +51,7 @@ function apply(value: TripLoading | TripCapacityPlanning) {
palletSpaces: saved?.palletSpaces ?? 1, weightKg: saved?.weightKg ?? null, volumeCbm: saved?.volumeCbm ?? null, deliveryIndex: saved && saved.deliveryIndex > 0 && props.trip.stops[saved.deliveryIndex]?.destinationKey === cargo.destinationKey ? saved.deliveryIndex : (index > 0 ? index : null) })
}
rows.value = [...pallets.values()]
baseline.value = JSON.stringify(rows.value)
}
async function load() {
const current = ++sequence; loading.value = true; error.value = null
@@ -53,7 +60,9 @@ async function load() {
finally { if (current === sequence) loading.value = false }
}
async function save() {
if (busy.value || loading.value || !props.editable || !info.value) return
if (busy.value || loading.value || !info.value) return
if (props.planning && 'planValid' in info.value && info.value.planValid && baseline.value === JSON.stringify(rows.value)) return info.value
if (!props.editable) { ElMessage.warning('计划容量需重新核对,请由有车次编辑权限的调度操作'); return }
if (destinationIssue.value) { ElMessage.warning(destinationIssue.value); return }
if (rows.value.some(row => !matchingStops(row).some(stop => stop.index === row.deliveryIndex))) { ElMessage.warning('请选择与货物目的地匹配的卸货站'); return }
if (!rows.value.length || rows.value.some(row => row.weightKg == null || row.volumeCbm == null || row.deliveryIndex == null)) { ElMessage.warning(props.planning ? '请填写每托预计重量、体积和卸货站' : '请填写每托实测重量、体积和卸货站'); return }
@@ -65,7 +74,7 @@ async function save() {
const body = { recordVersion: snapshot.recordVersion, manifestHash: snapshot.manifestHash, pallets, reason: props.planning ? '核对本车自有货计划载荷' : '核对实物托盘及实际装载' }
if (props.planning) {
const result = await confirmTripCapacityPlan(props.trip.tripNo, body)
if (current === sequence) { apply(result); emit('planned', result); ElMessage.success('计划容量已核对') }
if (current === sequence) { apply(result); emit('planned', result); if (!props.embedded) ElMessage.success('计划容量已核对'); return result }
} else {
const result = await confirmTripLoading(props.trip.tripNo, body)
if (current === sequence) { apply(result); emit('saved', result); ElMessage.success('实际装载已确认') }
@@ -74,6 +83,7 @@ async function save() {
finally { busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion, props.planning], () => { if (info.value?.tripNo !== props.trip.tripNo || info.value.recordVersion !== props.trip.recordVersion || !!props.planning !== ('capacityPlan' in info.value)) void load() }, { immediate: true })
defineExpose({ save, destinationIssue })
onBeforeUnmount(() => { sequence++ })
</script>
<template>
@@ -85,13 +95,19 @@ onBeforeUnmount(() => { sequence++ })
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
<DataTable :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
<DataTable class="trip-loading-table" :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
<template #palletNo="{ row }"><span class="trip-pallet-code">{{ row.palletNo }}</span></template>
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
<template #stop="{ row }"><el-select v-model="row.deliveryIndex" :disabled="!editable || busy || loading || !matchingStops(row as PalletRow).length" placeholder="请先匹配货物目的地"><el-option v-for="stop in matchingStops(row as PalletRow)" :key="stop.index" :value="stop.index" :label="stop.name" /></el-select></template>
<template #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="loading || !!info?.blockReason || !!destinationIssue || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
<template #stop="{ row }"><span v-if="matchingStops(row as PalletRow).length === 1">{{ matchingStops(row as PalletRow)[0]?.name }}</span><el-select v-else v-model="row.deliveryIndex" :disabled="!editable || busy || loading || !matchingStops(row as PalletRow).length" placeholder="请先匹配货物目的地"><el-option v-for="stop in matchingStops(row as PalletRow)" :key="stop.index" :value="stop.index" :label="stop.name" /></el-select></template>
<template v-if="!embedded" #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="loading || !!info?.blockReason || !!destinationIssue || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
</DataTable>
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
</div>
</template>
<style scoped>
.trip-pallet-code{white-space:normal;overflow-wrap:anywhere}
.trip-loading-table :deep(.el-input-number),.trip-loading-table :deep(.el-select){width:100%;min-width:0;max-width:100%}
</style>
@@ -0,0 +1,41 @@
import { createPinia } from 'pinia'
import { shallowMount, flushPromises } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import TripPreparationDialog from './TripPreparationDialog.vue'
import { listTrips, prepareTrip } from '../api/trips'
import type { TripDetail, FleetTrip } from '../tripTypes'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/trips', () => ({ listTrips: vi.fn(), prepareTrip: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
const trip = { tripNo: 'TR-READY', recordVersion: 4, warehouseId: '9007199254740993', stops: [{ name: '起运仓' }, { name: 'ACY2' }] } as FleetTrip
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listTrips).mockResolvedValue({ records: [trip], page: 1, size: 100, total: 1, pages: 1 }) })
function render() {
const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '<div><slot/></div>' }
return shallowMount(TripPreparationDialog, { props: { modelValue: true, warehouseId: trip.warehouseId, warehouseName: '本仓', dispatchNos: ['DS-ONE', 'DS-TWO'] }, global: { plugins: [createPinia()], stubs: {
AppDialog: { name: 'AppDialog', props: ['showConfirm'], emits: ['confirm'], template: '<div><slot/></div>' },
ElForm: field, ElFormItem: field, ElSelect: { ...field, name: 'ElSelect' }, ElOption: true,
ElRadioGroup: { ...field, name: 'ElRadioGroup' }, ElRadio: true, ElDatePicker: field,
ElButton: { template: '<button><slot/></button>' }, CapacityOfferSettingsDialog: true,
} } })
}
it('loads same-warehouse trips on demand and retries one immutable atomic preparation', async () => {
const wrapper=render(); await flushPromises()
expect(listTrips).not.toHaveBeenCalled()
const select=wrapper.findComponent({ name: 'ElSelect' })
select.vm.$emit('focus'); await flushPromises()
expect(listTrips).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '9007199254740993', status: 'PLANNED' }))
select.vm.$emit('update:modelValue', trip.tripNo); await flushPromises()
vi.mocked(prepareTrip).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ trip, dispatches: [], driverQualification: null, loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } } as TripDetail)
const dialog=wrapper.findComponent({ name: 'AppDialog' })
dialog.vm.$emit('confirm'); await flushPromises()
const original=vi.mocked(prepareTrip).mock.calls[0]![0]
expect(original).toMatchObject({ tripNo: 'TR-READY', recordVersion: 4, dispatchNos: ['DS-ONE','DS-TWO'], plan: null, vehicleId: null, driverId: null })
expect(wrapper.emitted('saved')).toBeUndefined()
dialog.vm.$emit('confirm'); await flushPromises()
expect(prepareTrip).toHaveBeenNthCalledWith(2, original)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('核对容量并发布余位')
expect(dialog.props('showConfirm')).toBe(false)
wrapper.unmount()
})

Some files were not shown because too many files have changed in this diff Show More