This commit is contained in:
2026-08-31 10:58:12 +08:00
parent 462d02efd5
commit f7328e7c60
10 changed files with 140 additions and 20 deletions
@@ -170,6 +170,39 @@ export async function releaseDispatchDraft(draftToken: string) {
return unwrapApiResponse(response)
}
export interface DispatchPdaPositionDto { id: number; balanceId?: number | null; availablePackages: number; allocatedPackages: number; pickedPackages: number }
export interface DispatchPdaItemDto {
dispatchItemId: number
palletNo?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
scheduledPackages: number
pickedPackages: number
positions: DispatchPdaPositionDto[]
}
export interface DispatchPdaTaskDto { id: number; taskNo: string; dispatchNo: string; vehiclePlate?: string | null; warehouseId?: string | null; status: string; items: DispatchPdaItemDto[] }
export interface DispatchPdaPushPayload { idempotencyKey: string; operatorId?: string; operatorName?: string; warehouseId?: string }
export interface DispatchPdaPickPayload { positionId: number; packages: number; productQuantity: number; idempotencyKey: string; remark?: string; warehouseId?: string }
export interface DispatchPdaCompletePayload { idempotencyKey: string; reason: string }
export async function pushDispatchPda(dispatchNo: string, payload: DispatchPdaPushPayload) {
const response = await overseasHttp.post<ApiEnvelope<DispatchPdaTaskDto>, ApiEnvelope<DispatchPdaTaskDto>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/push`, payload)
return unwrapApiResponse(response)
}
export async function getDispatchPdaTask(dispatchNo: string) {
const response = await overseasHttp.get<ApiEnvelope<DispatchPdaTaskDto>, ApiEnvelope<DispatchPdaTaskDto>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`)
return unwrapApiResponse(response)
}
export async function pickDispatchPda(dispatchNo: string, itemId: number, payload: DispatchPdaPickPayload) {
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${itemId}/pick`, payload)
return unwrapApiResponse(response)
}
export async function completeDispatchPda(dispatchNo: string, payload: DispatchPdaCompletePayload) {
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`/pda/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/complete`, payload)
return unwrapApiResponse(response)
}
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; deliveryNo: string; deliveryStatus: string; palletCount: number; packageCount: number; replayed: boolean }
export interface TransferDeliveryPayload {
idempotencyKey: string
@@ -40,6 +40,7 @@ export interface InboundOrderSummaryDto {
expectedLoadingAt?: string | null
dispatchStatus?: string | null
createdAt?: string | null
version?: number | null
}
export interface BatchFbaAppointmentPayload {
@@ -117,7 +117,8 @@ export async function listInventoryBalances(query: InventoryBalanceQuery) {
}
export async function downloadInventoryBalances(query: Partial<InventoryBalanceQuery>) {
return overseasHttp.get('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
return response.data
}
export async function getInventorySummary(query: InventoryBalanceQuery) {
@@ -165,5 +166,6 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) {
}
export async function downloadInventoryLedger(query: Partial<InventoryLedgerQuery>) {
return overseasHttp.get('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
return response.data
}
@@ -188,7 +188,8 @@ export async function getLastMileOrder(orderId: string) {
}
export async function downloadLastMileOrders(query: Partial<LastMileOrderQuery>) {
return overseasHttp.get('/platform/v1/last-leg/orders/export', { params: query, responseType: 'blob' })
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/orders/export', { params: query, responseType: 'blob' })
return response.data
}
export async function createLastMileOrderFee(orderId: string, payload: CreateLastMileFeePayload) {
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import { completeDispatchPda, getDispatch, getDispatchPdaTask, pickDispatchPda, pushDispatchPda, type DispatchItemDto, type DispatchPdaItemDto, type DispatchPdaTaskDto, type DispatchSummaryDto } from '../api/dispatches'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -25,6 +26,13 @@ const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchSummaryDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const pdaTask = ref<DispatchPdaTaskDto | null>(null)
const pdaLoading = ref(false)
const pdaMutating = ref(false)
const pdaError = ref<ApiErrorDetails | null>(null)
const pdaPushKey = ref('')
const pdaReason = ref('')
const pickForm = ref({ itemId: 0, positionId: 0, packages: 1, productQuantity: 1, idempotencyKey: '' })
const dispatchStatusLabels: Record<string, string> = {
PENDING_DEPARTURE: '待发车',
@@ -126,6 +134,40 @@ async function load() {
}
function back() { void router.push({ name: 'overseas-dispatches' }) }
function makeKey(prefix: string) { return `${prefix}-${dispatchNo.value}-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function selectPdaItem(item: DispatchPdaItemDto) {
const position = item.positions?.find((value) => value.availablePackages > 0) || item.positions?.[0]
pickForm.value = { itemId: item.dispatchItemId, positionId: Number(position?.id || 0), packages: 1, productQuantity: 1, idempotencyKey: makeKey('web-pick') }
}
function selectPdaRow(item: unknown) { selectPdaItem(item as DispatchPdaItemDto) }
async function loadPdaTask() {
if (!dispatchNo.value) return
pdaLoading.value = true; pdaError.value = null
try { pdaTask.value = await getDispatchPdaTask(dispatchNo.value); if (!pickForm.value.itemId && pdaTask.value.items?.[0]) selectPdaItem(pdaTask.value.items[0]) }
catch (error) { pdaError.value = getApiErrorDetails(error, 'PDA任务加载失败,请先启动拣货任务') }
finally { pdaLoading.value = false }
}
async function startPda() {
pdaMutating.value = true; pdaError.value = null
try { pdaTask.value = await pushDispatchPda(dispatchNo.value, { idempotencyKey: pdaPushKey.value.trim() || makeKey('web-push') }); pdaPushKey.value = ''; if (pdaTask.value.items?.[0]) selectPdaItem(pdaTask.value.items[0]); ElMessage.success('PDA拣货任务已启动') }
catch (error) { pdaError.value = getApiErrorDetails(error, '启动PDA任务失败') }
finally { pdaMutating.value = false }
}
async function submitPick() {
const form = pickForm.value
if (!form.itemId || !form.positionId || form.packages < 1) { ElMessage.warning('请选择拣货明细和有效库位、数量'); return }
pdaMutating.value = true; pdaError.value = null
try { await pickDispatchPda(dispatchNo.value, form.itemId, { positionId: form.positionId, packages: form.packages, productQuantity: form.productQuantity || form.packages, idempotencyKey: form.idempotencyKey.trim() || makeKey('web-pick') }); ElMessage.success('拣货成功,库存已扣减'); await loadPdaTask() }
catch (error) { pdaError.value = getApiErrorDetails(error, '拣货失败,请检查库位库存') }
finally { pdaMutating.value = false }
}
async function completePda() {
pdaMutating.value = true; pdaError.value = null
try { await completeDispatchPda(dispatchNo.value, { idempotencyKey: makeKey('web-complete'), reason: pdaReason.value.trim() || '正常完成拣货' }); ElMessage.success('拣货任务已完成'); await loadPdaTask(); await load() }
catch (error) { pdaError.value = getApiErrorDetails(error, '完成拣货失败') }
finally { pdaMutating.value = false }
}
onMounted(load)
</script>
@@ -139,6 +181,20 @@ onMounted(load)
<section class="detail-section document-section" aria-labelledby="dispatch-document-heading"><h2 id="dispatch-document-heading">单据信息</h2><dl class="detail-fields"><div v-for="field in documentFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
</div>
<section class="detail-section order-section" aria-labelledby="dispatch-orders-heading"><h2 id="dispatch-orders-heading">关联订单明细</h2><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="orderNo" empty-text="暂无关联订单明细" /></section>
<section class="detail-section pda-section" aria-labelledby="dispatch-pda-heading">
<div class="pda-header"><h2 id="dispatch-pda-heading">PDA拣货与库存扣减</h2><div class="pda-actions"><el-input v-model="pdaPushKey" placeholder="启动幂等键(可选)" clearable /><el-button type="primary" :loading="pdaMutating" @click="startPda">启动拣货</el-button><el-button :loading="pdaLoading" @click="loadPdaTask">刷新任务</el-button></div></div>
<el-alert v-if="pdaError" :title="pdaError.message" type="error" :closable="false" class="pda-alert" />
<div v-if="pdaTask" v-loading="pdaLoading" class="pda-body">
<div class="pda-meta">任务号:{{ pdaTask.taskNo }} 状态:<strong>{{ dispatchStatus(pdaTask.status) }}</strong> 仓库:{{ text(pdaTask.warehouseId) }}</div>
<el-table :data="pdaTask.items || []" size="small" border @row-click="selectPdaItem">
<el-table-column label="托盘" prop="palletNo" min-width="140" /><el-table-column label="目的仓" prop="fbaWarehouseCode" min-width="110" /><el-table-column label="计划箱数" prop="scheduledPackages" width="100" /><el-table-column label="已拣箱数" prop="pickedPackages" width="100" />
<el-table-column label="库位(可用)" min-width="220"><template #default="{ row }"><el-button v-for="position in row.positions || []" :key="position.id" link type="primary" @click.stop="selectPdaRow(row); pickForm.positionId = Number(position.id)">{{ position.id }}({{ position.availablePackages }})</el-button></template></el-table-column>
</el-table>
<div class="pick-form"><span class="pick-label">拣货明细:{{ pickForm.itemId || '未选择' }}</span><el-input-number v-model="pickForm.positionId" :min="1" controls-position="right" placeholder="库位ID" /><el-input-number v-model="pickForm.packages" :min="1" controls-position="right" placeholder="箱数" @change="(value) => { pickForm.productQuantity = Number(value || 1) }" /><el-input v-model="pickForm.idempotencyKey" placeholder="拣货幂等键" /><el-button type="success" :loading="pdaMutating" @click="submitPick">拣货并扣减库存</el-button></div>
<div class="pda-complete"><el-input v-model="pdaReason" placeholder="完成原因(可选)" /><el-button type="warning" :loading="pdaMutating" @click="completePda">完成拣货</el-button></div>
</div>
<el-empty v-else description="尚未加载PDA任务,请点击启动拣货" />
</section>
</div>
</OverseasDetailShell>
</template>
@@ -158,10 +214,22 @@ onMounted(load)
.detail-fields dd { margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.order-section { overflow: hidden; border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.order-section h2 { margin-right: 4px; margin-left: 4px; }
.pda-section { border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.pda-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.pda-header h2 { margin-bottom: 0; }
.pda-actions, .pick-form, .pda-complete { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pda-actions :deep(.el-input) { width: 230px; }
.pda-alert { margin: 12px 0; }
.pda-meta { margin: 12px 0; color: var(--cc-muted); }
.pick-form { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.pick-form :deep(.el-input), .pda-complete :deep(.el-input) { width: 220px; }
.pick-label { color: var(--cc-muted); }
.pda-complete { justify-content: flex-end; margin-top: 12px; }
@media (max-width: 960px) {
.summary-grid { grid-template-columns: 1fr; }
.summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: auto; }
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
.pda-actions { width: 100%; }
}
</style>
@@ -14,7 +14,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
@@ -52,6 +53,7 @@ const appointmentSelectedCount = ref(0)
const appointmentFbaWarehouse = ref('—')
const appointmentForm = reactive({ appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'SUCCESS' as 'PENDING' | 'SUCCESS' | 'FAILED' | 'CANCELLED', appointmentRemark: '' })
const appointmentTitle = computed(() => appointmentMode.value === 'edit' ? '编辑预约' : '预约')
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
function selectableRow(source: object) {
const row = source as InboundOrderListItem
@@ -93,7 +95,7 @@ function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
createdAt: formatOverseasDateTime(dto.createdAt),
createdAt: formatOverseasDateTime(dto.createdAt), version: number(dto.version),
}
}
@@ -168,6 +170,8 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.warehouse, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
async function submitPlanEdit() { const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
onMounted(() => { void load() })
</script>
@@ -177,7 +181,7 @@ onMounted(() => { void load() })
<template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />编辑</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
@@ -194,6 +198,7 @@ onMounted(() => { void load() })
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog v-model="planEditOpen" title="编辑入库单" confirm-text="保存" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div v-loading="planEditLoading"><el-form label-position="top"><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item><el-form-item label="版本"><el-input-number v-model="planEditForm.version" :min="0" disabled /></el-form-item></el-form></div></AppDialog>
</AdminPage>
</template>
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { ArrowLeft, Download } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'
import { downloadInventoryLedger, listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'; import { overseasWarehousePermissions as permission } from '../permissions'; import { useAccessPreviewStore } from '@/core/access/accessPreview'; import { saveCsv } from '@/modules/overseas-master-data/downloadCsv'
const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore(); const exportLoading = ref(false)
const filters = ref<QueryModel>({ occurredDate: [], warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
@@ -24,11 +26,12 @@ const columns: DataTableColumn[] = [
function text(value: unknown) { return String(value ?? '').trim() || '—' }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerId), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script>
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供产品名称与海外仓名称,对应列显示空值。</p></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { BookOpen, Eye } from '@lucide/vue'
import { BookOpen, Download, Eye } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -10,7 +10,9 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { downloadInventoryBalances, getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { saveCsv } from '@/modules/overseas-master-data/downloadCsv'
import { ElMessage } from 'element-plus'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
import { overseasWarehousePermissions } from '../permissions'
@@ -27,6 +29,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const exportLoading = ref(false)
const filters = ref<QueryModel>({ warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const fields = computed<QueryField[]>(() => [
...(access.can(overseasWarehousePermissions.warehouseList) ? [{ key: 'warehouseId', type: 'select' as const, label: '海外仓', width: 230, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: contextLoading.value } }] : []),
@@ -78,6 +81,7 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { overseasWarehouseId: row.warehouseId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku === '—' ? undefined : row.sku.split('、')[0] } }) }
async function exportBalances() { exportLoading.value = true; try { const q = query(); const { page: _page, size: _size, ...params } = q; saveCsv(await downloadInventoryBalances(params) as Blob, '库存余额.csv') } catch { ElMessage.error('库存导出失败,请重试') } finally { exportLoading.value = false } }
onMounted(() => { void loadWarehouses(); void load() })
</script>
@@ -87,7 +91,7 @@ onMounted(() => { void loadWarehouses(); void load() })
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template v-if="loadError" #state><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport)" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { Download, Eye } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -17,6 +17,8 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { saveCsv } from '@/modules/overseas-master-data/downloadCsv'
const rows = ref<LastMileOrderListItem[]>([])
const access = useAccessPreviewStore()
@@ -29,13 +31,14 @@ const total = ref(0)
const selectedRows = ref<LastMileOrderListItem[]>([])
const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { const blob = await downloadLastMileOrders(filters.value); const url = URL.createObjectURL(blob as Blob); const a = document.createElement('a'); a.href = url; a.download = '尾程订单.csv'; a.click(); URL.revokeObjectURL(url) } finally { exportLoading.value = false } }
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, page: undefined, size: undefined, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch { ElMessage.error('尾程订单导出失败,请重试') } finally { exportLoading.value = false } }
const actionError = ref<ApiErrorDetails | null>(null)
const transferOpen = ref(false)
const detainedOpen = ref(false)
const detainedTarget = ref<LastMileOrderListItem | null>(null)
const detainedRemark = ref('')
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const fields = computed<QueryField[]>(() => [
@@ -190,7 +193,8 @@ function openDetail(source: object) {
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
}
onMounted(() => { void load() })
onMounted(() => { void load(); void loadWarehouses() })
async function loadWarehouses() { try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } }
</script>
<template>
@@ -210,16 +214,14 @@ onMounted(() => { void load() })
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" :row-class-name="({ row }: { row: LastMileOrderListItem }) => row.detained ? 'is-detained-row' : ''" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" size="small" plain :type="(row as LastMileOrderListItem).detained ? 'success' : 'danger'" :disabled="actionLoading" @click="openDetained(row as LastMileOrderListItem)">{{ (row as LastMileOrderListItem).detained ? '解除暂扣' : '暂扣' }}</el-button></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :confirm-loading="actionLoading" @confirm="submitTransfer">
<el-form label-position="top">
<el-form-item label="目的仓 ID" required><el-input v-model="transferForm.warehouseId" placeholder="请输入目的仓 ID" /></el-form-item>
<el-form-item label="目的仓代码" required><el-input v-model="transferForm.warehouseCode" placeholder="请输入目的仓代码" /></el-form-item>
<el-form-item label="目的仓名称" required><el-input v-model="transferForm.warehouseName" placeholder="请输入目的仓名称" /></el-form-item>
<el-form-item label="目的仓" required><el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的仓" style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); transferForm.warehouseCode = w?.warehouseCode || ''; transferForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form>
</AppDialog>
+1
View File
@@ -69,6 +69,7 @@ export interface InboundOrderListItem {
expectedLoadingAt: string
dispatchStatus: string
createdAt: string
version: number
}
export interface DispatchListItem {