front/src/views/warehouse/inboundNotice/index.vue
zzh 6ec41b2dbe
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m25s
feat: 入库预报查看编辑合一、箱唛/SKU打印与订单改销售产品
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 19:05:50 +08:00

1390 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Modal } from '@arco-design/web-vue'
import { IconDelete, IconPlus, IconQuestionCircle } from '@arco-design/web-vue/es/icon'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
confirmWarehouseInboundReceive,
deleteWarehouseInbound,
getWarehouseBinLocationList,
getWarehouseInboundDetail,
getWarehouseInboundList,
getWarehouseInboundSummary,
getWarehouseSkuList,
postWarehouseInboundPrintLabels,
saveWarehouseInbound,
} from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState'
import {
buildWarehouseNameMap,
formatDateTime,
normalizeListRow,
pickField,
resolveWarehouseLabel,
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { buildStatusTagHtml, type StatusTagTone } from '@/utils/statusTag'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import { useCurrentWarehouseStore } from '@/store'
defineOptions({ name: 'WarehouseInboundNotice' })
const OrderPrintModal = defineAsyncComponent(
() => import('@/components/PrintTemplate/OrderPrintModal.vue'),
)
interface InboundLineForm {
skuId?: number
skuCode: string
plannedQty: number
}
interface InboundBoxForm {
key: string
boxSeqNo: string
markBoxNo: string
weightG?: number
lengthCm?: number
widthCm?: number
heightCm?: number
lines: InboundLineForm[]
}
interface ReceiveLineForm {
lineId: number
skuCode: string
plannedQty: number
receivedQty: number
binLocationId?: number
}
interface ReceiveBoxForm {
boxId: number
markBoxNo: string
boxSeqNo: string
lines: ReceiveLineForm[]
}
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
resolveSelectedCustomerId,
} = useWarehouseAdminCustomer()
const currentWarehouseStore = useCurrentWarehouseStore()
const printConnectionState = useHiprintClientState()
const printVisible = ref(false)
const printItems = ref<Array<{ id: number | string; label: string; url: string }>>([])
const printPreparing = ref(false)
/** Tab active-key;全部用 'all',请求时再转成后端 receiptStatus */
const STATUS_TAB_ALL = 'all'
const receiptStatus = ref<string>(STATUS_TAB_ALL)
const summary = ref({ forecasted: 0, received: 0, completed: 0 })
const keyword = ref('')
const warehouseId = ref<number>()
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
const warehouseNameMap = computed(() => buildWarehouseNameMap(warehouseOptions.value))
const skuOptions = ref<Array<{ label: string; value: number; code: string }>>([])
const listLoading = ref(false)
const tableRows = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(50)
const totalCount = ref(0)
const selectedBoxKeys = ref<string[]>([])
const activeBoxKey = ref('')
const statusTabs = [
{ value: STATUS_TAB_ALL, labelKey: 'warehouse.inbound.status.all', countKey: null },
{ value: 'forecasted', labelKey: 'warehouse.inbound.status.forecasted', countKey: 'forecasted' as const },
{ value: 'received', labelKey: 'warehouse.inbound.status.received', countKey: 'received' as const },
{ value: 'completed', labelKey: 'warehouse.inbound.status.completed', countKey: 'completed' as const },
]
const slidingStatusTabs = computed(() =>
statusTabs.map((tab) => ({
key: tab.value,
label: t(tab.labelKey),
badge: tab.countKey ? summary.value[tab.countKey] ?? 0 : undefined,
})),
)
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const [registerReceiveModal, { openModal: openReceiveModal, closeModal: closeReceiveModal }] = useModal() as any
/** 已完成等不可改状态:同一弹窗只读查看 */
const formReadonly = ref(false)
const saving = ref(false)
const receiveSaving = ref(false)
const receiveNotice = ref<any>(null)
const receiveBoxes = ref<ReceiveBoxForm[]>([])
const receiveBinOptions = ref<Array<{ label: string; value: number }>>([])
const formModel = reactive({
id: 0,
warehouseId: undefined as number | undefined,
markReference: '',
inboundType: 'normal',
remark: '',
})
const boxRows = ref<InboundBoxForm[]>([])
const boxTabItems = computed(() =>
boxRows.value.map((box) => {
const filled = box.lines.filter((l) => l.skuId).length
return {
key: box.key,
label:
filled > 0
? t('warehouse.inbound.form.boxTabWithSku', { n: box.boxSeqNo, sku: filled })
: `${t('warehouse.inbound.box')} ${box.boxSeqNo}`,
}
}),
)
const canRemoveBox = computed(() => boxRows.value.length > 1)
const formModalTitle = computed(() => {
if (!formModel.id) return t('warehouse.inbound.modal.add')
if (formReadonly.value) return t('warehouse.inbound.modal.view')
return t('warehouse.inbound.modal.edit')
})
const formInboundTypeLabel = computed(() =>
t(`warehouse.inbound.type.${formModel.inboundType}`, formModel.inboundType || '-'),
)
const modalWarehouseName = computed(() => {
const wh = warehouseOptions.value.find((w) => w.value === formModel.warehouseId)
return wh?.label || ''
})
function displayOrDash(value: unknown) {
const text = String(value ?? '').trim()
return text || '-'
}
function formatBoxWeight(box: InboundBoxForm) {
if (box.weightG == null || Number.isNaN(Number(box.weightG))) return '-'
return `${box.weightG} g`
}
function formatBoxSize(box: InboundBoxForm) {
const l = box.lengthCm
const w = box.widthCm
const h = box.heightCm
if (l == null && w == null && h == null) return '-'
return `${l ?? '-'} × ${w ?? '-'} × ${h ?? '-'} cm`
}
function resolveSkuLabel(line: InboundLineForm) {
if (!line.skuId) return '-'
const opt = skuOptions.value.find((s) => s.value === line.skuId)
return opt?.label || line.skuCode || `#${line.skuId}`
}
function viewSkuLines(box: InboundBoxForm) {
return box.lines.filter((l) => l.skuId)
}
const receiveWarehouseName = computed(() => {
const whId = receiveNotice.value?.warehouseId
return resolveWarehouseLabel(whId, warehouseNameMap.value)
})
const boxCount = computed(() => boxRows.value.length)
const totalSkuCount = computed(() =>
boxRows.value.reduce((sum, box) => sum + box.lines.filter((l) => l.skuId).length, 0),
)
const totalPlannedQty = computed(() =>
boxRows.value.reduce(
(sum, box) => sum + box.lines.reduce((s, line) => s + (Number(line.plannedQty) || 0), 0),
0,
),
)
function renderReceiptStatusCell(row: any) {
const code = String(row?._status || '')
const toneMap: Record<string, StatusTagTone> = {
forecasted: 'brand',
received: 'wait',
completed: 'ok',
}
return buildStatusTagHtml(String(row?.receiptStatus || '--'), toneMap[code] || 'neutral')
}
const hotColumns = computed(() => {
const cols = [
{ key: 'noticeNo', label: t('warehouse.inbound.table.noticeNo'), width: 180 },
{ key: 'markReference', label: t('warehouse.inbound.table.markReference'), width: 120 },
{ key: 'warehouseName', label: t('warehouse.inbound.table.warehouse'), width: 140 },
{ key: 'inboundType', label: t('warehouse.inbound.table.inboundType'), width: 100 },
{
key: 'receiptStatus',
label: t('warehouse.inbound.table.receiptStatus'),
width: 100,
renderer: renderReceiptStatusCell,
},
{ key: 'boxCount', label: t('warehouse.inbound.table.boxCount'), width: 80 },
{ key: 'plannedQty', label: t('warehouse.inbound.table.plannedQty'), width: 90 },
{ key: 'expectedArrivalTime', label: t('warehouse.inbound.table.expectedArrival'), width: 160 },
{ key: 'createTime', label: t('warehouse.inbound.table.createTime'), width: 160 },
]
if (isAdmin.value) {
cols.unshift(
{ key: 'customerName', label: t('warehouse.common.customerName'), width: 120 },
{ key: 'customerCode', label: t('warehouse.common.customerCode'), width: 110 },
)
}
return cols
})
function createBox(seq: number): InboundBoxForm {
return {
key: `box-${Date.now()}-${seq}`,
boxSeqNo: String(seq),
markBoxNo: `BOX-${String(seq).padStart(3, '0')}`,
lines: [{ skuId: undefined, skuCode: '', plannedQty: 1 }],
}
}
function resetBoxes() {
const first = createBox(1)
boxRows.value = [first]
selectedBoxKeys.value = []
activeBoxKey.value = first.key
}
function addBox() {
const next = boxRows.value.length + 1
const box = createBox(next)
boxRows.value = [...boxRows.value, box]
activeBoxKey.value = box.key
}
function addBoxes(count: number) {
const start = boxRows.value.length
const added = Array.from({ length: count }, (_, i) => createBox(start + i + 1))
boxRows.value = [...boxRows.value, ...added]
if (added.length) activeBoxKey.value = added[added.length - 1].key
}
function removeActiveBox() {
if (boxRows.value.length <= 1) {
$baseMessage(t('warehouse.inbound.message.keepOneBox'), 'warning')
return
}
const key = activeBoxKey.value
const remain = boxRows.value.filter((b) => b.key !== key)
boxRows.value = remain
selectedBoxKeys.value = selectedBoxKeys.value.filter((k) => k !== key)
activeBoxKey.value = remain[remain.length - 1]?.key || ''
}
function removeLine(box: InboundBoxForm, index: number) {
if (box.lines.length <= 1) {
box.lines[0] = { skuId: undefined, skuCode: '', plannedQty: 1 }
return
}
box.lines.splice(index, 1)
}
function onSkuChange(line: InboundLineForm, skuId?: number) {
const sku = skuOptions.value.find((s) => s.value === skuId)
line.skuId = skuId
line.skuCode = sku?.code || ''
}
watch(
boxRows,
(boxes) => {
if (formReadonly.value) return
for (const box of boxes) {
const last = box.lines[box.lines.length - 1]
if (last?.skuId) {
box.lines.push({ skuId: undefined, skuCode: '', plannedQty: 1 })
}
}
},
{ deep: true },
)
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const status = String(pickField(row, 'receiptStatus', 'ReceiptStatus') ?? '')
const inboundType = String(pickField(row, 'inboundType', 'InboundType') ?? 'normal')
const whId = pickField<number>(row, 'warehouseId', 'WarehouseId')
return {
id: pickField<number>(row, 'id', 'Id'),
customerId: pickField<number>(row, 'customerId', 'CustomerId'),
customerName: pickField(row, 'customerName', 'CustomerName') ?? '',
customerCode: pickField(row, 'customerCode', 'CustomerCode') ?? '',
noticeNo: pickField(row, 'noticeNo', 'NoticeNo') ?? '-',
markReference: pickField(row, 'markReference', 'MarkReference') ?? '-',
warehouseName: resolveWarehouseLabel(whId, warehouseNameMap.value),
inboundType: t(`warehouse.inbound.type.${inboundType}`, inboundType),
receiptStatus: t(`warehouse.inbound.status.${status}`, status),
boxCount: pickField<number>(row, 'boxCount', 'BoxCount') ?? 0,
plannedQty: pickField<number>(row, 'plannedQty', 'PlannedQty') ?? 0,
expectedArrivalTime: formatDateTime(pickField(row, 'expectedArrivalTime', 'ExpectedArrivalTime')),
createTime: formatDateTime(pickField(row, 'createTime', 'CreateTime')),
_status: status,
_raw: row,
}
}
async function loadWarehouses() {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 }).catch(() => ({ items: [] }))
warehouseOptions.value = (items || []).map((w: any) => ({
label: w.warehouseName || `#${w.id}`,
value: Number(w.id),
}))
}
async function loadSkus() {
const { items } = await getWarehouseSkuList(withCustomerId({ page: 1, pageSize: 500 })).catch(() => ({ items: [] }))
skuOptions.value = (items || []).map((s: any) => ({
label: `${s.skuCode || s.SkuCode} - ${s.skuName || s.SkuName}`,
value: Number(s.id || s.Id),
code: s.skuCode || s.SkuCode,
}))
}
async function loadSummary() {
try {
const data = await getWarehouseInboundSummary(withCustomerId({
warehouseId: warehouseId.value,
}))
summary.value = {
forecasted: data?.forecasted ?? data?.Forecasted ?? 0,
received: data?.received ?? data?.Received ?? 0,
completed: data?.completed ?? data?.Completed ?? 0,
}
} catch {
summary.value = { forecasted: 0, received: 0, completed: 0 }
}
}
function resolveReceiptStatusFilter(): string | undefined {
const status = receiptStatus.value
if (!status || status === STATUS_TAB_ALL) return undefined
return status
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseInboundList(withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
receiptStatus: resolveReceiptStatusFilter(),
keyword: keyword.value || undefined,
warehouseId: warehouseId.value,
}))
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
totalCount.value = Number(total || 0)
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.loadFailed'), 'error')
} finally {
listLoading.value = false
}
}
function handleStatusTabChange(key: string | number) {
receiptStatus.value = String(key)
currentPage.value = 1
loadList()
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
loadList()
}
function handleSearch() {
currentPage.value = 1
loadList()
}
async function loadReceiveBinOptions(whId?: number) {
if (!whId) {
receiveBinOptions.value = []
return
}
try {
const { items } = await getWarehouseBinLocationList({ warehouseId: whId, isEnabled: true })
receiveBinOptions.value = (items || []).map((b: any) => ({
label: `${b.binCode || b.BinCode}${b.binName || b.BinName ? ` - ${b.binName || b.BinName}` : ''}`,
value: Number(b.id || b.Id),
}))
} catch {
receiveBinOptions.value = []
}
}
async function openConfirmReceive(row: any) {
if (
!(
hasPermission('Warehouse.InboundNotice.ConfirmReceive')
|| hasPermission('Warehouse.InboundNotice.Save')
)
|| row._status === 'completed'
) {
return
}
if (isAdmin.value && ensureMutationCustomerId() == null) return
try {
const detail = await getWarehouseInboundDetail(
row._raw?.id || row.id,
isAdmin.value ? resolveSelectedCustomerId() : undefined,
)
const normalized = normalizeListRow(detail || {})
const whId = pickField<number>(normalized, 'warehouseId', 'WarehouseId')
const boxesRaw = pickField(normalized, 'boxes', 'Boxes')
const boxes = Array.isArray(boxesRaw) ? boxesRaw : []
receiveNotice.value = {
id: pickField<number>(normalized, 'id', 'Id'),
noticeNo: pickField(normalized, 'noticeNo', 'NoticeNo') ?? row.noticeNo,
warehouseId: whId,
}
receiveBoxes.value = boxes.map((box: any) => {
const b = normalizeListRow(box)
const linesRaw = pickField(b, 'lines', 'Lines')
const lines = Array.isArray(linesRaw) ? linesRaw : []
return {
boxId: Number(pickField(b, 'id', 'Id') || 0),
markBoxNo: String(pickField(b, 'markBoxNo', 'MarkBoxNo') ?? ''),
boxSeqNo: String(pickField(b, 'boxSeqNo', 'BoxSeqNo') ?? ''),
lines: lines.map((line: any) => {
const l = normalizeListRow(line)
const planned = Number(pickField(l, 'plannedQty', 'PlannedQty') ?? 0)
const received = pickField<number>(l, 'receivedQty', 'ReceivedQty')
return {
lineId: Number(pickField(l, 'id', 'Id') || 0),
skuCode: String(pickField(l, 'skuCode', 'SkuCode') ?? '-'),
plannedQty: planned,
receivedQty: received != null ? Number(received) : planned,
binLocationId: pickField<number>(l, 'binLocationId', 'BinLocationId') || undefined,
}
}),
}
})
await loadReceiveBinOptions(whId)
openReceiveModal(true)
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.detailFailed'), 'error')
}
}
async function submitConfirmReceive() {
const notice = receiveNotice.value
if (!notice?.id) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
for (const box of receiveBoxes.value) {
for (const line of box.lines) {
if (line.receivedQty > 0 && !line.binLocationId) {
$baseMessage(t('warehouse.inbound.message.receiveBinRequired'), 'warning')
return
}
if (line.receivedQty < 0) {
$baseMessage(t('warehouse.inbound.message.receiveQtyInvalid'), 'warning')
return
}
}
}
receiveSaving.value = true
try {
await confirmWarehouseInboundReceive(withCustomerId({
inboundNoticeId: notice.id,
boxes: receiveBoxes.value.map((box) => ({
boxId: box.boxId,
lines: box.lines.map((line) => ({
lineId: line.lineId,
receivedQty: Number(line.receivedQty) || 0,
binLocationId: line.receivedQty > 0 ? Number(line.binLocationId) : 0,
})),
})),
}))
$baseMessage(t('warehouse.inbound.message.receiveSuccess'), 'success')
closeReceiveModal()
await loadSummary()
await loadList()
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.receiveFailed'), 'error')
} finally {
receiveSaving.value = false
}
}
function canEditInbound(row: any) {
return row?._status === 'forecasted' || row?._status === 'received'
}
function handleAdd() {
if (!hasPermission('Warehouse.InboundNotice.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
formReadonly.value = false
formModel.id = 0
formModel.warehouseId = currentWarehouseStore.resolveDefaultWarehouseId(warehouseId.value)
formModel.markReference = ''
formModel.remark = ''
formModel.inboundType = 'normal'
resetBoxes()
if (!formModel.warehouseId) {
$baseMessage(t('warehouse.common.noCurrentWarehouse'), 'warning')
return
}
openModal(true)
}
/** 查看/编辑融合:同一弹窗;预报/待入库可改,已完成只读 */
async function openInboundForm(row: any) {
const editable =
hasPermission('Warehouse.InboundNotice.Save') && canEditInbound(row)
if (editable && isAdmin.value && ensureMutationCustomerId() == null) return
try {
const detail = await getWarehouseInboundDetail(
row._raw?.id || row.id,
isAdmin.value ? resolveSelectedCustomerId() : undefined,
)
const normalized = normalizeListRow(detail || {})
formReadonly.value = !editable
formModel.id = Number(pickField(normalized, 'id', 'Id') || 0)
formModel.warehouseId = pickField<number>(normalized, 'warehouseId', 'WarehouseId')
formModel.markReference = String(pickField(normalized, 'markReference', 'MarkReference') ?? '')
formModel.inboundType = String(pickField(normalized, 'inboundType', 'InboundType') ?? 'normal')
formModel.remark = String(pickField(normalized, 'remark', 'Remark') ?? '')
const boxesRaw = pickField(normalized, 'boxes', 'Boxes')
const boxes = Array.isArray(boxesRaw) ? boxesRaw : []
if (!boxes.length) {
if (editable) resetBoxes()
else {
boxRows.value = []
activeBoxKey.value = ''
}
} else {
boxRows.value = boxes.map((box: any, index: number) => {
const b = normalizeListRow(box)
const linesRaw = pickField(b, 'lines', 'Lines')
const lines = Array.isArray(linesRaw) ? linesRaw : []
const mappedLines: InboundLineForm[] = lines.map((line: any) => {
const l = normalizeListRow(line)
return {
skuId: Number(pickField(l, 'skuId', 'SkuId') || 0) || undefined,
skuCode: String(pickField(l, 'skuCode', 'SkuCode') ?? ''),
plannedQty: Number(pickField(l, 'plannedQty', 'PlannedQty') ?? 1) || 1,
}
})
if (editable && (!mappedLines.length || mappedLines.every((l) => l.skuId))) {
mappedLines.push({ skuId: undefined, skuCode: '', plannedQty: 1 })
}
return {
key: `box-open-${pickField(b, 'id', 'Id') || index}-${Date.now()}`,
boxSeqNo: String(pickField(b, 'boxSeqNo', 'BoxSeqNo') ?? index + 1),
markBoxNo: String(pickField(b, 'markBoxNo', 'MarkBoxNo') ?? ''),
weightG: pickField<number>(b, 'weightG', 'WeightG') ?? undefined,
lengthCm: pickField<number>(b, 'lengthCm', 'LengthCm') ?? undefined,
widthCm: pickField<number>(b, 'widthCm', 'WidthCm') ?? undefined,
heightCm: pickField<number>(b, 'heightCm', 'HeightCm') ?? undefined,
lines: mappedLines.length
? mappedLines
: [{ skuId: undefined, skuCode: '', plannedQty: 1 }],
}
})
selectedBoxKeys.value = []
activeBoxKey.value = boxRows.value[0]?.key || ''
}
if (!formModel.warehouseId) {
$baseMessage(t('warehouse.common.noCurrentWarehouse'), 'warning')
return
}
openModal(true)
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.detailFailed'), 'error')
}
}
function closeFormModal() {
formReadonly.value = false
closeModal()
}
async function handleSave() {
if (formReadonly.value) return
if (!formModel.warehouseId) {
$baseMessage(t('warehouse.inbound.message.required'), 'warning')
return
}
if (isAdmin.value && ensureMutationCustomerId() == null) return
const boxes = boxRows.value
.map((box) => ({
boxSeqNo: box.boxSeqNo,
markBoxNo: box.markBoxNo,
weightG: box.weightG,
lengthCm: box.lengthCm,
widthCm: box.widthCm,
heightCm: box.heightCm,
lines: box.lines
.filter((line) => line.skuId)
.map((line) => ({
skuId: line.skuId!,
skuCode: line.skuCode,
plannedQty: Number(line.plannedQty) || 1,
})),
}))
.filter((box) => box.lines.length > 0)
if (!boxes.length) {
$baseMessage(t('warehouse.inbound.message.boxRequired'), 'warning')
return
}
saving.value = true
try {
await saveWarehouseInbound(withCustomerId({
id: formModel.id,
warehouseId: formModel.warehouseId,
markReference: formModel.markReference,
inboundType: formModel.inboundType,
remark: formModel.remark,
boxes,
}))
$baseMessage(t('warehouse.inbound.message.saveSuccess'), 'success')
closeFormModal()
await loadSummary()
await loadList()
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.saveFailed'), 'error')
} finally {
saving.value = false
}
}
function handleDelete(row: any) {
if (!hasPermission('Warehouse.InboundNotice.Delete')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
Modal.confirm({
title: t('warehouse.inbound.actions.delete'),
content: row.noticeNo,
onOk: async () => {
await deleteWarehouseInbound(row._raw?.id || row.id)
$baseMessage(t('warehouse.inbound.message.deleteSuccess'), 'success')
await loadSummary()
await loadList()
},
})
}
function isUsablePrintUrl(raw: unknown) {
const value = String(raw ?? '').trim()
if (!value) return false
if (value.includes('获取中')) return false
return true
}
async function handlePrintLabel(row: any) {
if (!hasPermission('Warehouse.InboundNotice.PrintLabel')) return
const id = Number(row?._raw?.id || row?.id || 0)
if (!id) return
printVisible.value = false
printItems.value = []
printPreparing.value = true
try {
const customerId = isAdmin.value ? resolveSelectedCustomerId() ?? undefined : undefined
const { printUrl, noticeNo, pageCount } = await postWarehouseInboundPrintLabels({
id,
customerId,
})
if (!isUsablePrintUrl(printUrl)) {
$baseMessage(t('warehouse.inbound.message.printEmpty'), 'warning')
return
}
const label = noticeNo || row.noticeNo || String(id)
printItems.value = [
{
id,
label: pageCount > 1 ? `${label}(${pageCount}页)` : label,
url: printUrl,
},
]
printVisible.value = true
if (!printConnectionState.enabled || !printConnectionState.connected) {
$baseMessage('已打开箱唛预览;静默打印需先在顶部连接打印开关', 'info')
}
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.printFailed'), 'error')
} finally {
printPreparing.value = false
}
}
function actionRenderer(row: any) {
const canEdit = hasPermission('Warehouse.InboundNotice.Save') && canEditInbound(row)
const parts = [
canEdit
? renderWarehouseActionBtn('open', t('warehouse.common.edit'), 'edit')
: renderWarehouseActionBtn('open', t('warehouse.common.detail'), 'view'),
]
if (hasPermission('Warehouse.InboundNotice.PrintLabel')) {
parts.push(
renderWarehouseActionBtn('printLabel', t('warehouse.inbound.actions.printLabel'), 'print'),
)
}
if (
(hasPermission('Warehouse.InboundNotice.ConfirmReceive')
|| hasPermission('Warehouse.InboundNotice.Save'))
&& row._status !== 'completed'
) {
parts.push(renderWarehouseActionBtn('receive', t('warehouse.inbound.actions.confirmReceive')))
}
if (hasPermission('Warehouse.InboundNotice.Delete') && row._status === 'forecasted') {
parts.push(renderWarehouseActionBtn('delete', t('warehouse.common.delete')))
}
return wrapWarehouseActions(parts)
}
function handleActionClick(row: any, _i: number, action?: string) {
if (action === 'open') void openInboundForm(row)
if (action === 'printLabel') void handlePrintLabel(row)
if (action === 'receive') openConfirmReceive(row)
if (action === 'delete') handleDelete(row)
}
onMounted(async () => {
await loadWarehouses()
await loadSkus()
await loadSummary()
await loadList()
})
</script>
<template>
<div class="warehouse-inbound-container warehouse-list-page">
<div class="warehouse-list-page-card">
<div class="status-tabs">
<SlidingTabs
:model-value="receiptStatus"
size="mini"
:items="slidingStatusTabs"
@change="handleStatusTabChange"
/>
</div>
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8">
<a-select
v-if="isAdmin"
v-model="adminCustomerId"
allow-clear
allow-search
:placeholder="t('warehouse.common.selectCustomer')"
:options="customerOptions"
style="width: 220px"
@change="() => { loadSkus(); loadSummary(); handleSearch() }"
/>
<a-select
v-model="warehouseId"
allow-clear
:placeholder="t('warehouse.common.warehouse')"
:options="warehouseOptions"
style="width: 200px"
@change="() => { loadSummary(); handleSearch() }"
/>
<a-input
v-model="keyword"
allow-clear
:placeholder="t('warehouse.inbound.search.keyword')"
style="width: 200px"
@press-enter="handleSearch"
/>
<a-button class="app-btn-search" @click="handleSearch">{{ t('warehouse.common.search') }}</a-button>
<a-button v-if="hasPermission('Warehouse.InboundNotice.Save')" class="btn-add app-btn-primary-soft" @click="handleAdd">
{{ t('warehouse.common.add') }}
</a-button>
</a-space>
</div>
<div class="toolbar-right">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="tableRows"
:height="tableHeight"
:loading="listLoading"
:stretch-columns="true"
show-action-column
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
</div>
</div>
<OrderPrintModal
v-if="printVisible"
v-model:visible="printVisible"
mode="single"
:items="printItems"
:preparing="printPreparing"
/>
<ModalPro
:title="t('warehouse.inbound.modal.confirmReceive')"
:width="860"
:mask-closable="false"
@register="registerReceiveModal"
@cancel="closeReceiveModal"
>
<div class="wh-form-modal">
<div v-if="receiveNotice" class="wh-form-info-bar">
<div>{{ t('warehouse.inbound.table.noticeNo') }}:{{ receiveNotice.noticeNo }}</div>
<div>{{ t('warehouse.common.warehouse') }}:{{ receiveWarehouseName || '-' }}</div>
</div>
<div v-for="box in receiveBoxes" :key="box.boxId" class="receive-box-block">
<div class="form-section-title">
{{ t('warehouse.inbound.box') }} {{ box.boxSeqNo }}
<span v-if="box.markBoxNo">({{ box.markBoxNo }})</span>
</div>
<a-table
:data="box.lines"
:pagination="false"
size="small"
:bordered="{ cell: true }"
:scroll="{ x: '100%', y: 220 }"
>
<template #columns>
<a-table-column :title="t('warehouse.inbound.line.skuCode')" data-index="skuCode" :width="140" />
<a-table-column :title="t('warehouse.inbound.line.plannedQty')" data-index="plannedQty" :width="90" />
<a-table-column :title="t('warehouse.inbound.line.receivedQty')" :width="130">
<template #cell="{ record }">
<a-input-number v-model="record.receivedQty" :min="0" hide-button style="width: 100%" />
</template>
</a-table-column>
<a-table-column :title="t('warehouse.inbound.line.binLocation')" :width="220">
<template #cell="{ record }">
<a-select
v-model="record.binLocationId"
:options="receiveBinOptions"
allow-search
allow-clear
:placeholder="t('warehouse.inbound.form.selectBin')"
:disabled="!(record.receivedQty > 0)"
/>
</template>
</a-table-column>
</template>
</a-table>
</div>
</div>
<template #footer>
<a-space :size="8">
<a-button @click="closeReceiveModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="receiveSaving" @click="submitConfirmReceive">
{{ t('warehouse.inbound.actions.confirmReceive') }}
</a-button>
</a-space>
</template>
</ModalPro>
<ModalPro
:title="formModalTitle"
:width="860"
:mask-closable="false"
@register="registerFormModal"
@cancel="closeFormModal"
>
<div class="wh-form-modal">
<div class="wh-form-info-bar">
<span class="label-with-tip">
{{ t('warehouse.parcel.form.currentWarehouse') }}:{{
modalWarehouseName
|| warehouseOptions.find((o) => o.value === formModel.warehouseId)?.label
|| currentWarehouseStore.warehouseName
|| formModel.warehouseId
|| '-'
}}
<a-tooltip v-if="!formReadonly" :content="t('warehouse.inbound.tip.currentWarehouse')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
</div>
<div class="form-section-title">{{ t('warehouse.modal.section.basic') }}</div>
<div v-if="formReadonly" class="inbound-view-basic">
<div class="inbound-view-row">
<div class="inbound-view-field inbound-view-field--grow">
<div class="inbound-view-label">{{ t('warehouse.inbound.form.markReference') }}</div>
<div class="wh-form-readonly">{{ displayOrDash(formModel.markReference) }}</div>
</div>
<div class="inbound-view-field">
<div class="inbound-view-label">{{ t('warehouse.inbound.table.inboundType') }}</div>
<div class="wh-form-readonly">{{ formInboundTypeLabel }}</div>
</div>
</div>
<div class="inbound-view-field">
<div class="inbound-view-label">{{ t('warehouse.parcel.form.remark') }}</div>
<div class="wh-form-readonly">{{ displayOrDash(formModel.remark) }}</div>
</div>
</div>
<a-form v-else :model="formModel" layout="vertical" class="inbound-form-basic edit-form">
<a-row :gutter="16">
<a-col :span="14">
<a-form-item :required="false">
<template #label>
<span class="label-with-tip">
{{ t('warehouse.inbound.form.markReference') }}
<a-tooltip :content="t('warehouse.inbound.tip.markReference')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
</template>
<a-input
v-model="formModel.markReference"
:placeholder="t('warehouse.inbound.form.markPlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item>
<template #label>
<span class="label-with-tip">
{{ t('warehouse.inbound.table.inboundType') }}
<a-tooltip :content="t('warehouse.inbound.tip.inboundType')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
</template>
<a-select v-model="formModel.inboundType">
<a-option value="normal">{{ t('warehouse.inbound.type.normal') }}</a-option>
<a-option value="return">{{ t('warehouse.inbound.type.return') }}</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<template #label>{{ t('warehouse.parcel.form.remark') }}</template>
<a-input
v-model="formModel.remark"
allow-clear
:placeholder="t('warehouse.inbound.form.remarkPlaceholder')"
/>
</a-form-item>
</a-form>
<div class="form-section-title form-section-title--with-extra">
<span class="label-with-tip">
{{ t('warehouse.inbound.form.boxSkuDetail') }}
<span class="wh-form-section__count">
{{ t('warehouse.inbound.form.summaryMeta', {
box: boxCount,
sku: totalSkuCount,
qty: totalPlannedQty,
}) }}
</span>
<a-tooltip :content="t('warehouse.inbound.tip.boxSkuDetail')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
<a-space v-if="!formReadonly" :size="8">
<a-button size="mini" @click="addBox">
<template #icon><IconPlus :size="12" /></template>
{{ t('warehouse.inbound.actions.addBox') }}
</a-button>
<a-button size="mini" @click="addBoxes(5)">
{{ t('warehouse.inbound.actions.addBox5') }}
</a-button>
<a-button
size="mini"
status="danger"
:disabled="!canRemoveBox"
@click="removeActiveBox"
>
{{ t('warehouse.inbound.actions.removeCurrentBox') }}
</a-button>
</a-space>
</div>
<div class="wh-form-box-tabs">
<SlidingTabs v-model="activeBoxKey" size="mini" :items="boxTabItems" />
</div>
<div
v-for="box in boxRows"
v-show="activeBoxKey === box.key"
:key="box.key"
class="wh-form-box-panel inbound-box-panel"
>
<div v-if="formReadonly" class="inbound-box-meta inbound-box-meta--view">
<div class="inbound-box-meta__field inbound-box-meta__field--mark">
<span class="inbound-box-meta__label">{{ t('warehouse.inbound.form.markBoxNo') }}</span>
<div class="wh-form-readonly">{{ displayOrDash(box.markBoxNo) }}</div>
</div>
<div class="inbound-box-meta__field inbound-box-meta__field--weight">
<span class="inbound-box-meta__label">{{ t('warehouse.inbound.form.boxWeight') }}</span>
<div class="wh-form-readonly">{{ formatBoxWeight(box) }}</div>
</div>
<div class="inbound-box-meta__field inbound-box-meta__field--size">
<span class="inbound-box-meta__label">{{ t('warehouse.inbound.form.boxSize') }}</span>
<div class="wh-form-readonly">{{ formatBoxSize(box) }}</div>
</div>
</div>
<div v-else class="inbound-box-meta">
<div class="inbound-box-meta__field inbound-box-meta__field--mark">
<span class="inbound-box-meta__label label-with-tip">
{{ t('warehouse.inbound.form.markBoxNo') }}
<a-tooltip :content="t('warehouse.inbound.tip.markBoxNo')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
<a-input
v-model="box.markBoxNo"
allow-clear
:placeholder="t('warehouse.inbound.form.markBoxNoPlaceholder')"
/>
</div>
<div class="inbound-box-meta__field inbound-box-meta__field--weight">
<span class="inbound-box-meta__label">{{ t('warehouse.inbound.form.boxWeight') }}</span>
<a-input-number
v-model="box.weightG"
:min="0"
hide-button
style="width: 100%"
:placeholder="t('warehouse.inbound.form.optional')"
>
<template #suffix>g</template>
</a-input-number>
</div>
<div class="inbound-box-meta__field inbound-box-meta__field--size">
<span class="inbound-box-meta__label">{{ t('warehouse.inbound.form.boxSize') }}(cm)</span>
<div class="inbound-box-size">
<a-input-number
v-model="box.lengthCm"
:min="0"
hide-button
:placeholder="t('warehouse.sku.form.length')"
>
<template #suffix>cm</template>
</a-input-number>
<span>×</span>
<a-input-number
v-model="box.widthCm"
:min="0"
hide-button
:placeholder="t('warehouse.sku.form.width')"
>
<template #suffix>cm</template>
</a-input-number>
<span>×</span>
<a-input-number
v-model="box.heightCm"
:min="0"
hide-button
:placeholder="t('warehouse.sku.form.height')"
>
<template #suffix>cm</template>
</a-input-number>
</div>
</div>
</div>
<div v-if="!formReadonly" class="inbound-sku-head">
<span class="wh-form-hint label-with-tip">
{{ t('warehouse.inbound.form.autoAddSkuRow') }}
<a-tooltip :content="t('warehouse.inbound.tip.skuLines')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
</div>
<a-table
v-if="formReadonly"
class="inbound-sku-table"
:data="viewSkuLines(box)"
:pagination="false"
size="small"
:bordered="{ cell: true }"
:scroll="{ x: '100%', y: 240 }"
>
<template #columns>
<a-table-column :title="'#'" :width="44">
<template #cell="{ rowIndex }">{{ rowIndex + 1 }}</template>
</a-table-column>
<a-table-column :title="t('warehouse.inbound.form.sku')" :min-width="220">
<template #cell="{ record }">
<span class="wh-form-readonly">{{ resolveSkuLabel(record) }}</span>
</template>
</a-table-column>
<a-table-column :title="t('warehouse.inbound.line.plannedQty')" :width="120">
<template #cell="{ record }">
<span class="wh-form-readonly">{{ record.plannedQty }}</span>
</template>
</a-table-column>
</template>
</a-table>
<a-table
v-else
class="inbound-sku-table"
:data="box.lines"
:pagination="false"
size="small"
:bordered="{ cell: true }"
:scroll="{ x: '100%', y: 240 }"
>
<template #columns>
<a-table-column :title="'#'" :width="44">
<template #cell="{ rowIndex }">{{ rowIndex + 1 }}</template>
</a-table-column>
<a-table-column :title="t('warehouse.inbound.form.sku')" :min-width="220">
<template #cell="{ record }">
<a-select
v-model="record.skuId"
:options="skuOptions"
allow-search
allow-clear
:placeholder="t('warehouse.inbound.form.selectSku')"
@change="(v: number) => onSkuChange(record, v)"
/>
</template>
</a-table-column>
<a-table-column :title="t('warehouse.inbound.line.plannedQty')" :width="120">
<template #cell="{ record }">
<a-input-number
v-model="record.plannedQty"
:min="1"
hide-button
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column :title="t('common.actions')" :width="64" align="center">
<template #cell="{ rowIndex, record }">
<div class="inbound-action-cell">
<a-button
v-if="record.skuId || box.lines.length > 1"
type="text"
status="danger"
size="mini"
:aria-label="t('warehouse.common.delete')"
@click="removeLine(box, rowIndex)"
>
<IconDelete :size="16" />
</a-button>
</div>
</template>
</a-table-column>
</template>
</a-table>
</div>
</div>
<template #footer>
<a-space :size="8">
<a-button @click="closeFormModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button
v-if="!formReadonly"
type="primary"
:loading="saving"
@click="handleSave"
>
{{ t('warehouse.common.save') }}
</a-button>
</a-space>
</template>
</ModalPro>
</div>
</template>
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
.wh-form-box-tabs {
margin-bottom: 12px;
}
.inbound-view-basic {
margin-bottom: 8px;
}
.inbound-view-row {
display: flex;
gap: 24px;
margin-bottom: 12px;
}
.inbound-view-field {
min-width: 140px;
&--grow {
flex: 1;
min-width: 0;
}
}
.inbound-view-label {
margin-bottom: 4px;
font-size: 13px;
color: var(--color-text-3);
}
.inbound-box-meta--view {
.inbound-box-meta__label {
margin-bottom: 4px;
}
}
.receive-box-block {
margin-bottom: 20px;
}
.form-section-title {
margin: 0 0 12px;
padding: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-2);
border-bottom: 1px solid var(--color-border-2);
&:not(:first-child) {
margin-top: 8px;
}
&--with-extra {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
}
.label-with-tip {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tip-icon {
color: var(--color-text-3);
cursor: help;
font-size: 14px;
}
.wh-form-section__count {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--color-text-3);
}
.inbound-form-basic {
:deep(.arco-form-item) {
margin-bottom: 16px;
}
}
.inbound-box-panel {
padding: 12px;
}
.inbound-box-meta {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
margin-bottom: 12px;
align-items: flex-end;
}
.inbound-box-meta__field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
&--mark {
flex: 1 1 160px;
}
&--weight {
flex: 0 0 120px;
}
&--size {
flex: 1 1 320px;
}
}
.inbound-box-meta__label {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.2;
}
.inbound-box-size {
display: flex;
align-items: center;
gap: 6px;
:deep(.arco-input-number) {
width: 96px;
}
> span {
color: var(--color-text-3);
}
}
.inbound-sku-head {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.inbound-sku-table {
:deep(.arco-table-td) {
vertical-align: middle;
}
:deep(.arco-table-cell) {
padding-top: 6px;
padding-bottom: 6px;
}
}
.inbound-action-cell {
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
}
</style>