完善仓储模块:操作列图标、确认收货与换库位对接、新增库位管理页,管理端支持按客户筛选(不选则查全部)。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-14 11:12:40 +08:00
parent fb1e566a21
commit f5011edde6
15 changed files with 1167 additions and 53 deletions

View File

@ -137,6 +137,9 @@ server {
location = /warehouse/sku {
try_files /index.html =200;
}
location = /warehouse/binLocation {
try_files /index.html =200;
}
location ^~ /warehouse/inboundNotice {
try_files /index.html =200;
}

View File

@ -52,6 +52,7 @@ server {
location = /warehouse/parcel { try_files /index.html =200; }
location = /warehouse/inventory { try_files /index.html =200; }
location = /warehouse/sku { try_files /index.html =200; }
location = /warehouse/binLocation { try_files /index.html =200; }
location ^~ /warehouse/inboundNotice { try_files /index.html =200; }
location ^~ /channel/salesProducts { try_files /index.html =200; }

View File

@ -0,0 +1,94 @@
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CustomerService } from '@/api/customer'
import { extractCustomerId, mapCustomerRow } from '@/api/customer/resolve'
import { $baseMessage } from '@/utils'
import { useRbacUserStore } from '@/store/modules/rbac-user'
/**
* 仓储页管理端客户上下文:
* - 列表/汇总:未选客户 → 不传 customerId → 查全部
* - 写入:管理端须显式选择客户
* 客户门户登录时由后端会话强制归属,前端不传 customerId。
*/
export function useWarehouseAdminCustomer() {
const { t } = useI18n()
const rbacUserStore = useRbacUserStore()
const isAdmin = computed(() => !rbacUserStore.isCustomerPortal())
/** undefined = 不筛选(查全部);仅 >0 才作为筛选条件 */
const customerId = ref<number>()
const customerOptions = ref<Array<{ label: string; value: number }>>([])
const customerLoading = ref(false)
function resolveSelectedCustomerId(): number | undefined {
const raw = customerId.value as number | string | null | undefined
if (raw == null || raw === '') return undefined
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}
async function loadCustomers() {
if (!isAdmin.value) {
customerOptions.value = []
return
}
customerLoading.value = true
try {
const { items } = await CustomerService.getList({ page: 1, pageSize: 500 })
customerOptions.value = (items || [])
.map((row) => mapCustomerRow(row as Record<string, unknown>))
.map((c) => {
const id = extractCustomerId(c as Record<string, unknown>)
if (!id) return null
const name = c.customerName || c.customerCode || c.phone || `#${id}`
const code = c.customerCode ? ` (${c.customerCode})` : ''
return { label: `${name}${code}`, value: id }
})
.filter((x): x is { label: string; value: number } => !!x)
} catch {
customerOptions.value = []
} finally {
customerLoading.value = false
}
}
/**
* 追加到 list/save 请求体。
* 管理端未选客户时显式去掉 customerId,保证「不选 = 查全部」。
*/
function withCustomerId<T extends Record<string, unknown>>(body: T): T & { customerId?: number } {
const next = { ...body } as T & { customerId?: number }
delete next.customerId
if (!isAdmin.value) return next
const id = resolveSelectedCustomerId()
if (id != null) next.customerId = id
return next
}
/** 管理端写操作前校验已选客户;客户门户返回 undefined 由后端会话解析 */
function ensureMutationCustomerId(): number | undefined {
if (!isAdmin.value) return undefined
const id = resolveSelectedCustomerId()
if (id == null) {
$baseMessage(t('warehouse.common.selectCustomerRequired'), 'warning')
return undefined
}
return id
}
onMounted(() => {
loadCustomers()
})
return {
isAdmin,
customerId,
customerOptions,
customerLoading,
loadCustomers,
withCustomerId,
ensureMutationCustomerId,
resolveSelectedCustomerId,
}
}

View File

@ -117,6 +117,8 @@ export const PERMISSION_ACTION_LABELS: Record<string, string> = {
AddAnnouncement: '新增公告',
AddDict: '新增字典',
AddWarehouse: '新增仓库',
Relocate: '换库位',
ConfirmReceive: '确认收货',
AddConfig: '新增配置',
Debug: '调试',
Rollback: '回滚到此版本',
@ -254,6 +256,11 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record<string, string> = {
'Api.Test.Query': 'Api.ApiTest',
'Channel.LabelDesign': 'Channel.LabelDesign',
'Order.BatchJobs.Query': 'Order.BatchJobs',
'Warehouse.Sku.Query': 'Warehouse.Sku',
'Warehouse.Inventory.Query': 'Warehouse.Inventory',
'Warehouse.Parcel.Query': 'Warehouse.Parcel',
'Warehouse.InboundNotice.Query': 'Warehouse.InboundNotice',
'Warehouse.BinLocation.Query': 'Warehouse.BinLocation',
};
type BtnInput = { code: string; name: string };
@ -510,8 +517,13 @@ const CORE_GROUPS: PageButtonGroup[] = [
B('Warehouse.InboundNotice', [
{ code: 'Query', name: '查询' },
{ code: 'Save', name: '保存' },
{ code: 'ConfirmReceive', name: '确认收货' },
{ code: 'Delete', name: '删除' },
]),
B('Warehouse.BinLocation', [
{ code: 'Query', name: '查询' },
{ code: 'Save', name: '保存' },
]),
// ── 渠道 ──
B('Channel.SalesProducts', [

View File

@ -90,6 +90,7 @@ export default {
'menu.warehouse.inventory': 'My Inventory',
'menu.warehouse.sku': 'My SKU',
'menu.warehouse.inboundNotice': 'Inbound Notice',
'menu.warehouse.binLocation': 'Bin Locations',
'warehouse.common.search': 'Search',
'warehouse.common.add': 'Add',
'warehouse.common.edit': 'Edit',
@ -99,6 +100,9 @@ export default {
'warehouse.common.confirm': 'Confirm',
'warehouse.common.detail': 'Detail',
'warehouse.common.warehouse': 'Warehouse',
'warehouse.common.customer': 'Customer',
'warehouse.common.selectCustomer': 'All customers',
'warehouse.common.selectCustomerRequired': 'Select a customer before create/update',
'warehouse.common.yes': 'Yes',
'warehouse.common.no': 'No',
'warehouse.sku.table.dimensions': 'Size(cm)',
@ -152,12 +156,40 @@ export default {
'warehouse.inventory.form.targetBin': 'Target Bin',
'warehouse.inventory.form.qty': 'Qty',
'warehouse.inventory.form.selectBin': 'Select bin',
'warehouse.inventory.form.selectSourceBin': 'Select source bin with stock',
'warehouse.inventory.form.selectTargetBin': 'Select target bin',
'warehouse.inventory.message.loadFailed': 'Failed to load inventory',
'warehouse.inventory.message.binDetailFailed': 'Failed to load bin detail',
'warehouse.inventory.message.relocateRequired': 'Fill relocate fields',
'warehouse.inventory.message.relocateSuccess': 'Relocated',
'warehouse.inventory.message.relocateFailed': 'Relocate failed',
'warehouse.inventory.message.sameBin': 'Source and target bin must differ',
'warehouse.inventory.message.qtyExceed': 'Qty cannot exceed source bin stock',
'warehouse.binLocation.table.binCode': 'Bin Code',
'warehouse.binLocation.table.binName': 'Bin Name',
'warehouse.binLocation.table.zoneCode': 'Zone',
'warehouse.binLocation.table.isEnabled': 'Enabled',
'warehouse.binLocation.table.remark': 'Remark',
'warehouse.binLocation.search.warehouse': 'Search warehouse',
'warehouse.binLocation.search.keyword': 'Code / Name',
'warehouse.binLocation.filter.isEnabled': 'Enabled',
'warehouse.binLocation.actions.refreshWarehouse': 'Refresh warehouses',
'warehouse.binLocation.actions.add': 'Add Bin',
'warehouse.binLocation.loading.warehouse': 'Loading warehouses...',
'warehouse.binLocation.emptySelect': 'Select a warehouse on the left',
'warehouse.binLocation.modal.add': 'Add Bin Location',
'warehouse.binLocation.modal.edit': 'Edit Bin Location',
'warehouse.binLocation.form.binCode': 'Bin Code',
'warehouse.binLocation.form.binName': 'Bin Name',
'warehouse.binLocation.form.zoneCode': 'Zone Code',
'warehouse.binLocation.form.isEnabled': 'Enabled',
'warehouse.binLocation.form.remark': 'Remark',
'warehouse.binLocation.message.warehouseLoadFailed': 'Failed to load warehouses',
'warehouse.binLocation.message.loadFailed': 'Failed to load bins',
'warehouse.binLocation.message.selectWarehouse': 'Select a warehouse first',
'warehouse.binLocation.message.required': 'Bin code is required',
'warehouse.binLocation.message.saveSuccess': 'Saved',
'warehouse.binLocation.message.saveFailed': 'Save failed',
'warehouse.parcel.status.all': 'All',
'warehouse.parcel.status.inTransit': 'In Transit',
'warehouse.parcel.status.arrived': 'Arrived',
@ -228,7 +260,11 @@ export default {
'warehouse.inbound.line.skuCode': 'SKU',
'warehouse.inbound.line.plannedQty': 'Planned',
'warehouse.inbound.line.receivedQty': 'Received',
'warehouse.inbound.line.binLocation': 'Putaway Bin',
'warehouse.inbound.form.selectBin': 'Select bin',
'warehouse.inbound.actions.delete': 'Delete',
'warehouse.inbound.actions.confirmReceive': 'Confirm Receive',
'warehouse.inbound.modal.confirmReceive': 'Confirm Receive & Putaway',
'warehouse.inbound.message.loadFailed': 'Failed to load inbound list',
'warehouse.inbound.message.saveSuccess': 'Saved',
'warehouse.inbound.message.saveFailed': 'Save failed',
@ -236,6 +272,10 @@ export default {
'warehouse.inbound.message.required': 'Select warehouse',
'warehouse.inbound.message.boxRequired': 'Add SKU lines to at least one box',
'warehouse.inbound.message.detailFailed': 'Failed to load detail',
'warehouse.inbound.message.receiveBinRequired': 'Bin is required when received qty > 0',
'warehouse.inbound.message.receiveQtyInvalid': 'Received qty cannot be negative',
'warehouse.inbound.message.receiveSuccess': 'Receive succeeded',
'warehouse.inbound.message.receiveFailed': 'Receive failed',
'order.platformStore.platform.manual': 'Manual',
'order.platformStore.table.siteCountry': 'Site Country',
'order.platformStore.table.autoFulfill': 'Auto Fulfill',

View File

@ -90,6 +90,7 @@ export default {
'menu.warehouse.inventory': '我的库存',
'menu.warehouse.sku': '我的 SKU',
'menu.warehouse.inboundNotice': '入库预报',
'menu.warehouse.binLocation': '库位管理',
'warehouse.common.search': '查询',
'warehouse.common.add': '新增',
'warehouse.common.edit': '编辑',
@ -99,6 +100,9 @@ export default {
'warehouse.common.confirm': '确定',
'warehouse.common.detail': '详情',
'warehouse.common.warehouse': '仓库',
'warehouse.common.customer': '客户',
'warehouse.common.selectCustomer': '全部客户',
'warehouse.common.selectCustomerRequired': '新增/修改请先选择客户',
'warehouse.common.yes': '是',
'warehouse.common.no': '否',
'warehouse.sku.table.dimensions': '尺寸(cm)',
@ -152,12 +156,40 @@ export default {
'warehouse.inventory.form.targetBin': '目标库位',
'warehouse.inventory.form.qty': '数量',
'warehouse.inventory.form.selectBin': '请选择库位',
'warehouse.inventory.form.selectSourceBin': '请选择有库存的源库位',
'warehouse.inventory.form.selectTargetBin': '请选择目标库位',
'warehouse.inventory.message.loadFailed': '加载库存失败',
'warehouse.inventory.message.binDetailFailed': '加载库位明细失败',
'warehouse.inventory.message.relocateRequired': '请填写换库位信息',
'warehouse.inventory.message.relocateSuccess': '换库位成功',
'warehouse.inventory.message.relocateFailed': '换库位失败',
'warehouse.inventory.message.sameBin': '源库位与目标库位不能相同',
'warehouse.inventory.message.qtyExceed': '数量不能超过源库位库存',
'warehouse.binLocation.table.binCode': '库位编码',
'warehouse.binLocation.table.binName': '库位名称',
'warehouse.binLocation.table.zoneCode': '库区',
'warehouse.binLocation.table.isEnabled': '启用',
'warehouse.binLocation.table.remark': '备注',
'warehouse.binLocation.search.warehouse': '搜索仓库',
'warehouse.binLocation.search.keyword': '编码/名称',
'warehouse.binLocation.filter.isEnabled': '启用状态',
'warehouse.binLocation.actions.refreshWarehouse': '刷新仓库',
'warehouse.binLocation.actions.add': '新增库位',
'warehouse.binLocation.loading.warehouse': '正在加载仓库...',
'warehouse.binLocation.emptySelect': '请选择左侧仓库',
'warehouse.binLocation.modal.add': '新增库位',
'warehouse.binLocation.modal.edit': '编辑库位',
'warehouse.binLocation.form.binCode': '库位编码',
'warehouse.binLocation.form.binName': '库位名称',
'warehouse.binLocation.form.zoneCode': '库区编码',
'warehouse.binLocation.form.isEnabled': '启用',
'warehouse.binLocation.form.remark': '备注',
'warehouse.binLocation.message.warehouseLoadFailed': '加载仓库失败',
'warehouse.binLocation.message.loadFailed': '加载库位失败',
'warehouse.binLocation.message.selectWarehouse': '请先选择仓库',
'warehouse.binLocation.message.required': '请填写库位编码',
'warehouse.binLocation.message.saveSuccess': '保存成功',
'warehouse.binLocation.message.saveFailed': '保存失败',
'warehouse.parcel.status.all': '全部',
'warehouse.parcel.status.inTransit': '在途',
'warehouse.parcel.status.arrived': '已到仓',
@ -228,7 +260,11 @@ export default {
'warehouse.inbound.line.skuCode': 'SKU',
'warehouse.inbound.line.plannedQty': '计划',
'warehouse.inbound.line.receivedQty': '实收',
'warehouse.inbound.line.binLocation': '上架库位',
'warehouse.inbound.form.selectBin': '请选择库位',
'warehouse.inbound.actions.delete': '删除预报',
'warehouse.inbound.actions.confirmReceive': '确认收货',
'warehouse.inbound.modal.confirmReceive': '确认收货上架',
'warehouse.inbound.message.loadFailed': '加载入库预报失败',
'warehouse.inbound.message.saveSuccess': '保存成功',
'warehouse.inbound.message.saveFailed': '保存失败',
@ -236,6 +272,10 @@ export default {
'warehouse.inbound.message.required': '请选择仓库',
'warehouse.inbound.message.boxRequired': '请至少为一个箱子选择 SKU',
'warehouse.inbound.message.detailFailed': '加载详情失败',
'warehouse.inbound.message.receiveBinRequired': '有实收数量时必须选择上架库位',
'warehouse.inbound.message.receiveQtyInvalid': '实收数量不能为负',
'warehouse.inbound.message.receiveSuccess': '收货成功',
'warehouse.inbound.message.receiveFailed': '收货失败',
'order.platformStore.platform.manual': '手工店铺',
'order.platformStore.table.siteCountry': '站点国家',
'order.platformStore.table.autoFulfill': '自动发货',

View File

@ -20,6 +20,7 @@ const WAREHOUSE: AppRouteRecordRaw = {
locale: 'menu.warehouse.parcel',
requiresAuth: true,
permission: 'Warehouse.Parcel.Query',
order: 1,
},
},
{
@ -30,6 +31,7 @@ const WAREHOUSE: AppRouteRecordRaw = {
locale: 'menu.warehouse.inventory',
requiresAuth: true,
permission: 'Warehouse.Inventory.Query',
order: 2,
},
},
{
@ -40,6 +42,7 @@ const WAREHOUSE: AppRouteRecordRaw = {
locale: 'menu.warehouse.sku',
requiresAuth: true,
permission: 'Warehouse.Sku.Query',
order: 3,
},
},
{
@ -50,6 +53,18 @@ const WAREHOUSE: AppRouteRecordRaw = {
locale: 'menu.warehouse.inboundNotice',
requiresAuth: true,
permission: 'Warehouse.InboundNotice.Query',
order: 4,
},
},
{
path: 'binLocation',
name: 'WarehouseBinLocation',
component: () => import('@/views/warehouse/binLocation/index.vue'),
meta: {
locale: 'menu.warehouse.binLocation',
requiresAuth: true,
permission: 'Warehouse.BinLocation.Query',
order: 5,
},
},
],

View File

@ -71,3 +71,37 @@
margin-left: 4px;
color: var(--app-primary-color);
}
.warehouse-list-page-card .list-wrap :deep(.wh-action-group) {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
width: 100%;
}
.warehouse-list-page-card .list-wrap :deep(.wh-action-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
color: var(--color-text-3);
transition: color 0.2s ease, background-color 0.2s ease;
user-select: none;
}
.warehouse-list-page-card .list-wrap :deep(.wh-action-btn--view:hover),
.warehouse-list-page-card .list-wrap :deep(.wh-action-btn--edit:hover),
.warehouse-list-page-card .list-wrap :deep(.wh-action-btn--relocate:hover),
.warehouse-list-page-card .list-wrap :deep(.wh-action-btn--receive:hover) {
color: var(--app-primary-color);
background: var(--app-primary-color-light, #e6f7f7);
}
.warehouse-list-page-card .list-wrap :deep(.wh-action-btn--delete:hover) {
color: rgb(var(--danger-6));
background: rgb(var(--danger-1));
}

View File

@ -0,0 +1,28 @@
/** Handsontable 仓储列表操作列:图标按钮 HTML(配合 data-action 点击) */
const ICON_STYLE = 'width:17px;height:17px;display:block;pointer-events:none'
export const warehouseActionIcons = {
view: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="${ICON_STYLE}"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`,
edit: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="${ICON_STYLE}"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`,
delete: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="${ICON_STYLE}"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>`,
relocate: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="${ICON_STYLE}"><path d="M7 7h11l-2.5-2.5"/><path d="M17 17H6l2.5 2.5"/><path d="M7 7v4"/><path d="M17 17v-4"/></svg>`,
receive: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="${ICON_STYLE}"><path d="M20 6 9 17l-5-5"/><path d="M21 12v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h11"/></svg>`,
} as const
export type WarehouseActionVariant = keyof typeof warehouseActionIcons
export function renderWarehouseActionBtn(
action: string,
title: string,
variant: WarehouseActionVariant = action as WarehouseActionVariant,
): string {
const icon = warehouseActionIcons[variant] || warehouseActionIcons.view
const safeTitle = String(title).replace(/"/g, '&quot;')
return `<span class="wh-action-btn wh-action-btn--${variant}" data-action="${action}" title="${safeTitle}" role="button">${icon}</span>`
}
export function wrapWarehouseActions(parts: string[]): string {
if (!parts.length) return ''
return `<div class="wh-action-group">${parts.join('')}</div>`
}

View File

@ -0,0 +1,449 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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 CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { getWarehouseBinLocationList, saveWarehouseBinLocation } from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import { normalizeListRow, pickField } from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
defineOptions({ name: 'WarehouseBinLocation' })
interface WarehouseItem {
id: number
warehouseName: string
warehouseCode?: string
}
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const warehouseLoading = ref(false)
const warehouseList = ref<WarehouseItem[]>([])
const warehouseSearchKeyword = ref('')
const selectedWarehouseId = ref<number>()
const expandedKeys = ref<string[]>(['category-warehouses'])
const listLoading = ref(false)
const tableRows = ref<any[]>([])
const keyword = ref('')
const enabledFilter = ref<boolean | undefined>(undefined)
const currentPage = ref(1)
const pageSize = ref(30)
const totalCount = ref(0)
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
const formModel = reactive({
id: 0,
warehouseId: 0,
binCode: '',
binName: '',
zoneCode: '',
isEnabled: true,
remark: '',
})
const selectedWarehouse = computed(() =>
warehouseList.value.find((w) => w.id === selectedWarehouseId.value),
)
const warehouseTreeData = computed<CustomLeftMenuTreeNode[]>(() => {
const kw = warehouseSearchKeyword.value.trim().toLowerCase()
const filtered = warehouseList.value.filter((w) => {
if (!kw) return true
return (
w.warehouseName.toLowerCase().includes(kw)
|| (w.warehouseCode || '').toLowerCase().includes(kw)
)
})
return [
{
key: 'category-warehouses',
title: t('warehouse.common.warehouse'),
children: filtered.map((w) => ({
key: `item-${w.id}`,
title: w.warehouseName,
isLeaf: true,
id: w.id,
name: w.warehouseName,
code: w.warehouseCode,
})),
},
]
})
const selectedKeys = computed(() =>
selectedWarehouseId.value ? [`item-${selectedWarehouseId.value}`] : [],
)
const pagedRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return tableRows.value.slice(start, start + pageSize.value)
})
const hotColumns = computed(() => [
{ key: 'binCode', label: t('warehouse.binLocation.table.binCode'), width: 140 },
{ key: 'binName', label: t('warehouse.binLocation.table.binName'), width: 160 },
{ key: 'zoneCode', label: t('warehouse.binLocation.table.zoneCode'), width: 120 },
{ key: 'isEnabledLabel', label: t('warehouse.binLocation.table.isEnabled'), width: 90 },
{ key: 'remark', label: t('warehouse.binLocation.table.remark'), width: 180 },
])
const modalTitle = computed(() =>
formModel.id > 0 ? t('warehouse.binLocation.modal.edit') : t('warehouse.binLocation.modal.add'),
)
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const enabled = Boolean(pickField(row, 'isEnabled', 'IsEnabled'))
return {
id: pickField<number>(row, 'id', 'Id'),
warehouseId: pickField<number>(row, 'warehouseId', 'WarehouseId'),
binCode: pickField(row, 'binCode', 'BinCode') ?? '-',
binName: pickField(row, 'binName', 'BinName') ?? '',
zoneCode: pickField(row, 'zoneCode', 'ZoneCode') ?? '',
isEnabled: enabled,
isEnabledLabel: enabled ? t('warehouse.common.yes') : t('warehouse.common.no'),
remark: pickField(row, 'remark', 'Remark') ?? '',
_raw: row,
}
}
async function loadWarehouses() {
warehouseLoading.value = true
try {
const { items } = await getWarehouseList({ page: 1, pageSize: 500 })
warehouseList.value = (items || []).map((w: any) => ({
id: Number(w.id || w.Id),
warehouseName: String(w.warehouseName || w.WarehouseName || `#${w.id}`),
warehouseCode: w.warehouseCode || w.WarehouseCode || '',
}))
if (!selectedWarehouseId.value && warehouseList.value.length) {
selectedWarehouseId.value = warehouseList.value[0].id
} else if (
selectedWarehouseId.value
&& !warehouseList.value.some((w) => w.id === selectedWarehouseId.value)
) {
selectedWarehouseId.value = warehouseList.value[0]?.id
}
} catch (e: any) {
warehouseList.value = []
$baseMessage(e?.message || t('warehouse.binLocation.message.warehouseLoadFailed'), 'error')
} finally {
warehouseLoading.value = false
}
}
async function loadList() {
if (!selectedWarehouseId.value) {
tableRows.value = []
totalCount.value = 0
return
}
listLoading.value = true
try {
const { items } = await getWarehouseBinLocationList({
warehouseId: selectedWarehouseId.value,
keyword: keyword.value || undefined,
isEnabled: enabledFilter.value,
})
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
totalCount.value = tableRows.value.length
const maxPage = Math.max(1, Math.ceil(totalCount.value / pageSize.value) || 1)
if (currentPage.value > maxPage) currentPage.value = maxPage
} catch (e: any) {
tableRows.value = []
totalCount.value = 0
$baseMessage(e?.message || t('warehouse.binLocation.message.loadFailed'), 'error')
} finally {
listLoading.value = false
}
}
function selectWarehouseFromKey(key?: string) {
if (!key || !key.startsWith('item-')) return
const id = Number(key.slice(5))
if (!Number.isFinite(id) || id <= 0) return
if (selectedWarehouseId.value === id) return
selectedWarehouseId.value = id
currentPage.value = 1
keyword.value = ''
loadList()
}
function handleWarehouseSelect(keys: string[]) {
selectWarehouseFromKey(keys[0])
}
function handleWarehouseNodeClick(key: string) {
selectWarehouseFromKey(key)
}
function handleSearch() {
currentPage.value = 1
loadList()
}
function handlePageChange(page: number) {
currentPage.value = page
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
}
function resetForm() {
formModel.id = 0
formModel.warehouseId = selectedWarehouseId.value || 0
formModel.binCode = ''
formModel.binName = ''
formModel.zoneCode = ''
formModel.isEnabled = true
formModel.remark = ''
}
function handleAdd() {
if (!hasPermission('Warehouse.BinLocation.Save')) return
if (!selectedWarehouseId.value) {
$baseMessage(t('warehouse.binLocation.message.selectWarehouse'), 'warning')
return
}
resetForm()
openModal(true)
}
function handleEdit(row: any) {
if (!hasPermission('Warehouse.BinLocation.Save')) return
const raw = row._raw || row
formModel.id = Number(pickField(raw, 'id', 'Id') ?? 0)
formModel.warehouseId = Number(pickField(raw, 'warehouseId', 'WarehouseId') ?? selectedWarehouseId.value)
formModel.binCode = String(pickField(raw, 'binCode', 'BinCode') ?? '')
formModel.binName = String(pickField(raw, 'binName', 'BinName') ?? '')
formModel.zoneCode = String(pickField(raw, 'zoneCode', 'ZoneCode') ?? '')
formModel.isEnabled = Boolean(pickField(raw, 'isEnabled', 'IsEnabled'))
formModel.remark = String(pickField(raw, 'remark', 'Remark') ?? '')
openModal(true)
}
async function handleSave() {
if (!formModel.warehouseId || !formModel.binCode.trim()) {
$baseMessage(t('warehouse.binLocation.message.required'), 'warning')
return
}
saving.value = true
try {
await saveWarehouseBinLocation({
id: formModel.id,
warehouseId: formModel.warehouseId,
binCode: formModel.binCode.trim(),
binName: formModel.binName.trim() || undefined,
zoneCode: formModel.zoneCode.trim() || undefined,
isEnabled: formModel.isEnabled,
remark: formModel.remark.trim() || undefined,
})
$baseMessage(t('warehouse.binLocation.message.saveSuccess'), 'success')
closeModal()
await loadList()
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.binLocation.message.saveFailed'), 'error')
} finally {
saving.value = false
}
}
function actionRenderer() {
if (!hasPermission('Warehouse.BinLocation.Save')) return ''
return wrapWarehouseActions([
renderWarehouseActionBtn('edit', t('warehouse.common.edit')),
])
}
function handleActionClick(row: any, _idx: number, action?: string) {
if (action === 'edit') handleEdit(row)
}
onMounted(async () => {
await loadWarehouses()
await loadList()
})
</script>
<template>
<div class="rbac-split-layout warehouse-bin-location-page">
<CustomLeftMenu
class="left-sidebar"
:tree-data="warehouseTreeData"
:selected-keys="selectedKeys"
v-model:expanded-keys="expandedKeys"
v-model:search-keyword="warehouseSearchKeyword"
:loading="warehouseLoading"
:search-placeholder="t('warehouse.binLocation.search.warehouse')"
:refresh-title="t('warehouse.binLocation.actions.refreshWarehouse')"
:loading-text="t('warehouse.binLocation.loading.warehouse')"
:show-add="false"
:show-node-actions="false"
:show-method="false"
:show-map-count="false"
:show-node-code="true"
:show-pagination="true"
:page-size="30"
@search="() => {}"
@refresh="loadWarehouses"
@select="handleWarehouseSelect"
@node-click="handleWarehouseNodeClick"
/>
<div class="right-panel">
<div class="warehouse-list-page-card bin-right-card">
<div v-if="!selectedWarehouse" class="panel-empty">
<p>{{ t('warehouse.binLocation.emptySelect') }}</p>
</div>
<template v-else>
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8">
<span class="selected-wh-name">{{ selectedWarehouse.warehouseName }}</span>
<a-input
v-model="keyword"
allow-clear
:placeholder="t('warehouse.binLocation.search.keyword')"
style="width: 200px"
@press-enter="handleSearch"
/>
<a-select
v-model="enabledFilter"
allow-clear
:placeholder="t('warehouse.binLocation.filter.isEnabled')"
style="width: 120px"
@change="handleSearch"
>
<a-option :value="true">{{ t('warehouse.common.yes') }}</a-option>
<a-option :value="false">{{ t('warehouse.common.no') }}</a-option>
</a-select>
<a-button type="primary" @click="handleSearch">{{ t('warehouse.common.search') }}</a-button>
<a-button
v-if="hasPermission('Warehouse.BinLocation.Save')"
type="primary"
@click="handleAdd"
>
{{ t('warehouse.binLocation.actions.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="pagedRows"
:height="tableHeight"
:loading="listLoading"
:stretch-columns="true"
show-action-column
:action-column-width="70"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
</div>
</template>
</div>
</div>
<ModalPro
:title="modalTitle"
:width="560"
:mask-closable="false"
@register="registerFormModal"
@cancel="closeModal"
>
<div class="wh-form-modal">
<div class="wh-form-info-bar">
<div>{{ t('warehouse.common.warehouse') }}:{{ selectedWarehouse?.warehouseName || '-' }}</div>
</div>
<a-form
:model="formModel"
layout="horizontal"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.binLocation.form.binCode')" required>
<a-input v-model="formModel.binCode" allow-clear />
</a-form-item>
<a-form-item :label="t('warehouse.binLocation.form.binName')">
<a-input v-model="formModel.binName" allow-clear />
</a-form-item>
<a-form-item :label="t('warehouse.binLocation.form.zoneCode')">
<a-input v-model="formModel.zoneCode" allow-clear />
</a-form-item>
<a-form-item :label="t('warehouse.binLocation.form.isEnabled')">
<a-switch v-model="formModel.isEnabled" />
</a-form-item>
<a-form-item :label="t('warehouse.binLocation.form.remark')">
<a-textarea v-model="formModel.remark" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</a-form>
</div>
<template #footer>
<a-space :size="8">
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">
{{ t('warehouse.common.save') }}
</a-button>
</a-space>
</template>
</ModalPro>
</div>
</template>
<style scoped lang="less">
@import '@/components/RbacSplit/rbac-split-layout.less';
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
.warehouse-bin-location-page {
padding: 16px;
background: #f0f3f4;
box-sizing: border-box;
}
.bin-right-card {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.panel-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-3);
padding: 48px 16px;
}
.selected-wh-name {
font-weight: 600;
color: var(--color-text-1);
margin-right: 4px;
}
</style>

View File

@ -8,14 +8,16 @@ 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,
saveWarehouseInbound,
} from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import { getWarehouseSkuList } from '@/api/warehouse'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import {
@ -25,6 +27,8 @@ import {
pickField,
resolveWarehouseLabel,
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
defineOptions({ name: 'WarehouseInboundNotice' })
@ -45,8 +49,31 @@ interface InboundBoxForm {
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 receiptStatus = ref<string>()
const summary = ref({ forecasted: 0, received: 0, completed: 0 })
@ -74,7 +101,12 @@ const detailVisible = ref(false)
const detailData = ref<any>(null)
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const [registerReceiveModal, { openModal: openReceiveModal, closeModal: closeReceiveModal }] = useModal() as any
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,
@ -89,6 +121,11 @@ const modalWarehouseName = computed(() => {
return wh?.label || ''
})
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),
@ -207,7 +244,7 @@ async function loadWarehouses() {
}
async function loadSkus() {
const { items } = await getWarehouseSkuList({ page: 1, pageSize: 500 }).catch(() => ({ items: [] }))
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),
@ -217,7 +254,9 @@ async function loadSkus() {
async function loadSummary() {
try {
const data = await getWarehouseInboundSummary({ warehouseId: warehouseId.value })
const data = await getWarehouseInboundSummary(withCustomerId({
warehouseId: warehouseId.value,
}))
summary.value = {
forecasted: data?.forecasted ?? data?.Forecasted ?? 0,
received: data?.received ?? data?.Received ?? 0,
@ -231,13 +270,13 @@ async function loadSummary() {
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseInboundList({
const { items, total } = await getWarehouseInboundList(withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
receiptStatus: receiptStatus.value,
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) {
@ -265,15 +304,129 @@ function handleSearch() {
async function showDetail(row: any) {
try {
detailData.value = await getWarehouseInboundDetail(row._raw?.id || row.id)
detailData.value = await getWarehouseInboundDetail(
row._raw?.id || row.id,
isAdmin.value ? resolveSelectedCustomerId() : undefined,
)
detailVisible.value = true
} catch (e: any) {
$baseMessage(e?.message || t('warehouse.inbound.message.detailFailed'), 'error')
}
}
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 handleAdd() {
if (!hasPermission('Warehouse.InboundNotice.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
formModel.id = 0
formModel.warehouseId = warehouseId.value
formModel.markReference = ''
@ -288,6 +441,7 @@ async function handleSave() {
$baseMessage(t('warehouse.inbound.message.required'), 'warning')
return
}
if (isAdmin.value && ensureMutationCustomerId() == null) return
const boxes = boxRows.value
.map((box) => ({
boxSeqNo: box.boxSeqNo,
@ -313,14 +467,14 @@ async function handleSave() {
saving.value = true
try {
await saveWarehouseInbound({
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')
closeModal()
await loadSummary()
@ -334,6 +488,7 @@ async function handleSave() {
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,
@ -347,15 +502,23 @@ function handleDelete(row: any) {
}
function actionRenderer(row: any) {
const parts = [`<button type="button" class="hot-action-btn" data-action="detail">${t('warehouse.common.detail')}</button>`]
if (hasPermission('Warehouse.InboundNotice.Delete') && row._status === 'forecasted') {
parts.push(`<button type="button" class="hot-action-btn" data-action="delete">${t('warehouse.common.delete')}</button>`)
const parts = [renderWarehouseActionBtn('detail', t('warehouse.common.detail'), 'view')]
if (
(hasPermission('Warehouse.InboundNotice.ConfirmReceive')
|| hasPermission('Warehouse.InboundNotice.Save'))
&& row._status !== 'completed'
) {
parts.push(renderWarehouseActionBtn('receive', t('warehouse.inbound.actions.confirmReceive')))
}
return parts.join('')
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 === 'detail') showDetail(row)
if (action === 'receive') openConfirmReceive(row)
if (action === 'delete') handleDelete(row)
}
@ -385,6 +548,16 @@ onMounted(async () => {
<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
@ -425,6 +598,7 @@ onMounted(async () => {
:loading="listLoading"
:stretch-columns="true"
show-action-column
:action-column-width="120"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
@ -433,11 +607,11 @@ onMounted(async () => {
<a-drawer v-model:visible="detailVisible" :title="t('warehouse.inbound.drawer.detail')" :width="640">
<template v-if="detailData">
<p>{{ t('warehouse.inbound.table.noticeNo') }}: {{ detailData.noticeNo }}</p>
<p>{{ t('warehouse.inbound.table.markReference') }}: {{ detailData.markReference || '-' }}</p>
<div v-for="box in detailData.boxes || []" :key="box.id" class="box-block">
<div class="box-title">{{ t('warehouse.inbound.box') }}: {{ box.markBoxNo || box.boxSeqNo }}</div>
<a-table :data="box.lines || []" :pagination="false" size="small">
<p>{{ t('warehouse.inbound.table.noticeNo') }}: {{ detailData.noticeNo || detailData.NoticeNo }}</p>
<p>{{ t('warehouse.inbound.table.markReference') }}: {{ detailData.markReference || detailData.MarkReference || '-' }}</p>
<div v-for="box in (detailData.boxes || detailData.Boxes || [])" :key="box.id || box.Id" class="box-block">
<div class="box-title">{{ t('warehouse.inbound.box') }}: {{ box.markBoxNo || box.MarkBoxNo || box.boxSeqNo || box.BoxSeqNo }}</div>
<a-table :data="box.lines || box.Lines || []" :pagination="false" size="small">
<template #columns>
<a-table-column :title="t('warehouse.inbound.line.skuCode')" data-index="skuCode" />
<a-table-column :title="t('warehouse.inbound.line.plannedQty')" data-index="plannedQty" />
@ -448,6 +622,61 @@ onMounted(async () => {
</template>
</a-drawer>
<ModalPro
:title="t('warehouse.inbound.modal.confirmReceive')"
:width="860"
:mask-closable="false"
:body-style="{ maxHeight: '70vh', overflow: 'auto' }"
@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="box-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 }">
<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" 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="t('warehouse.inbound.modal.add')"
:width="800"
@ -594,4 +823,7 @@ onMounted(async () => {
font-weight: 500;
margin-bottom: 8px;
}
.receive-box-block {
margin-bottom: 20px;
}
</style>

View File

@ -21,11 +21,21 @@ import {
pickField,
resolveWarehouseLabel,
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
defineOptions({ name: 'WarehouseInventory' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
resolveSelectedCustomerId,
} = useWarehouseAdminCustomer()
const listLoading = ref(false)
const tableRows = ref<any[]>([])
@ -43,7 +53,8 @@ const activeSku = ref<any>(null)
const [registerRelocateModal, { openModal: openRelocate, closeModal: closeRelocate }] = useModal() as any
const relocateSaving = ref(false)
const binOptions = ref<Array<{ label: string; value: number }>>([])
const sourceBinOptions = ref<Array<{ label: string; value: number; qty: number }>>([])
const targetBinOptions = ref<Array<{ label: string; value: number }>>([])
const relocateForm = ref({
sourceBinLocationId: undefined as number | undefined,
targetBinLocationId: undefined as number | undefined,
@ -56,6 +67,13 @@ const modalWarehouseName = computed(() => {
return resolveWarehouseLabel(row.warehouseId, warehouseNameMap.value)
})
const sourceMaxQty = computed(() => {
const sourceId = relocateForm.value.sourceBinLocationId
if (!sourceId) return activeSku.value?.totalQty || 1
const hit = sourceBinOptions.value.find((b) => b.value === sourceId)
return hit?.qty || 1
})
const hotColumns = computed(() => [
{ key: 'skuCode', label: t('warehouse.inventory.table.skuCode'), width: 140 },
{ key: 'skuName', label: t('warehouse.inventory.table.skuName'), width: 200 },
@ -91,31 +109,59 @@ async function loadWarehouses() {
}
}
async function loadBinOptions(whId?: number) {
async function loadTargetBinOptions(whId?: number) {
if (!whId) {
binOptions.value = []
targetBinOptions.value = []
return
}
try {
const { items } = await getWarehouseBinLocationList({ warehouseId: whId, isEnabled: true })
binOptions.value = (items || []).map((b: any) => ({
targetBinOptions.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 {
binOptions.value = []
targetBinOptions.value = []
}
}
async function loadSourceBinOptions(skuId?: number, whId?: number) {
if (!skuId || !whId) {
sourceBinOptions.value = []
return
}
try {
const data = await getWarehouseInventoryBinDetail(
skuId,
whId,
isAdmin.value ? resolveSelectedCustomerId() : undefined,
)
const items = data?.items ?? data?.Items ?? (Array.isArray(data) ? data : [])
sourceBinOptions.value = (items || []).map((item: Record<string, unknown>) => {
const normalized = normalizeListRow(item)
const qty = Number(pickField(normalized, 'qty', 'Qty') ?? 0)
const code = pickField(normalized, 'binCode', 'BinCode') ?? '-'
const name = pickField(normalized, 'binName', 'BinName')
return {
label: `${code}${name ? ` - ${name}` : ''}(${qty})`,
value: Number(pickField(normalized, 'binLocationId', 'BinLocationId') ?? 0),
qty,
}
}).filter((b: { value: number; qty: number }) => b.value > 0 && b.qty > 0)
} catch {
sourceBinOptions.value = []
}
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseInventoryList({
const { items, total } = await getWarehouseInventoryList(withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
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) {
@ -146,11 +192,16 @@ function handleSearch() {
async function showBinDetail(row: any) {
activeSku.value = row
try {
const data = await getWarehouseInventoryBinDetail(row.skuId, row.warehouseId)
const data = await getWarehouseInventoryBinDetail(
row.skuId,
row.warehouseId,
isAdmin.value ? resolveSelectedCustomerId() : undefined,
)
const items = data?.items ?? data?.Items ?? (Array.isArray(data) ? data : [])
binDetailRows.value = (items || []).map((item: Record<string, unknown>) => {
const normalized = normalizeListRow(item)
return {
binLocationId: pickField<number>(normalized, 'binLocationId', 'BinLocationId'),
binCode: pickField(normalized, 'binCode', 'BinCode') ?? '-',
binName: pickField(normalized, 'binName', 'BinName') ?? '',
qty: pickField<number>(normalized, 'qty', 'Qty') ?? 0,
@ -164,12 +215,28 @@ async function showBinDetail(row: any) {
async function openRelocateModal(row: any) {
if (!hasPermission('Warehouse.Inventory.Relocate')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
activeSku.value = row
relocateForm.value = { sourceBinLocationId: undefined, targetBinLocationId: undefined, qty: 1 }
await loadBinOptions(row.warehouseId)
await Promise.all([
loadSourceBinOptions(row.skuId, row.warehouseId),
loadTargetBinOptions(row.warehouseId),
])
if (sourceBinOptions.value.length === 1) {
relocateForm.value.sourceBinLocationId = sourceBinOptions.value[0].value
relocateForm.value.qty = Math.min(1, sourceBinOptions.value[0].qty) || 1
}
openRelocate(true)
}
function onSourceBinChange(binId?: number) {
relocateForm.value.sourceBinLocationId = binId
const hit = sourceBinOptions.value.find((b) => b.value === binId)
if (hit && relocateForm.value.qty > hit.qty) {
relocateForm.value.qty = hit.qty
}
}
async function submitRelocate() {
const row = activeSku.value
const f = relocateForm.value
@ -181,15 +248,19 @@ async function submitRelocate() {
$baseMessage(t('warehouse.inventory.message.sameBin'), 'warning')
return
}
if (f.qty > sourceMaxQty.value) {
$baseMessage(t('warehouse.inventory.message.qtyExceed'), 'warning')
return
}
relocateSaving.value = true
try {
await relocateWarehouseInventory({
await relocateWarehouseInventory(withCustomerId({
warehouseId: row.warehouseId,
skuId: row.skuId,
sourceBinLocationId: f.sourceBinLocationId,
targetBinLocationId: f.targetBinLocationId,
qty: f.qty,
})
}))
$baseMessage(t('warehouse.inventory.message.relocateSuccess'), 'success')
closeRelocate()
await loadList()
@ -202,12 +273,12 @@ async function submitRelocate() {
function actionRenderer() {
const parts: string[] = [
`<button type="button" class="hot-action-btn" data-action="detail">${t('warehouse.inventory.actions.binDetail')}</button>`,
renderWarehouseActionBtn('detail', t('warehouse.inventory.actions.binDetail'), 'view'),
]
if (hasPermission('Warehouse.Inventory.Relocate')) {
parts.push(`<button type="button" class="hot-action-btn" data-action="relocate">${t('warehouse.inventory.actions.relocate')}</button>`)
parts.push(renderWarehouseActionBtn('relocate', t('warehouse.inventory.actions.relocate')))
}
return parts.join('')
return wrapWarehouseActions(parts)
}
function handleActionClick(row: any, _idx: number, action?: string) {
@ -227,6 +298,16 @@ onMounted(async () => {
<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="handleSearch"
/>
<a-select
v-model="warehouseId"
allow-clear
@ -263,6 +344,7 @@ onMounted(async () => {
:loading="listLoading"
:stretch-columns="true"
show-action-column
:action-column-width="90"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
@ -301,21 +383,27 @@ onMounted(async () => {
<a-form-item :label="t('warehouse.inventory.form.sourceBin')" required>
<a-select
v-model="relocateForm.sourceBinLocationId"
:options="binOptions"
:options="sourceBinOptions"
allow-search
:placeholder="t('warehouse.inventory.form.selectBin')"
:placeholder="t('warehouse.inventory.form.selectSourceBin')"
@change="onSourceBinChange"
/>
</a-form-item>
<a-form-item :label="t('warehouse.inventory.form.targetBin')" required>
<a-select
v-model="relocateForm.targetBinLocationId"
:options="binOptions"
:options="targetBinOptions"
allow-search
:placeholder="t('warehouse.inventory.form.selectBin')"
:placeholder="t('warehouse.inventory.form.selectTargetBin')"
/>
</a-form-item>
<a-form-item :label="t('warehouse.inventory.form.qty')" required>
<a-input-number v-model="relocateForm.qty" :min="1" style="width: 100%" />
<a-input-number
v-model="relocateForm.qty"
:min="1"
:max="sourceMaxQty"
style="width: 100%"
/>
</a-form-item>
</a-form>
</div>

View File

@ -19,6 +19,8 @@ import {
resolveWarehouseLabel,
summarizeParcelItems,
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
defineOptions({ name: 'WarehouseParcel' })
@ -30,6 +32,13 @@ interface ParcelItemRow {
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const lifecycleStatus = ref<string>()
const keyword = ref('')
@ -128,13 +137,13 @@ async function loadWarehouses() {
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseParcelList({
const { items, total } = await getWarehouseParcelList(withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
lifecycleStatus: lifecycleStatus.value,
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) {
@ -164,6 +173,7 @@ function handleSearch() {
function handleAdd() {
if (!hasPermission('Warehouse.Parcel.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
formModel.id = 0
formModel.warehouseId = warehouseId.value
formModel.trackingNo = ''
@ -174,11 +184,38 @@ function handleAdd() {
openModal(true)
}
function handleEdit(row: any) {
if (!hasPermission('Warehouse.Parcel.Save') || row._status === 'stored') return
if (isAdmin.value && ensureMutationCustomerId() == null) return
const raw = row._raw || row
formModel.id = Number(pickField(raw, 'id', 'Id') ?? 0)
formModel.warehouseId = pickField<number>(raw, 'warehouseId', 'WarehouseId')
formModel.trackingNo = String(pickField(raw, 'trackingNo', 'TrackingNo') ?? '')
formModel.carrierName = String(pickField(raw, 'carrierName', 'CarrierName') ?? '')
formModel.lifecycleStatus = String(pickField(raw, 'lifecycleStatus', 'LifecycleStatus') ?? 'inTransit')
formModel.remark = String(pickField(raw, 'remark', 'Remark') ?? '')
const items = pickField(raw, 'items', 'Items')
if (Array.isArray(items) && items.length) {
itemRows.value = items.map((item: any) => ({
productName: String(pickField(item, 'productName', 'ProductName') ?? ''),
quantity: Number(pickField(item, 'quantity', 'Quantity') ?? 1) || 1,
remark: String(pickField(item, 'remark', 'Remark') ?? ''),
}))
if (!itemRows.value.some((r) => !r.productName.trim())) {
itemRows.value = [...itemRows.value, { productName: '', quantity: 1, remark: '' }]
}
} else {
resetItems()
}
openModal(true)
}
async function handleSave() {
if (!formModel.warehouseId || !formModel.trackingNo.trim()) {
$baseMessage(t('warehouse.parcel.message.required'), 'warning')
return
}
if (isAdmin.value && ensureMutationCustomerId() == null) return
const items = itemRows.value
.filter((row) => row.productName.trim())
.map((row) => ({
@ -192,7 +229,7 @@ async function handleSave() {
}
saving.value = true
try {
await saveWarehouseParcel({ ...formModel, items })
await saveWarehouseParcel(withCustomerId({ ...formModel, items }))
$baseMessage(t('warehouse.parcel.message.saveSuccess'), 'success')
closeModal()
await loadList()
@ -205,6 +242,7 @@ async function handleSave() {
function handleDelete(row: any) {
if (!hasPermission('Warehouse.Parcel.Delete')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
Modal.confirm({
title: t('warehouse.parcel.actions.delete'),
content: row.trackingNo,
@ -219,15 +257,16 @@ function handleDelete(row: any) {
function actionRenderer(row: any) {
const parts: string[] = []
if (hasPermission('Warehouse.Parcel.Save') && row._status !== 'stored') {
parts.push(`<button type="button" class="hot-action-btn" data-action="edit">${t('warehouse.common.edit')}</button>`)
parts.push(renderWarehouseActionBtn('edit', t('warehouse.common.edit')))
}
if (hasPermission('Warehouse.Parcel.Delete') && row._status !== 'stored') {
parts.push(`<button type="button" class="hot-action-btn" data-action="delete">${t('warehouse.common.delete')}</button>`)
parts.push(renderWarehouseActionBtn('delete', t('warehouse.common.delete')))
}
return parts.join('')
return wrapWarehouseActions(parts)
}
function handleActionClick(row: any, _i: number, action?: string) {
if (action === 'edit') handleEdit(row)
if (action === 'delete') handleDelete(row)
}
@ -250,6 +289,16 @@ onMounted(async () => {
<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="handleSearch"
/>
<a-select
v-model="warehouseId"
allow-clear
@ -289,6 +338,7 @@ onMounted(async () => {
:loading="listLoading"
:stretch-columns="true"
show-action-column
:action-column-width="90"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
@ -296,7 +346,7 @@ onMounted(async () => {
</div>
<ModalPro
:title="t('warehouse.parcel.modal.add')"
:title="formModel.id ? t('warehouse.common.edit') : t('warehouse.parcel.modal.add')"
:width="720"
:mask-closable="false"
:body-style="{ maxHeight: '70vh', overflow: 'auto' }"

View File

@ -17,11 +17,20 @@ import {
normalizeListRow,
pickField,
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
defineOptions({ name: 'WarehouseSku' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const {
isAdmin,
customerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const listLoading = ref(false)
const tableRows = ref<any[]>([])
@ -112,9 +121,6 @@ async function loadWarehouses() {
label: w.warehouseName || w.WarehouseName || `#${w.id}`,
value: Number(w.id),
}))
if (!selectedWarehouseId.value && warehouseOptions.value.length) {
selectedWarehouseId.value = warehouseOptions.value[0].value
}
} catch {
warehouseOptions.value = []
}
@ -123,11 +129,11 @@ async function loadWarehouses() {
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseSkuList({
const { items, total } = await getWarehouseSkuList(withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined,
})
}))
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
totalCount.value = Number(total || 0)
} catch (e: any) {
@ -157,12 +163,14 @@ function handleSearch() {
function handleAdd() {
if (!hasPermission('Warehouse.Sku.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
resetForm()
openModal(true)
}
function handleEdit(row: any) {
if (!hasPermission('Warehouse.Sku.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
const raw = row._raw || row
formModel.id = Number(pickField(raw, 'id', 'Id') ?? 0)
formModel.skuCode = String(pickField(raw, 'skuCode', 'SkuCode') ?? '')
@ -181,9 +189,11 @@ async function handleSave() {
$baseMessage(t('warehouse.sku.message.required'), 'warning')
return
}
const mutationCustomerId = isAdmin.value ? ensureMutationCustomerId() : undefined
if (isAdmin.value && mutationCustomerId == null) return
saving.value = true
try {
await saveWarehouseSku({ ...formModel })
await saveWarehouseSku(withCustomerId({ ...formModel }))
$baseMessage(t('warehouse.sku.message.saveSuccess'), 'success')
closeModal()
await loadList()
@ -196,6 +206,7 @@ async function handleSave() {
function handleDelete(row: any) {
if (!hasPermission('Warehouse.Sku.Delete')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
Modal.confirm({
title: t('warehouse.sku.actions.delete'),
content: t('warehouse.sku.message.deleteConfirm', { code: row.skuCode }),
@ -214,12 +225,12 @@ function handleDelete(row: any) {
function actionRenderer() {
const parts: string[] = []
if (hasPermission('Warehouse.Sku.Save')) {
parts.push(`<button type="button" class="hot-action-btn" data-action="edit">${t('warehouse.common.edit')}</button>`)
parts.push(renderWarehouseActionBtn('edit', t('warehouse.common.edit')))
}
if (hasPermission('Warehouse.Sku.Delete')) {
parts.push(`<button type="button" class="hot-action-btn" data-action="delete">${t('warehouse.common.delete')}</button>`)
parts.push(renderWarehouseActionBtn('delete', t('warehouse.common.delete')))
}
return parts.join('')
return wrapWarehouseActions(parts)
}
function handleActionClick(row: any, _idx: number, action?: string) {
@ -239,6 +250,16 @@ onMounted(async () => {
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8">
<a-select
v-if="isAdmin"
v-model="customerId"
allow-clear
allow-search
:placeholder="t('warehouse.common.selectCustomer')"
:options="customerOptions"
style="width: 220px"
@change="handleSearch"
/>
<a-select
v-model="selectedWarehouseId"
allow-clear
@ -279,6 +300,7 @@ onMounted(async () => {
:loading="listLoading"
:stretch-columns="true"
show-action-column
:action-column-width="90"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>

View File

@ -68,7 +68,12 @@ export default defineConfig(({ mode }) => {
secure: false,
bypass(req) {
const path = req.url?.split('?')[0] ?? '';
const warehouseSpaExact = ['/warehouse/parcel', '/warehouse/inventory', '/warehouse/sku'];
const warehouseSpaExact = [
'/warehouse/parcel',
'/warehouse/inventory',
'/warehouse/sku',
'/warehouse/binLocation',
];
if (warehouseSpaExact.some((p) => path === p || path === `${p}/`)) {
return '/index.html';
}
@ -304,6 +309,7 @@ export default defineConfig(({ mode }) => {
'/warehouse/inventory',
'/warehouse/sku',
'/warehouse/inboundNotice',
'/warehouse/binLocation',
// Xhs 相关前端路由(勿用 /xhs 前缀,否则 API 会被误判为 SPA)
'/xhs/startService',
'/xhs/analysis',