269 lines
13 KiB
TypeScript
269 lines
13 KiB
TypeScript
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
|
|
|
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
|
|
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
|
|
|
|
export interface ContainerCustomsFile {
|
|
fileId: string
|
|
fileName: string
|
|
fileHash: string
|
|
sizeBytes: number
|
|
version: number
|
|
uploadedAt: string
|
|
}
|
|
|
|
export interface ContainerCustomsReceiptVersion {
|
|
version: number
|
|
file: ContainerCustomsFile
|
|
operatorName?: string | null
|
|
createdAt: string
|
|
}
|
|
|
|
export type ContainerCustomsConfirmationAction = 'CONFIRM' | 'REJECT'
|
|
|
|
export interface ContainerCustomsConfirmationHistory {
|
|
id: string
|
|
receiptVersion: number
|
|
action: ContainerCustomsConfirmationAction
|
|
reason: string | null
|
|
operatorName: string
|
|
createdAt: string
|
|
}
|
|
|
|
export interface ContainerCustomsBrokerSubmission {
|
|
id: string
|
|
customsContainerId: string
|
|
loadingPlanId: string
|
|
batchNo: string
|
|
status: 'SENT'
|
|
sourceVersion: number
|
|
sentByName: string
|
|
sentAt: string
|
|
version: number
|
|
}
|
|
|
|
export interface ContainerCustomsInbound {
|
|
inboundId: string
|
|
orderId: string
|
|
orderNo: string
|
|
inboundCode: string
|
|
tenantId: string
|
|
customerName: string
|
|
taxMode: ContainerCustomsTaxMode
|
|
covered: boolean
|
|
}
|
|
|
|
export interface ContainerCustomsDocument {
|
|
documentId: string
|
|
tenantId: string
|
|
source: ContainerCustomsFile
|
|
inboundIds: string[]
|
|
inboundCodes: string[]
|
|
frozen: boolean
|
|
receipt: ContainerCustomsFile | null
|
|
receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
|
|
receiptRejectReason?: string | null
|
|
finalDocument: ContainerCustomsFile | null
|
|
receiptHistory?: ContainerCustomsReceiptVersion[]
|
|
confirmationHistory: ContainerCustomsConfirmationHistory[]
|
|
}
|
|
|
|
export interface ContainerCustomsView {
|
|
aggregateId: string
|
|
loadingPlanId: string
|
|
containerBookingId: string
|
|
planNo: string
|
|
cabinetNo: string
|
|
containerNo: string | null
|
|
status: ContainerCustomsStatus
|
|
hasActualCargo: boolean
|
|
version: number
|
|
taxRefundCount: number
|
|
coveredTaxRefundCount: number
|
|
actualBoxCount: number
|
|
inbounds: ContainerCustomsInbound[]
|
|
documents: ContainerCustomsDocument[]
|
|
brokerSubmission: ContainerCustomsBrokerSubmission | null
|
|
startedAt: string | null
|
|
completedAt: string | null
|
|
brokerSubmittedAt?: string | null
|
|
}
|
|
|
|
export type DeclarationBatchStatus = 'READY' | 'SUBMITTED' | 'ACKNOWLEDGED' | 'RESULT_PENDING' | 'CLOSED' | 'CANCELLED'
|
|
export type ClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER'
|
|
export type ClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED'
|
|
|
|
export interface ClearanceResult {
|
|
id: string
|
|
declarationBatchId: string
|
|
customsContainerId: string
|
|
resultType: ClearanceResultType
|
|
resultStatus: ClearanceResultStatus
|
|
customsEntryNo: string | null
|
|
occurredAt: string
|
|
resolution: string | null
|
|
evidenceFileId: string | null
|
|
recordedByName: string
|
|
recordedAt: string
|
|
previousResultId: string | null
|
|
}
|
|
|
|
export interface DeclarationBatch {
|
|
id: string
|
|
customsContainerId: string
|
|
loadingPlanId: string
|
|
batchNo: string
|
|
status: DeclarationBatchStatus
|
|
sourceVersion: number
|
|
customsEntryNo: string | null
|
|
version: number
|
|
submittedAt: string | null
|
|
submittedByName: string | null
|
|
acknowledgedAt: string | null
|
|
acknowledgedByName: string | null
|
|
remark: string | null
|
|
results: ClearanceResult[]
|
|
}
|
|
|
|
interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateId' | 'loadingPlanId' | 'containerBookingId' | 'version' | 'inbounds' | 'documents'> {
|
|
aggregateId: string | number
|
|
loadingPlanId: string | number
|
|
containerBookingId: string | number
|
|
version: string | number
|
|
inbounds: Array<Omit<ContainerCustomsInbound, 'inboundId' | 'orderId' | 'tenantId'> & { inboundId: string | number; orderId: string | number; tenantId: string | number }>
|
|
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'receipt' | 'finalDocument'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; receipt: ContainerCustomsFile | null; finalDocument: ContainerCustomsFile | null }>
|
|
}
|
|
|
|
const basePath = '/platform/v1/customs/loading-plans'
|
|
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
|
function file(value: ContainerCustomsFile | null): ContainerCustomsFile | null {
|
|
return value ? { ...value, fileId: String(value.fileId), sizeBytes: Number(value.sizeBytes), version: Number(value.version) } : null
|
|
}
|
|
function normalize(value: RawContainerCustomsView): ContainerCustomsView {
|
|
return {
|
|
...value,
|
|
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
|
|
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
|
|
brokerSubmission: value.brokerSubmission ? {
|
|
...value.brokerSubmission,
|
|
id: String(value.brokerSubmission.id),
|
|
customsContainerId: String(value.brokerSubmission.customsContainerId),
|
|
loadingPlanId: String(value.brokerSubmission.loadingPlanId),
|
|
sourceVersion: Number(value.brokerSubmission.sourceVersion),
|
|
version: Number(value.brokerSubmission.version),
|
|
} : null,
|
|
documents: value.documents.map((item) => ({
|
|
...item,
|
|
documentId: String(item.documentId),
|
|
tenantId: String(item.tenantId),
|
|
source: file(item.source)!,
|
|
receipt: file(item.receipt),
|
|
finalDocument: file(item.finalDocument),
|
|
receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })),
|
|
confirmationHistory: (item.confirmationHistory || []).map((history) => ({ ...history, id: String(history.id), receiptVersion: Number(history.receiptVersion), reason: history.reason ?? null })),
|
|
})),
|
|
}
|
|
}
|
|
|
|
function normalizeClearanceResult(value: ClearanceResult): ClearanceResult {
|
|
return { ...value, id: String(value.id), declarationBatchId: String(value.declarationBatchId), customsContainerId: String(value.customsContainerId), evidenceFileId: value.evidenceFileId == null ? null : String(value.evidenceFileId), previousResultId: value.previousResultId == null ? null : String(value.previousResultId) }
|
|
}
|
|
|
|
function normalizeDeclarationBatch(value: DeclarationBatch): DeclarationBatch {
|
|
return { ...value, id: String(value.id), customsContainerId: String(value.customsContainerId), loadingPlanId: String(value.loadingPlanId), sourceVersion: Number(value.sourceVersion), version: Number(value.version), results: (value.results || []).map(normalizeClearanceResult) }
|
|
}
|
|
|
|
export async function getDeclarationBatches(loadingPlanId: string) {
|
|
const response = await http.get<ApiEnvelope<DeclarationBatch[]>, ApiEnvelope<DeclarationBatch[]>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`)
|
|
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
|
|
}
|
|
|
|
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number) {
|
|
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
|
|
return normalizeDeclarationBatch(unwrapApiResponse(response))
|
|
}
|
|
|
|
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
|
|
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
|
|
return normalizeDeclarationBatch(unwrapApiResponse(response))
|
|
}
|
|
|
|
export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) {
|
|
// The domestic API stores LocalDateTime; send Shanghai wall time, not UTC with a discarded offset.
|
|
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
}).formatToParts(new Date()).map(({ type, value }) => [type, value]))
|
|
const occurredAt = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`
|
|
const response = await http.post<ApiEnvelope<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt, resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } })
|
|
return normalizeClearanceResult(unwrapApiResponse(response))
|
|
}
|
|
|
|
export async function resolveClearanceResult(result: ClearanceResult, batchVersion: number, resolution: string) {
|
|
const response = await http.post<ApiEnvelope<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${basePath}/clearance-results/${encodeURIComponent(result.id)}/resolve`, { expectedVersion: batchVersion, resolution: resolution.trim() }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-resolve') } })
|
|
return normalizeClearanceResult(unwrapApiResponse(response))
|
|
}
|
|
|
|
export async function getContainerCustoms(loadingPlanId: string) {
|
|
const response = await http.get<ApiEnvelope<RawContainerCustomsView | null>, ApiEnvelope<RawContainerCustomsView | null>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
|
|
const value = unwrapApiResponse(response)
|
|
return value ? normalize(value) : null
|
|
}
|
|
|
|
export function downloadContainerCustomsDocument(documentId: string) {
|
|
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
|
|
}
|
|
|
|
export function downloadContainerCustomsReceipt(documentId: string) {
|
|
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
|
|
}
|
|
|
|
export function downloadContainerCustomsFinal(documentId: string) {
|
|
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
|
|
}
|
|
|
|
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
|
|
const response = await http.post<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
|
|
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
|
|
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
|
|
{ headers: { 'Idempotency-Key': operationId(`container-customs-${name}`) } },
|
|
)
|
|
return normalize(unwrapApiResponse(response))
|
|
}
|
|
|
|
export function startContainerCustoms(loadingPlanId: string, expectedVersion: number) {
|
|
return action(loadingPlanId, 'start', expectedVersion)
|
|
}
|
|
|
|
export function sendContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
|
|
return action(loadingPlanId, 'send-to-broker', expectedVersion, '已将冻结退税报关资料首次发送给报关行')
|
|
}
|
|
|
|
export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) {
|
|
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
|
|
}
|
|
|
|
export function submitContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
|
|
return action(loadingPlanId, 'submit-to-broker', expectedVersion, '已将客户确认回执发送给报关行')
|
|
}
|
|
|
|
async function uploadFile(documentId: string, fileValue: File, expectedVersion: number, kind: 'receipt' | 'final') {
|
|
const body = new FormData()
|
|
body.append('file', fileValue)
|
|
const response = await http.post<ApiEnvelope<ContainerCustomsDocument>, ApiEnvelope<ContainerCustomsDocument>>(
|
|
`${basePath}/documents/${encodeURIComponent(documentId)}/${kind}/upload`,
|
|
body,
|
|
{ params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-customs-${kind}`) } },
|
|
)
|
|
const result = unwrapApiResponse(response)
|
|
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, receipt: file(result.receipt), finalDocument: file(result.finalDocument) }
|
|
}
|
|
|
|
export function uploadContainerCustomsReceipt(documentId: string, fileValue: File, expectedVersion: number) {
|
|
return uploadFile(documentId, fileValue, expectedVersion, 'receipt')
|
|
}
|
|
|
|
export function uploadContainerCustomsFinal(documentId: string, fileValue: File, expectedVersion: number) {
|
|
return uploadFile(documentId, fileValue, expectedVersion, 'final')
|
|
}
|