diff --git a/src/app/router/index.ts b/src/app/router/index.ts index cc6a8481..6fbb20f9 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -19,6 +19,8 @@ import { customerRoutes } from '@/modules/customers/routes' import { dashboardRoutes } from '@/modules/dashboard/routes' import { warehouseRoutes } from '@/modules/warehouses/routes' import { prototypeModuleRoutes } from '@/prototype/routes' +import { oceanBookingRoutes } from '@/modules/ocean-bookings/routes' +import { truckingRoutes } from '@/modules/trucking/routes' const router = createRouter({ history: createWebHistory(), @@ -42,6 +44,8 @@ const router = createRouter({ }, ...dashboardRoutes, ...prototypeModuleRoutes, + ...oceanBookingRoutes, + ...truckingRoutes, ...customerRoutes, ...warehouseRoutes, ...unifiedOrderRoutes, diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index bd78b3d6..6fc2f348 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -44,7 +44,7 @@ export const adminNavigation: AdminNavigationNode[] = [ iconColor: '#52c41a', children: [ { key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] }, - { key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/prototype/customer-management/warehouse-tier-fee', hidden: true }, + { key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/customers/overseas-storage-fees', permission: 'platform:overseas-storage-fee:list' }, ], }, { @@ -76,10 +76,9 @@ export const adminNavigation: AdminNavigationNode[] = [ key: 'service-center', title: '服务中心', icon: ClipboardList, - hidden: true, children: [ - { key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/prototype/service-center/booking-list' }, - { key: 'truck-list', title: '拖车列表', icon: Truck, path: '/prototype/service-center/truck-list' }, + { key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' }, + { key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' }, ], }, { diff --git a/src/modules/customers/CustomerOverseasStorageFeePanel.vue b/src/modules/customers/CustomerOverseasStorageFeePanel.vue new file mode 100644 index 00000000..9a45c755 --- /dev/null +++ b/src/modules/customers/CustomerOverseasStorageFeePanel.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/modules/customers/overseasStorageFee.test.ts b/src/modules/customers/overseasStorageFee.test.ts new file mode 100644 index 00000000..75428a1c --- /dev/null +++ b/src/modules/customers/overseasStorageFee.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees } from './overseasStorageFee' +import { http } from '@/shared/api/http' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), put: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null } + +describe('customer overseas storage fee api', () => { + beforeEach(() => vi.clearAllMocks()) + + it('keeps customer ids as strings and uses the customer detail path', async () => { + vi.mocked(http.get).mockResolvedValue({ data: config }) + const result = await getCustomerOverseasStorageFees(config.customerId) + expect(http.get).toHaveBeenCalledWith(`/platform/v1/iam/tenants/${config.customerId}/overseas-storage-fees`) + expect(result.id).toBe('12') + expect(result.customerId).toBe(config.customerId) + }) + + it('submits the complete group and reloads the new version', async () => { + vi.mocked(http.put).mockResolvedValue({ data: null }) + vi.mocked(http.get).mockResolvedValue({ data: { ...config, version: 1 } }) + const request = { customerId: config.customerId, customerName: '客户', currency: 'CNY', version: 0, tiers: [{ feeType: 'STORAGE' as const, billingUnit: 'CBM' as const, startDay: 1, endDay: null, unitPrice: 0 }] } + const result = await updateCustomerOverseasStorageFees(config.customerId, request) + expect(http.put).toHaveBeenCalledWith(`/platform/v1/iam/tenants/${config.customerId}/overseas-storage-fees`, request) + expect(result.version).toBe(1) + }) +}) diff --git a/src/modules/customers/overseasStorageFee.ts b/src/modules/customers/overseasStorageFee.ts new file mode 100644 index 00000000..833711d1 --- /dev/null +++ b/src/modules/customers/overseasStorageFee.ts @@ -0,0 +1,66 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type OverseasFeeType = 'STORAGE' | 'RETURN_STORAGE' +export type OverseasBillingUnit = 'CBM' | 'PALLET' + +export interface OverseasStorageFeeTier { + id: string + startDay: number + endDay: number | null + unitPrice: number + sortNo: number + feeType: OverseasFeeType + billingUnit: OverseasBillingUnit +} + +export interface OverseasStorageFeeConfig { + id: string + customerId: string + customerName: string + currency: string + tiers: OverseasStorageFeeTier[] + version: number + createdAt: string | null + updatedAt: string | null +} + +export interface OverseasStorageFeeUpdateRequest { + customerId: string + customerName: string + currency: string + tiers: Array> + version: number +} + +const path = (tenantId: string) => `/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees` + +type RawConfig = Omit & { + id: string | number + customerId: string | number + version: string | number + tiers: Array & { id: string | number; unitPrice: number | string }> +} + +function normalize(data: RawConfig): OverseasStorageFeeConfig { + return { + ...data, + id: String(data.id), customerId: String(data.customerId), version: Number(data.version), + tiers: data.tiers.map((tier) => ({ ...tier, id: String(tier.id), unitPrice: Number(tier.unitPrice), feeType: tier.feeType as OverseasFeeType, billingUnit: tier.billingUnit as OverseasBillingUnit })), + } +} + +export async function getCustomerOverseasStorageFees(tenantId: string) { + const response = await http.get>(path(tenantId)) + return normalize(unwrapApiResponse(response)) +} + +export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) { + await http.put>(path(tenantId), request) + return getCustomerOverseasStorageFees(tenantId) +} + +export async function listOverseasStorageFees(page = 1, size = 20, customerName?: string) { + const response = await http.get>('/platform/v1/overseas-storage-fees', { params: { page, size, customerName } }) + const result = unwrapApiResponse(response) + return { ...result, records: result.records.map(normalize) } +} diff --git a/src/modules/customers/pages/CustomerDetailView.vue b/src/modules/customers/pages/CustomerDetailView.vue index 8dd29170..ce85993c 100644 --- a/src/modules/customers/pages/CustomerDetailView.vue +++ b/src/modules/customers/pages/CustomerDetailView.vue @@ -31,6 +31,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import StatusBadge from '@/shared/components/StatusBadge.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue' +import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue' type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing' type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean } @@ -423,7 +424,7 @@ watch(orderPage, (page, previousPage) => {

财务账单

客户应收、回款与对账记录

财务账单尚未接入

当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。

操作日志

客户档案与状态变更记录

操作日志尚未接入

等待审计服务提供按租户查询的只读接口后展示真实操作记录。

-
+
diff --git a/src/modules/customers/pages/OverseasStorageFeesView.vue b/src/modules/customers/pages/OverseasStorageFeesView.vue new file mode 100644 index 00000000..84065606 --- /dev/null +++ b/src/modules/customers/pages/OverseasStorageFeesView.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/modules/customers/routes.ts b/src/modules/customers/routes.ts index a9d18164..e66bb8e6 100644 --- a/src/modules/customers/routes.ts +++ b/src/modules/customers/routes.ts @@ -7,6 +7,12 @@ export const customerRoutes = [ component: () => import('@/modules/customers/pages/CustomersView.vue'), meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] }, }, + { + path: 'customers/overseas-storage-fees', + name: 'overseas-storage-fees', + component: () => import('@/modules/customers/pages/OverseasStorageFeesView.vue'), + meta: { title: '海外仓阶梯费用', description: '维护客户仓租和退货仓储费阶梯', permission: 'platform:overseas-storage-fee:list' }, + }, { path: 'customers/:tenantId', name: 'customer-detail', diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts new file mode 100644 index 00000000..c4c619cb --- /dev/null +++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts @@ -0,0 +1,4 @@ +import { describe, expect, it, vi } from 'vitest' +import { listOceanBookings } from './platformOceanBookings' +import { http } from '@/shared/api/http' +describe('ocean bookings api', () => { it('normalizes pagination and long ids', async () => { vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, containers: [] }], page: '2', size: '20', total: '21', pages: '2' } }); const result = await listOceanBookings({ page: 1, size: 10 }); expect(result.page).toBe(2); expect(result.total).toBe(21); expect(result.records[0]?.id).toBe('123'); expect(result.records[0]?.customerId).toBe('456') }) }) diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.ts b/src/modules/ocean-bookings/api/platformOceanBookings.ts new file mode 100644 index 00000000..33ba5a53 --- /dev/null +++ b/src/modules/ocean-bookings/api/platformOceanBookings.ts @@ -0,0 +1,18 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } from '../types' + +const basePath = '/platform/v1/ocean-bookings' +function pageNumber(value: number | string, fallback: number) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : fallback } +function booking(raw: OceanBookingResource): OceanBookingResource { return { ...raw, id: String(raw.id), customerId: raw.customerId == null ? null : String(raw.customerId), containers: (raw.containers || []).map((item) => ({ ...item, id: String(item.id) })) } } +export async function listOceanBookings(query: OceanBookingQuery) { + const response = await http.get, ApiEnvelope>(basePath, { params: query }) + const result = unwrapApiResponse(response) + return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage +} +export async function getOceanBooking(id: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`); const raw = unwrapApiResponse(response); return { ...raw, ...booking(raw), statusHistory: (raw.statusHistory || []).map((event) => ({ ...event, id: String(event.id) })) } } +export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post, ApiEnvelope>(basePath, payload); return unwrapApiResponse(response) } +export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`, payload); return unwrapApiResponse(response) } +export type OceanBookingAction = 'submit' | 'confirm' | 'release' | 'fail' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail' +export async function performOceanBookingAction(id: string, action: OceanBookingAction, reason?: string) { const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/${action}`, reason ? { reason } : undefined); return unwrapApiResponse(response) } +export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) } +export type { OceanBookingResource, OceanBookingStatus } diff --git a/src/modules/ocean-bookings/index.ts b/src/modules/ocean-bookings/index.ts new file mode 100644 index 00000000..3277f49c --- /dev/null +++ b/src/modules/ocean-bookings/index.ts @@ -0,0 +1,2 @@ +export { oceanBookingRoutes } from './routes' +export * from './types' diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue new file mode 100644 index 00000000..e5b754e1 --- /dev/null +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts new file mode 100644 index 00000000..81f062bb --- /dev/null +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts @@ -0,0 +1,4 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }) }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.vue b/src/modules/ocean-bookings/pages/OceanBookingsView.vue new file mode 100644 index 00000000..b65868ba --- /dev/null +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/modules/ocean-bookings/routes.ts b/src/modules/ocean-bookings/routes.ts new file mode 100644 index 00000000..9bf0ed6a --- /dev/null +++ b/src/modules/ocean-bookings/routes.ts @@ -0,0 +1,2 @@ +import type { RouteRecordRaw } from 'vue-router' +export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:view' } }] satisfies RouteRecordRaw[] diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts new file mode 100644 index 00000000..8f0bf9e1 --- /dev/null +++ b/src/modules/ocean-bookings/types.ts @@ -0,0 +1,16 @@ +export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED' + +export interface OceanBookingContainer { id: string; containerType: string; quantity: number; containerKind?: string | null } +export interface OceanBookingResource { + id: string; bookingNo: string | null; orderNo: string | null; customerId: string | null; customerName: string | null + status: OceanBookingStatus; carrierName: string | null; bookingAgent: string | null; vesselName: string | null; voyageNo: string | null + pol: string | null; pod: string | null; mblShipper: string | null; mblConsignee: string | null; soNo: string | null + cutoffAt: string | null; etd: string | null; atd: string | null; eta: string | null; ata: string | null + containerSummary: string | null; containers: OceanBookingContainer[]; amount: string | null; currency: string | null + remark: string | null; createdAt: string | null; updatedAt: string | null +} +export interface OceanBookingDetail extends OceanBookingResource { goodsDescription: string | null; packageCount: number | null; grossWeight: string | null; volume: string | null; statusHistory: OceanBookingStatusEvent[] } +export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; operatorName: string | null; occurredAt: string } +export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number } +export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; pol?: string; pod?: string; soNo?: string; customerId?: string } +export interface OceanBookingSaveRequest { customerId?: string | null; orderNo?: string | null; carrierName?: string | null; bookingAgent?: string | null; vesselName?: string | null; voyageNo?: string | null; pol?: string | null; pod?: string | null; mblShipper?: string | null; mblConsignee?: string | null; soNo?: string | null; cutoffAt?: string | null; etd?: string | null; eta?: string | null; goodsDescription?: string | null; packageCount?: number | null; grossWeight?: string | null; volume?: string | null; amount?: string | null; currency?: string | null; remark?: string | null; containers: Array<{ containerType: string; quantity: number; containerKind?: string | null }> } diff --git a/src/modules/trucking/api/platformTrucking.test.ts b/src/modules/trucking/api/platformTrucking.test.ts new file mode 100644 index 00000000..5e9b99cd --- /dev/null +++ b/src/modules/trucking/api/platformTrucking.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { changeTruckAppointmentStatus, createTruckAppointment, getTruckAppointment, listTruckAppointments } from './platformTrucking' + +vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data })) + +describe('truck appointment API', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('normalizes page values and maps filters', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '21', pages: '2' } }) + await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerType: '40HC' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2 }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerType: '40HC' } }) + }) + + it('uses detail, create, and status contracts', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { id: '9001', status: 'DRAFT' } }) + vi.mocked(http.post).mockResolvedValue({ data: '9001' }) + vi.mocked(http.patch).mockResolvedValue({ data: null }) + await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' }) + await expect(createTruckAppointment({ expectedLoadingDate: '2026-08-10' })).resolves.toBe('9001') + await expect(changeTruckAppointmentStatus('9001', 'DISPATCHED')).resolves.toBeNull() + expect(http.patch).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/status', { status: 'DISPATCHED' }) + }) +}) diff --git a/src/modules/trucking/api/platformTrucking.ts b/src/modules/trucking/api/platformTrucking.ts new file mode 100644 index 00000000..6120d9c7 --- /dev/null +++ b/src/modules/trucking/api/platformTrucking.ts @@ -0,0 +1,84 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { + SaveTruckAppointmentRequest, + TruckAppointmentPage, + TruckAppointmentQuery, + TruckAppointmentResource, +} from '../types' + +const basePath = '/platform/v1/truck-appointments' +function dtoPayload(payload: SaveTruckAppointmentRequest) { + const { attachmentRefs: _attachmentRefs, ...dto } = payload + return dto +} + +type PagePayload = Omit & { + page: number | string + size: number | string + total: number | string + pages?: number | string +} + +function normalizePage(payload: PagePayload): TruckAppointmentPage { + const page = Number(payload.page || 1) + const size = Number(payload.size || 20) + const total = Number(payload.total || 0) + return { records: (payload.records || []).map(normalizeResource), page, size, total, pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)) } +} + +function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource { + const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'carrierId'] + const normalized = { ...resource } as Record + for (const key of idKeys) { + if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key]) + } + return normalized as unknown as TruckAppointmentResource +} + +export async function listTruckAppointments(query: TruckAppointmentQuery = {}) { + const response = await http.get, ApiEnvelope>(basePath, { + params: { + ...query, + keyword: query.keyword?.trim() || undefined, + status: query.status || undefined, + containerBookingId: query.containerBookingId || undefined, + }, + }) + return normalizePage(unwrapApiResponse(response)) +} + +export async function getTruckAppointment(id: string | number) { + const response = await http.get, ApiEnvelope>(`${basePath}/${id}`) + return normalizeResource(unwrapApiResponse(response)) +} + +export async function createTruckAppointment(payload: SaveTruckAppointmentRequest) { + const response = await http.post, ApiEnvelope>(basePath, dtoPayload(payload)) + return normalizeResource(unwrapApiResponse(response)) +} + +export async function updateTruckAppointment(id: string | number, payload: SaveTruckAppointmentRequest) { + const response = await http.put, ApiEnvelope>(`${basePath}/${id}`, dtoPayload(payload)) + return unwrapApiResponse(response) +} + +export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE' | 'LOAD' | 'DEPART' | 'COMPLETE' | 'EXCEPTION' | 'CANCEL' + +export async function performTruckAppointmentAction( + id: string | number, + action: TruckAppointmentAction, + expectedVersion: number, + reason?: string, + idempotencyKey?: string, + requestHash?: string, +) { + const response = await http.post, ApiEnvelope>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, { + headers: { ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) }, + }) + return unwrapApiResponse(response) +} + +export async function rescheduleTruckAppointment(id: string | number, plannedLoadingAt: string, expectedVersion: number, reason?: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/${id}/reschedule`, { expectedVersion, reason: reason || null }, { params: { plannedLoadingAt } }) + return unwrapApiResponse(response) +} diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue new file mode 100644 index 00000000..57ed8f17 --- /dev/null +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/modules/trucking/components/TruckAttachmentDialog.vue b/src/modules/trucking/components/TruckAttachmentDialog.vue new file mode 100644 index 00000000..2070c4c7 --- /dev/null +++ b/src/modules/trucking/components/TruckAttachmentDialog.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/modules/trucking/index.ts b/src/modules/trucking/index.ts new file mode 100644 index 00000000..da61e1dd --- /dev/null +++ b/src/modules/trucking/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './api/platformTrucking' diff --git a/src/modules/trucking/pages/TruckAppointmentCreateView.vue b/src/modules/trucking/pages/TruckAppointmentCreateView.vue new file mode 100644 index 00000000..86331c86 --- /dev/null +++ b/src/modules/trucking/pages/TruckAppointmentCreateView.vue @@ -0,0 +1,17 @@ + + diff --git a/src/modules/trucking/pages/TruckAppointmentDetailView.vue b/src/modules/trucking/pages/TruckAppointmentDetailView.vue new file mode 100644 index 00000000..714f611c --- /dev/null +++ b/src/modules/trucking/pages/TruckAppointmentDetailView.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/modules/trucking/pages/TruckAppointmentsView.test.ts b/src/modules/trucking/pages/TruckAppointmentsView.test.ts new file mode 100644 index 00000000..83c954c7 --- /dev/null +++ b/src/modules/trucking/pages/TruckAppointmentsView.test.ts @@ -0,0 +1,35 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TruckAppointmentsView from './TruckAppointmentsView.vue' +import { listTruckAppointments } from '../api/platformTrucking' +import type { TruckAppointmentResource } from '../types' + +vi.mock('../api/platformTrucking', () => ({ + listTruckAppointments: vi.fn(), + exportTruckAppointments: vi.fn(), +})) + +const row: TruckAppointmentResource = { + id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', customerName: '示例企业', orderNo: 'ORD-1', + containerNo: 'CONT-1', containerType: '40HC', transportCompanyName: '宁波运输', driverName: '王强', tractorNo: '浙B12345', + expectedLoadingDate: '2026-08-10', payableAmount: '3200.00', receivableAmount: '3800.00', attachments: [], +} + +describe('TruckAppointmentsView', () => { + beforeEach(() => { + vi.mocked(listTruckAppointments).mockReset().mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) + setActivePinia(createPinia()) + }) + + it('loads the real list and renders relationship snapshots', async () => { + const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerType: '' }) + expect(wrapper.text()).toContain('TA-9001') + expect(wrapper.text()).toContain('40HC') + expect(wrapper.text()).toContain('宁波运输') + expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true) + }) +}) diff --git a/src/modules/trucking/pages/TruckAppointmentsView.vue b/src/modules/trucking/pages/TruckAppointmentsView.vue new file mode 100644 index 00000000..7bdd8739 --- /dev/null +++ b/src/modules/trucking/pages/TruckAppointmentsView.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/modules/trucking/routes.ts b/src/modules/trucking/routes.ts new file mode 100644 index 00000000..6cac3b8d --- /dev/null +++ b/src/modules/trucking/routes.ts @@ -0,0 +1,23 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const truckingRoutes = [ + { + path: 'trucking', + name: 'trucking', + component: () => import('./pages/TruckAppointmentsView.vue'), + meta: { title: '拖车预约', description: '维护拖车预约及派车状态', permission: 'platform:firstleg:truck-appointment:list' }, + }, + { + path: 'trucking/new', + name: 'trucking-new', + component: () => import('./pages/TruckAppointmentCreateView.vue'), + meta: { title: '新增拖车预约', description: '创建拖车预约', permission: 'platform:firstleg:truck-appointment:create' }, + }, + { + path: 'trucking/:id', + name: 'trucking-detail', + component: () => import('./pages/TruckAppointmentDetailView.vue'), + props: true, + meta: { title: '拖车预约详情', description: '查看和编辑拖车预约', permission: 'platform:firstleg:truck-appointment:view' }, + }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/trucking/types.ts b/src/modules/trucking/types.ts new file mode 100644 index 00000000..768f02eb --- /dev/null +++ b/src/modules/trucking/types.ts @@ -0,0 +1,134 @@ +export const TRUCK_APPOINTMENT_STATUSES = [ + 'DRAFT', + 'PENDING_DISPATCH', + 'DISPATCHED', + 'PICKUP_IN_PROGRESS', + 'ARRIVED_WAREHOUSE', + 'LOADING', + 'DEPARTED', + 'COMPLETED', + 'RESCHEDULED', + 'EXCEPTION', + 'CANCELLED', +] as const + +export type TruckAppointmentStatus = typeof TRUCK_APPOINTMENT_STATUSES[number] + +export const truckAppointmentStatusLabels: Record = { + DRAFT: '草稿', + PENDING_DISPATCH: '待派车', + DISPATCHED: '已派车', + PICKUP_IN_PROGRESS: '提柜中', + ARRIVED_WAREHOUSE: '已到仓', + LOADING: '装柜中', + DEPARTED: '已出发', + COMPLETED: '已完成', + RESCHEDULED: '已改期', + EXCEPTION: '异常', + CANCELLED: '已取消', +} + +export interface TruckAttachmentReference { + fileId: string + fileName: string + objectKey?: string | null +} + +export interface TruckAppointmentResource { + id: string + appointmentNo?: string | null + status: TruckAppointmentStatus + version?: number | null + revision?: number | null + outboundOrderId?: string | null + oceanBookingId?: string | null + containerBookingId?: string | null + loadingPlanId?: string | null + warehouseId?: string | null + customerId?: string | null + customerName?: string | null + carrierId?: string | null + carrierName?: string | null + fleetContact?: string | null + fleetPhone?: string | null + driverName?: string | null + driverPhone?: string | null + driverIdCard?: string | null + vehicleNo?: string | null + storeName?: string | null + storeContact?: string | null + storePhone?: string | null + storeAddress?: string | null + storeCity?: string | null + storeZipcode?: string | null + plannedLoadingAt?: string | null + transitMinutes?: number | string | null + packageCount?: number | string | null + grossWeightKg?: string | null + volumeCbm?: string | null + palletCount?: number | string | null + payableAmount?: string | null + receivableAmount?: string | null + currency?: string | null + poNo?: string | null + remark?: string | null + orderId?: string | null + orderNo?: string | null + containerNo?: string | null + attachments?: TruckAttachmentReference[] | null + createdAt?: string | null + updatedAt?: string | null +} + +export interface SaveTruckAppointmentRequest { + outboundOrderId?: string | null + oceanBookingId?: string | null + containerBookingId?: string | null + loadingPlanId?: string | null + warehouseId?: string | null + customerId?: string | null + customerName?: string | null + carrierId?: string | null + carrierName?: string | null + fleetContact?: string | null + fleetPhone?: string | null + driverName?: string | null + driverPhone?: string | null + driverIdCard?: string | null + vehicleNo?: string | null + storeName?: string | null + storeContact?: string | null + storePhone?: string | null + storeAddress?: string | null + storeCity?: string | null + storeZipcode?: string | null + plannedLoadingAt?: string | null + transitMinutes?: number | string | null + packageCount?: number | string | null + grossWeightKg?: string | null + volumeCbm?: string | null + palletCount?: number | string | null + payableAmount?: string | null + receivableAmount?: string | null + currency?: string | null + poNo?: string | null + remark?: string | null + attachmentRefs?: TruckAttachmentReference[] + expectedVersion: number +} + +export interface TruckAppointmentQuery { + page?: number + size?: number + keyword?: string + status?: TruckAppointmentStatus | '' + containerBookingId?: string | '' +} + +export interface TruckAppointmentPage { + records: TruckAppointmentResource[] + page: number + size: number + total: number + pages: number +}