WMS主数据结构升级,支持仓库/物料类型等新实体

后端:重构WmsController/WmsService,新增仓库、库区、货位、物料类型等实体及其增删改查接口,支持批量生成网格货位、货位/容器锁定与启用、物料归档与批量清理。数据库模型新增Warehouse、MaterialType、StockEvent等表,调整索引和字段,支持SQLite自动补表/补列及数据迁移,接口兼容旧语义。

前端:wms.ts/types/wms.ts全面适配新结构,升级API路由和类型定义。WarehouseManagementView.vue重构,支持仓库、物料类型管理、货位网格可视化、库存事件展示等。WmsEntityDialog.vue支持新字段编辑及联动选择。新增AreaFacadePanel.vue实现库区网格立面可视化。wmsOptions.ts新增相关常量及UI优化。

兼容性:后端自动迁移旧数据,接口兼容旧绑定语义,提升灵活性、可维护性和可视化能力。
This commit is contained in:
2026-07-24 10:19:36 +08:00
parent ce66da772e
commit bdcd88608c
12 changed files with 1794 additions and 288 deletions
@@ -1,8 +1,10 @@
import http from './http'
import type {
WarehouseArea, Storage, Container, Material,
Warehouse, WarehouseArea, Storage, Container, Material, MaterialType,
ContainerLocation, ContainerMaterial, ContainerLocationHistory, ContainerMaterialHistory,
MasterDataPayload, StoragePayload, MaterialPayload, ContainerLocationPayload, ContainerMaterialPayload,
InventoryMaterialRow, StockEvent,
MasterDataPayload, AreaPayload, StoragePayload, ContainerPayload, MaterialPayload, MaterialTypePayload,
ContainerLocationPayload, BindMaterialPayload, GenerateBinsPayload,
WmsTransportRule, WmsTransportTask, TransportRulePayload, TransportRequestQuery, TransportCandidatePreview
} from '@/types/wms'
@@ -10,11 +12,25 @@ function q<T extends object>(params?: T) {
return { params }
}
export async function listAreas(keyword?: string) {
const { data } = await http.get<WarehouseArea[]>('/wms/areas', q({ q: keyword }))
export async function listWarehouses(keyword?: string) {
const { data } = await http.get<Warehouse[]>('/wms/warehouses', q({ q: keyword }))
return data
}
export async function saveArea(payload: MasterDataPayload) {
export async function saveWarehouse(payload: MasterDataPayload) {
const { data } = payload.id
? await http.put<Warehouse>(`/wms/warehouses/${payload.id}`, payload)
: await http.post<Warehouse>('/wms/warehouses', payload)
return data
}
export async function deleteWarehouse(id: string, version?: number) {
await http.delete(`/wms/warehouses/${id}`, q({ version }))
}
export async function listAreas(keyword?: string, warehouseId?: string) {
const { data } = await http.get<WarehouseArea[]>('/wms/areas', q({ q: keyword, warehouseId }))
return data
}
export async function saveArea(payload: AreaPayload) {
const { data } = payload.id
? await http.put<WarehouseArea>(`/wms/areas/${payload.id}`, payload)
: await http.post<WarehouseArea>('/wms/areas', payload)
@@ -24,8 +40,8 @@ export async function deleteArea(id: string, version?: number) {
await http.delete(`/wms/areas/${id}`, q({ version }))
}
export async function listStorages(keyword?: string) {
const { data } = await http.get<Storage[]>('/wms/storages', q({ q: keyword }))
export async function listStorages(keyword?: string, areaId?: string, locationKind?: string) {
const { data } = await http.get<Storage[]>('/wms/storages', q({ q: keyword, areaId, locationKind }))
return data
}
export async function saveStorage(payload: StoragePayload) {
@@ -37,12 +53,32 @@ export async function saveStorage(payload: StoragePayload) {
export async function deleteStorage(id: string, version?: number) {
await http.delete(`/wms/storages/${id}`, q({ version }))
}
export async function generateBins(areaId: string, payload: GenerateBinsPayload) {
const { data } = await http.post<{ created: number }>(`/wms/areas/${areaId}/generate-bins`, payload)
return data
}
export async function lockStorage(id: string, version?: number) {
const { data } = await http.post<Storage>(`/wms/storages/${id}/lock`, null, q({ version }))
return data
}
export async function unlockStorage(id: string, version?: number) {
const { data } = await http.post<Storage>(`/wms/storages/${id}/unlock`, null, q({ version }))
return data
}
export async function enableStorage(id: string, version?: number) {
const { data } = await http.post<Storage>(`/wms/storages/${id}/enable`, null, q({ version }))
return data
}
export async function disableStorage(id: string, version?: number) {
const { data } = await http.post<Storage>(`/wms/storages/${id}/disable`, null, q({ version }))
return data
}
export async function listContainers(keyword?: string) {
const { data } = await http.get<Container[]>('/wms/containers', q({ q: keyword }))
return data
}
export async function saveContainer(payload: MasterDataPayload) {
export async function saveContainer(payload: ContainerPayload) {
const { data } = payload.id
? await http.put<Container>(`/wms/containers/${payload.id}`, payload)
: await http.post<Container>('/wms/containers', payload)
@@ -52,8 +88,22 @@ export async function deleteContainer(id: string, version?: number) {
await http.delete(`/wms/containers/${id}`, q({ version }))
}
export async function listMaterials(keyword?: string) {
const { data } = await http.get<Material[]>('/wms/materials', q({ q: keyword }))
export async function listMaterialTypes(keyword?: string) {
const { data } = await http.get<MaterialType[]>('/wms/material-types', q({ q: keyword }))
return data
}
export async function saveMaterialType(payload: MaterialTypePayload) {
const { data } = payload.id
? await http.put<MaterialType>(`/wms/material-types/${payload.id}`, payload)
: await http.post<MaterialType>('/wms/material-types', payload)
return data
}
export async function deleteMaterialType(id: string, version?: number) {
await http.delete(`/wms/material-types/${id}`, q({ version }))
}
export async function listMaterials(params?: { q?: string; lifecycle?: string; onlyUnbound?: boolean; onlyBound?: boolean }) {
const { data } = await http.get<Material[]>('/wms/materials', q(params))
return data
}
export async function saveMaterial(payload: MaterialPayload) {
@@ -65,6 +115,13 @@ export async function saveMaterial(payload: MaterialPayload) {
export async function deleteMaterial(id: string, version?: number) {
await http.delete(`/wms/materials/${id}`, q({ version }))
}
export async function archiveMaterial(id: string, version?: number) {
const { data } = await http.post<Material>(`/wms/materials/${id}/archive`, null, q({ version }))
return data
}
export async function purgeArchivedMaterials(take = 200) {
await http.post('/wms/materials/purge-archived', null, q({ take }))
}
export async function listContainerLocations(params?: { locationType?: string; q?: string }) {
const { data } = await http.get<ContainerLocation[]>('/wms/container-locations', q(params))
@@ -78,18 +135,29 @@ export async function deleteContainerLocation(containerId: string, reason?: stri
await http.delete(`/wms/container-locations/${containerId}`, q({ reason }))
}
export async function listContainerMaterials(keyword?: string) {
const { data } = await http.get<ContainerMaterial[]>('/wms/container-materials', q({ q: keyword }))
export async function listContainerMaterials(params?: { q?: string; containerId?: string }) {
const { data } = await http.get<ContainerMaterial[]>('/wms/container-materials', q(params))
return data
}
export async function saveContainerMaterial(payload: ContainerMaterialPayload) {
const { data } = payload.id
? await http.put<ContainerMaterial>(`/wms/container-materials/${payload.id}`, payload)
: await http.post<ContainerMaterial>('/wms/container-materials', payload)
export async function bindMaterial(payload: BindMaterialPayload) {
const { data } = await http.post<ContainerMaterial>('/wms/container-materials/bind', payload)
return data
}
export async function deleteContainerMaterial(id: string, reason?: string) {
await http.delete(`/wms/container-materials/${id}`, q({ reason }))
/** @deprecated 使用 bindMaterial */
export async function saveContainerMaterial(payload: BindMaterialPayload) {
return bindMaterial(payload)
}
export async function deleteContainerMaterial(id: string, reason?: string, archive = false) {
await http.delete(`/wms/container-materials/${id}`, q({ reason, archive }))
}
export async function listInventoryMaterials(params?: { areaId?: string; storageId?: string; q?: string }) {
const { data } = await http.get<InventoryMaterialRow[]>('/wms/inventory/materials', q(params))
return data
}
export async function listStockEvents(params?: { eventType?: string; materialId?: string; containerId?: string }) {
const { data } = await http.get<StockEvent[]>('/wms/stock-events', q(params))
return data
}
export async function listContainerLocationHistory(containerId?: string) {
@@ -13,7 +13,7 @@ export interface EntityBase {
extend: string
}
export interface WarehouseArea extends EntityBase {
export interface Warehouse extends EntityBase {
code: string
name: string
type: string
@@ -21,12 +21,29 @@ export interface WarehouseArea extends EntityBase {
sortOrder: number
}
export interface WarehouseArea extends EntityBase {
warehouseId: string
code: string
name: string
type: string
layoutMode: string
state: string
enabled: boolean
sortOrder: number
}
export interface Storage extends EntityBase {
areaId: string
code: string
name: string
storageType: string
locationKind: string
columnNo: number
levelNo: number
depthNo: number
siteId: string
siteCode: string
barcode: string
capacity: number
status: string
usage: string
@@ -38,25 +55,43 @@ export interface Storage extends EntityBase {
}
export interface Container extends EntityBase {
areaId?: string
code: string
name: string
containerType: string
status: string
barcode: string
length: number
width: number
height: number
enabled: boolean
}
export interface MaterialType extends EntityBase {
code: string
name: string
spec: string
unit: string
category: string
barcodePrefix: string
enabled: boolean
}
export interface Material extends EntityBase {
code: string
name: string
typeCode: string
barcode: string
spec: string
unit: string
category: string
lifecycleStatus: string
unboundAt?: string
enabled: boolean
}
export type ContainerLocationType = 'Storage' | 'Car'
export type ContainerLocationStatus = 'Active' | 'Locked' | 'Exception'
export type ContainerMaterialStatus = 'Loaded' | 'Unloaded' | 'Adjusted' | 'Frozen'
export interface ContainerLocation extends EntityBase {
containerId: string
@@ -71,14 +106,64 @@ export interface ContainerLocation extends EntityBase {
export interface ContainerMaterial extends EntityBase {
containerId: string
materialId: string
/** @deprecated 绑定语义固定为 1UI 不展示数量 */
quantity: number
batchNo: string
serialNo: string
status: ContainerMaterialStatus
status: string
boundAt: string
loadedAt: string
unloadedAt?: string
}
export interface InventoryMaterialRow {
materialId: string
materialCode: string
materialName: string
materialBarcode: string
typeCode: string
containerId: string
containerCode: string
containerName: string
storageId?: string
storageCode: string
storageName: string
areaId?: string
areaCode: string
areaName: string
locationType: string
boundAt: string
}
export interface StockEvent {
id: string
eventType: string
materialId?: string
materialCode: string
materialName: string
materialBarcode: string
materialTypeCode: string
containerId?: string
containerCode: string
containerName: string
storageId?: string
storageCode: string
storageName: string
areaCode: string
fromStorageId?: string
fromStorageCode: string
fromStorageName: string
toStorageId?: string
toStorageCode: string
toStorageName: string
refType: string
refId?: string
refCode: string
operator: string
operatedAt: string
reason: string
}
export interface ContainerLocationHistory {
id: string
relationId?: string
@@ -129,6 +214,22 @@ export interface MasterDataPayload {
extend: string
}
export interface AreaPayload {
id?: string
version?: number
warehouseId?: string
code: string
name: string
type: string
layoutMode: string
state: string
enabled: boolean
sortOrder: number
isLock: boolean
remark: string
extend: string
}
export interface StoragePayload {
id?: string
version?: number
@@ -136,7 +237,13 @@ export interface StoragePayload {
code: string
name: string
storageType: string
locationKind: string
columnNo: number
levelNo: number
depthNo: number
siteId: string
siteCode: string
barcode: string
capacity: number
status: string
usage: string
@@ -150,7 +257,25 @@ export interface StoragePayload {
extend: string
}
export interface MaterialPayload {
export interface ContainerPayload {
id?: string
version?: number
areaId?: string
code: string
name: string
type: string
status: string
barcode: string
length: number
width: number
height: number
enabled: boolean
isLock: boolean
remark: string
extend: string
}
export interface MaterialTypePayload {
id?: string
version?: number
code: string
@@ -158,6 +283,24 @@ export interface MaterialPayload {
spec: string
unit: string
category: string
barcodePrefix: string
enabled: boolean
isLock: boolean
remark: string
extend: string
}
export interface MaterialPayload {
id?: string
version?: number
code: string
name: string
typeCode: string
barcode: string
spec: string
unit: string
category: string
lifecycleStatus: string
enabled: boolean
isLock: boolean
remark: string
@@ -179,17 +322,11 @@ export interface ContainerLocationPayload {
extend: string
}
export interface ContainerMaterialPayload {
export interface BindMaterialPayload {
id?: string
version?: number
containerId: string
materialId: string
quantity: number
batchNo: string
serialNo: string
status: ContainerMaterialStatus
loadedAt?: string
unloadedAt?: string
source: string
reason: string
isLock: boolean
@@ -197,6 +334,16 @@ export interface ContainerMaterialPayload {
extend: string
}
export interface GenerateBinsPayload {
columnFrom: number
columnTo: number
levelFrom: number
levelTo: number
depthFrom: number
depthTo: number
codePattern?: string
}
export type WmsTransportTriggerType = 'MaterialCall' | 'FinishedGoodsOffline' | 'AutoTransfer'
export type WmsTransportTaskStatus = 'Pending' | 'Reserved' | 'Dispatched' | 'InTransit' | 'Completed' | 'Failed' | 'Cancelled'
@@ -5,7 +5,7 @@
<div class="page-header">
<div>
<h2>仓储管理</h2>
<p>容器物料主数据以及容器位置和搬运任务配置</p>
<p>货位物料类型/实例绑定无数量在库与库存事件审计</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
</div>
@@ -25,6 +25,7 @@
<el-table-column prop="code" label="编码" width="120" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="type" label="类型" width="100" />
<el-table-column label="布局" width="80"><template #default="{ row }">{{ labelOf(AREA_LAYOUT_MODES, row.layoutMode) }}</template></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="70" />
<el-table-column prop="enabled" label="启用" width="80"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column prop="isLock" label="锁定" width="80"><template #default="{ row }"><el-tag :type="row.isLock ? 'warning' : 'info'" size="small">{{ row.isLock ? '是' : '否' }}</el-tag></template></el-table-column>
@@ -37,6 +38,7 @@
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索编码/名称/站点" clearable />
<el-button type="primary" @click="openStorage()">新增库位</el-button>
<el-button @click="openGenerateBins">批量生成网格</el-button>
<el-button type="danger" :disabled="batchStorages.batchCount === 0" @click="batchDeleteStorages">批量删除 ({{ batchStorages.batchCount }})</el-button>
</div>
<el-table :data="filteredStorages" border size="small" @selection-change="batchStorages.onSelectionChange">
@@ -46,7 +48,9 @@
<el-table-column label="库区" width="100"><template #default="{ row }">{{ areaName(row.areaId) }}</template></el-table-column>
<el-table-column label="类型" width="100"><template #default="{ row }">{{ labelOf(STORAGE_TYPES, row.storageType) }}</template></el-table-column>
<el-table-column prop="siteId" label="站点" width="90" />
<el-table-column label="状态" width="90"><template #default="{ row }">{{ labelOf(STORAGE_STATUSES, row.status) }}</template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="STORAGE_STATUS_TYPE[row.status] ?? 'info'" size="small">{{ labelOf(STORAGE_STATUSES, row.status) }}</el-tag></template></el-table-column>
<el-table-column label="种类" width="90"><template #default="{ row }">{{ labelOf(LOCATION_KINDS, row.locationKind) }}</template></el-table-column>
<el-table-column label="列/层/深" width="100"><template #default="{ row }">{{ row.locationKind === 'Grid' ? `${row.columnNo}/${row.levelNo}/${row.depthNo}` : '—' }}</template></el-table-column>
<el-table-column prop="priority" label="优先级" width="70" />
<el-table-column prop="zoneCode" label="区域" width="80" />
<el-table-column label="占用" width="70"><template #default="{ row }"><el-tag :type="isStorageOccupied(row.id) ? 'warning' : 'success'" size="small">{{ isStorageOccupied(row.id) ? '是' : '否' }}</el-tag></template></el-table-column>
@@ -76,9 +80,31 @@
</el-table>
</el-tab-pane>
<el-tab-pane label="物料类型" name="materialTypes">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索编码/名称" clearable />
<el-button type="primary" @click="openMaterialType()">新增类型</el-button>
<el-button type="danger" :disabled="batchMaterialTypes.batchCount === 0" @click="batchDeleteMaterialTypes">批量删除 ({{ batchMaterialTypes.batchCount }})</el-button>
</div>
<el-table :data="filteredMaterialTypes" border size="small" @selection-change="batchMaterialTypes.onSelectionChange">
<el-table-column type="selection" width="42" :selectable="batchMaterialTypes.selectable" />
<el-table-column prop="code" label="编码" width="120" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="spec" label="规格" />
<el-table-column prop="unit" label="单位" width="70" />
<el-table-column prop="category" label="类别" width="100" />
<el-table-column prop="barcodePrefix" label="条码前缀" width="100" />
<el-table-column prop="enabled" label="启用" width="70"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button size="small" link :disabled="row.isLock" @click="openMaterialType(row)">编辑</el-button><el-button size="small" link type="danger" :disabled="row.isLock" @click="removeMaterialType(row)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="物料" name="materials">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索编码/名称/规格" clearable />
<el-select v-model="materialLifecycleFilter" clearable placeholder="生命周期" style="width: 120px" @change="reloadMaterials">
<el-option v-for="o in MATERIAL_LIFECYCLES" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-button type="primary" @click="openMaterial()">新增物料</el-button>
<el-button type="danger" :disabled="batchMaterials.batchCount === 0" @click="batchDeleteMaterials">批量删除 ({{ batchMaterials.batchCount }})</el-button>
</div>
@@ -86,13 +112,25 @@
<el-table-column type="selection" width="42" :selectable="batchMaterials.selectable" />
<el-table-column prop="code" label="编码" width="120" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="typeCode" label="类型" width="100" />
<el-table-column prop="barcode" label="条码" width="120" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" />
<el-table-column prop="unit" label="单位" width="70" />
<el-table-column prop="category" label="类别" width="100" />
<el-table-column label="生命周期" width="90"><template #default="{ row }"><el-tag :type="row.lifecycleStatus === 'Active' ? 'success' : 'info'" size="small">{{ labelOf(MATERIAL_LIFECYCLES, row.lifecycleStatus) }}</el-tag></template></el-table-column>
<el-table-column prop="enabled" label="启用" width="70"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button size="small" link :disabled="row.isLock" @click="openMaterial(row)">编辑</el-button><el-button size="small" link type="danger" :disabled="row.isLock" @click="removeMaterial(row)">删除</el-button></template></el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" link :disabled="row.isLock" @click="openMaterial(row)">编辑</el-button>
<el-button v-if="row.lifecycleStatus === 'Active'" size="small" link :disabled="row.isLock" @click="archiveMaterial(row)">归档</el-button>
<el-button size="small" link type="danger" :disabled="row.isLock" @click="removeMaterial(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="库区立面" name="facade" lazy>
<AreaFacadePanel :areas="areas" :storages="storages" @edit="openStorage" />
</el-tab-pane>
</el-tabs>
</el-tab-pane>
@@ -120,18 +158,50 @@
<el-tab-pane label="容器物料" name="materials">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索" clearable />
<el-button type="primary" @click="openContainerMaterial()">装料/调整</el-button>
<el-button type="danger" :disabled="batchContainerMaterials.batchCount === 0" @click="batchUnloadMaterials">批量卸料 ({{ batchContainerMaterials.batchCount }})</el-button>
<el-button type="primary" @click="openContainerMaterial()">绑定物料</el-button>
<el-button type="danger" :disabled="batchContainerMaterials.batchCount === 0" @click="batchUnloadMaterials">批量解绑 ({{ batchContainerMaterials.batchCount }})</el-button>
</div>
<el-table :data="filteredContainerMaterials" border size="small" @selection-change="batchContainerMaterials.onSelectionChange">
<el-table-column type="selection" width="42" :selectable="batchContainerMaterials.selectable" />
<el-table-column label="容器" width="140"><template #default="{ row }">{{ containerName(row.containerId) }}</template></el-table-column>
<el-table-column label="物料" width="140"><template #default="{ row }">{{ materialName(row.materialId) }}</template></el-table-column>
<el-table-column prop="quantity" label="数量" width="90" />
<el-table-column prop="batchNo" label="批次" width="120" />
<el-table-column prop="serialNo" label="序列号" width="120" />
<el-table-column label="状态" width="90"><template #default="{ row }">{{ labelOf(CONTAINER_MATERIAL_STATUSES, row.status) }}</template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button size="small" link type="danger" :disabled="row.isLock" @click="removeContainerMaterial(row)">卸料</el-button></template></el-table-column>
<el-table-column label="物料" width="180"><template #default="{ row }">{{ materialName(row.materialId) }}</template></el-table-column>
<el-table-column prop="status" label="状态" width="90" />
<el-table-column prop="boundAt" label="绑定时间" width="170" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="danger" :disabled="row.isLock" @click="removeContainerMaterial(row, false)">解绑</el-button>
<el-button size="small" link type="warning" :disabled="row.isLock" @click="removeContainerMaterial(row, true)">解绑并归档</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="在库物料" name="inventory">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索物料/容器/货位" clearable />
<el-button @click="loadInventory">刷新</el-button>
</div>
<el-table :data="filteredInventory" border size="small">
<el-table-column prop="materialCode" label="物料编码" width="120" />
<el-table-column prop="materialName" label="物料名称" />
<el-table-column prop="containerCode" label="容器" width="100" />
<el-table-column prop="storageCode" label="货位" width="100" />
<el-table-column prop="areaCode" label="库区" width="90" />
<el-table-column prop="boundAt" label="绑定时间" width="170" />
</el-table>
</el-tab-pane>
<el-tab-pane label="库存事件" name="stockEvents">
<el-table :data="stockEvents" border size="small">
<el-table-column prop="eventType" label="事件" width="120" />
<el-table-column prop="materialCode" label="物料" width="120" />
<el-table-column prop="containerCode" label="容器" width="100" />
<el-table-column prop="storageCode" label="货位" width="100" />
<el-table-column prop="fromStorageCode" label="从" width="90" />
<el-table-column prop="toStorageCode" label="到" width="90" />
<el-table-column prop="operator" label="操作人" width="100" />
<el-table-column prop="operatedAt" label="时间" width="170" />
<el-table-column prop="reason" label="原因" show-overflow-tooltip />
</el-table>
</el-tab-pane>
@@ -177,7 +247,6 @@
<el-form-item label="触发类型"><el-select v-model="previewForm.triggerType"><el-option label="呼叫物料" value="MaterialCall" /><el-option label="成品下线" value="FinishedGoodsOffline" /><el-option label="自动转运" value="AutoTransfer" /></el-select></el-form-item>
<el-form-item label="呼叫站点"><el-input v-model="previewForm.requestSiteId" placeholder="站点 ID" /></el-form-item>
<el-form-item label="物料"><el-select v-model="previewForm.materialId" clearable><el-option v-for="m in materials" :key="m.id" :label="m.code" :value="m.id" /></el-select></el-form-item>
<el-form-item label="数量"><el-input-number v-model="previewForm.quantity" :min="0" :precision="4" /></el-form-item>
<el-form-item label="容器"><el-select v-model="previewForm.containerId" clearable><el-option v-for="c in containers" :key="c.id" :label="c.code" :value="c.id" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :loading="previewLoading" @click="runPreview">预览候选</el-button><el-button type="success" :loading="previewLoading" @click="generateFromPreview">生成任务</el-button></el-form-item>
</el-form>
@@ -187,7 +256,6 @@
<el-table-column prop="targetStorageCode" label="终点库位" width="110" />
<el-table-column prop="containerCode" label="容器" width="100" />
<el-table-column prop="materialCode" label="物料" width="100" />
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="score" label="评分" width="70" />
</el-table>
</el-tab-pane>
@@ -225,21 +293,52 @@
<WmsEntityDialog
v-model:visible="dialogVisible"
v-model:area-layout-mode="areaLayoutMode"
v-model:container-barcode="containerBarcode"
:kind="dialogKind"
:saving="saving"
:areas="areas"
:storages="storages"
:containers="containers"
:materials="materials"
:material-types="materialTypes"
:occupied-storage-ids="occupiedStorageIds"
:master-form="masterForm"
:storage-form="storageForm"
:material-form="materialForm"
:material-type-form="materialTypeForm"
:location-form="locationForm"
:container-material-form="containerMaterialForm"
:rule-form="ruleForm"
@save="submitDialog"
/>
<el-dialog v-model="generateBinsVisible" title="批量生成网格货位" width="480px">
<el-form label-width="100px">
<el-form-item label="库区">
<el-select v-model="generateBinsAreaId"><el-option v-for="a in areas" :key="a.id" :label="a.name" :value="a.id" /></el-select>
</el-form-item>
<el-form-item label="列范围">
<el-input-number v-model="generateBinsForm.columnFrom" :min="1" />
<span style="margin: 0 8px">~</span>
<el-input-number v-model="generateBinsForm.columnTo" :min="1" />
</el-form-item>
<el-form-item label="层范围">
<el-input-number v-model="generateBinsForm.levelFrom" :min="1" />
<span style="margin: 0 8px">~</span>
<el-input-number v-model="generateBinsForm.levelTo" :min="1" />
</el-form-item>
<el-form-item label="进深范围">
<el-input-number v-model="generateBinsForm.depthFrom" :min="1" />
<span style="margin: 0 8px">~</span>
<el-input-number v-model="generateBinsForm.depthTo" :min="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="generateBinsVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitGenerateBins">生成</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -249,16 +348,17 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import * as wmsApi from '@/api/wms'
import WmsEntityDialog, { type DialogKind } from '@/views/admin/warehouse/WmsEntityDialog.vue'
import AreaFacadePanel from '@/views/admin/warehouse/AreaFacadePanel.vue'
import { useWmsBatchActions } from '@/views/admin/warehouse/useWmsBatchActions'
import {
STORAGE_TYPES, STORAGE_STATUSES, CONTAINER_TYPES, CONTAINER_STATUSES,
CONTAINER_LOCATION_STATUSES, CONTAINER_MATERIAL_STATUSES,
AREA_LAYOUT_MODES, STORAGE_TYPES, STORAGE_STATUSES, STORAGE_STATUS_TYPE, LOCATION_KINDS,
CONTAINER_TYPES, CONTAINER_STATUSES, CONTAINER_LOCATION_STATUSES, MATERIAL_LIFECYCLES,
TRANSPORT_TRIGGER_LABELS, TRANSPORT_TASK_STATUS_TYPE, labelOf, truncate
} from '@/views/admin/warehouse/wmsOptions'
import type {
WarehouseArea, Storage, Container, Material, ContainerLocation, ContainerMaterial,
ContainerLocationHistory, ContainerMaterialHistory, MasterDataPayload, StoragePayload,
MaterialPayload, ContainerLocationPayload, ContainerMaterialPayload,
WarehouseArea, Storage, Container, Material, MaterialType, ContainerLocation, ContainerMaterial,
ContainerLocationHistory, ContainerMaterialHistory, InventoryMaterialRow, StockEvent,
MasterDataPayload, StoragePayload, MaterialPayload, MaterialTypePayload, ContainerLocationPayload, BindMaterialPayload,
WmsTransportRule, WmsTransportTask, TransportRulePayload, TransportRequestQuery, TransportCandidatePreview
} from '@/types/wms'
@@ -270,19 +370,28 @@ const keyword = ref('')
const locationFilter = ref('')
const ruleTriggerFilter = ref('')
const taskStatusFilter = ref('')
const materialLifecycleFilter = ref('Active')
const previewLoading = ref(false)
const taskStatuses = ['Pending', 'Reserved', 'Dispatched', 'InTransit', 'Completed', 'Failed', 'Cancelled']
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const dialogKind = ref<DialogKind>('area')
const areaLayoutMode = ref('Flat')
const containerBarcode = ref('')
const generateBinsVisible = ref(false)
const generateBinsAreaId = ref('')
const generateBinsForm = reactive({ columnFrom: 1, columnTo: 5, levelFrom: 1, levelTo: 3, depthFrom: 1, depthTo: 1 })
const areas = ref<WarehouseArea[]>([])
const storages = ref<Storage[]>([])
const containers = ref<Container[]>([])
const materialTypes = ref<MaterialType[]>([])
const materials = ref<Material[]>([])
const containerLocations = ref<ContainerLocation[]>([])
const containerMaterials = ref<ContainerMaterial[]>([])
const inventoryRows = ref<InventoryMaterialRow[]>([])
const stockEvents = ref<StockEvent[]>([])
const locationHistory = ref<ContainerLocationHistory[]>([])
const materialHistory = ref<ContainerMaterialHistory[]>([])
const transportRules = ref<WmsTransportRule[]>([])
@@ -292,14 +401,16 @@ const previewResult = ref<TransportCandidatePreview | null>(null)
const masterForm = reactive<MasterDataPayload>(baseMaster())
const storageForm = reactive<StoragePayload>(baseStorage())
const materialForm = reactive<MaterialPayload>(baseMaterial())
const materialTypeForm = reactive<MaterialTypePayload>(baseMaterialType())
const locationForm = reactive<ContainerLocationPayload>(baseLocation())
const containerMaterialForm = reactive<ContainerMaterialPayload>(baseContainerMaterial())
const containerMaterialForm = reactive<BindMaterialPayload>(baseContainerMaterial())
const ruleForm = reactive<TransportRulePayload>(baseRule())
const previewForm = reactive<TransportRequestQuery>({ triggerType: 'MaterialCall', priority: 0, reason: '' })
const batchAreas = useWmsBatchActions<WarehouseArea>()
const batchStorages = useWmsBatchActions<Storage>()
const batchContainers = useWmsBatchActions<Container>()
const batchMaterialTypes = useWmsBatchActions<MaterialType>()
const batchMaterials = useWmsBatchActions<Material>()
const batchRules = useWmsBatchActions<WmsTransportRule>()
const batchLocations = useWmsBatchActions<ContainerLocation>()
@@ -317,11 +428,18 @@ const occupiedStorageIds = computed(() => {
const filteredAreas = computed(() => filterRows(areas.value, keyword.value))
const filteredStorages = computed(() => filterRows(storages.value, keyword.value))
const filteredContainers = computed(() => filterRows(containers.value, keyword.value))
const filteredMaterialTypes = computed(() => filterRows(materialTypes.value, keyword.value))
const filteredMaterials = computed(() => filterRows(materials.value, keyword.value))
const filteredContainerLocations = computed(() => containerLocations.value.filter((r) =>
(!locationFilter.value || r.locationType === locationFilter.value) &&
(!keyword.value || `${r.locationCode} ${r.locationName}`.toLowerCase().includes(keyword.value.toLowerCase()))))
const filteredContainerMaterials = computed(() => filterRows(containerMaterials.value, keyword.value))
const filteredInventory = computed(() => {
if (!keyword.value) return inventoryRows.value
const s = keyword.value.toLowerCase()
return inventoryRows.value.filter((r) =>
`${r.materialCode} ${r.materialName} ${r.containerCode} ${r.storageCode} ${r.areaCode}`.toLowerCase().includes(s))
})
const filteredRules = computed(() => transportRules.value.filter((r) => !ruleTriggerFilter.value || r.triggerType === ruleTriggerFilter.value))
const historyRows = computed(() => [
...locationHistory.value.map((x) => ({ ...x, kind: '位置' })),
@@ -333,15 +451,24 @@ function taskSelectable(row: WmsTransportTask) {
return !row.isLock && ['Pending', 'Reserved', 'Failed'].includes(row.status)
}
async function reloadMaterials() {
materials.value = await wmsApi.listMaterials({
lifecycle: materialLifecycleFilter.value || undefined
})
}
async function loadAll() {
loading.value = true
try {
const [a, s, c, m, cl, cm, lh, mh] = await Promise.all([
wmsApi.listAreas(), wmsApi.listStorages(), wmsApi.listContainers(), wmsApi.listMaterials(),
wmsApi.listContainerLocations(), wmsApi.listContainerMaterials(), wmsApi.listContainerLocationHistory(), wmsApi.listContainerMaterialHistory()
const [a, s, c, mt, m, cl, cm, lh, mh, inv, ev] = await Promise.all([
wmsApi.listAreas(), wmsApi.listStorages(), wmsApi.listContainers(), wmsApi.listMaterialTypes(),
wmsApi.listMaterials({ lifecycle: materialLifecycleFilter.value || undefined }),
wmsApi.listContainerLocations(), wmsApi.listContainerMaterials(), wmsApi.listContainerLocationHistory(), wmsApi.listContainerMaterialHistory(),
wmsApi.listInventoryMaterials(), wmsApi.listStockEvents()
])
areas.value = a; storages.value = s; containers.value = c; materials.value = m
areas.value = a; storages.value = s; containers.value = c; materialTypes.value = mt; materials.value = m
containerLocations.value = cl; containerMaterials.value = cm; locationHistory.value = lh; materialHistory.value = mh
inventoryRows.value = inv; stockEvents.value = ev
await loadTransportRules()
await loadTransportTasks()
} finally {
@@ -349,18 +476,47 @@ async function loadAll() {
}
}
function openArea(row?: WarehouseArea) { dialogKind.value = 'area'; Object.assign(masterForm, baseMaster(row)); dialogVisible.value = true }
async function loadInventory() {
inventoryRows.value = await wmsApi.listInventoryMaterials()
stockEvents.value = await wmsApi.listStockEvents()
}
function openArea(row?: WarehouseArea) {
dialogKind.value = 'area'
Object.assign(masterForm, baseMaster(row))
areaLayoutMode.value = row?.layoutMode ?? 'Flat'
dialogVisible.value = true
}
function openStorage(row?: Storage) { dialogKind.value = 'storage'; Object.assign(storageForm, baseStorage(row)); dialogVisible.value = true }
function openContainer(row?: Container) {
dialogKind.value = 'container'
Object.assign(masterForm, baseMaster(row))
if (row) masterForm.type = row.containerType
containerBarcode.value = row?.barcode ?? ''
dialogVisible.value = true
}
function openMaterialType(row?: MaterialType) { dialogKind.value = 'materialType'; Object.assign(materialTypeForm, baseMaterialType(row)); dialogVisible.value = true }
function openMaterial(row?: Material) { dialogKind.value = 'material'; Object.assign(materialForm, baseMaterial(row)); dialogVisible.value = true }
function openLocation(row?: ContainerLocation) { dialogKind.value = 'location'; Object.assign(locationForm, baseLocation(row)); dialogVisible.value = true }
function openContainerMaterial(row?: ContainerMaterial) { dialogKind.value = 'containerMaterial'; Object.assign(containerMaterialForm, baseContainerMaterial(row)); dialogVisible.value = true }
function openRule(row?: WmsTransportRule) { dialogKind.value = 'rule'; Object.assign(ruleForm, baseRule(row)); dialogVisible.value = true }
function openGenerateBins() {
generateBinsAreaId.value = areas.value[0]?.id ?? ''
generateBinsVisible.value = true
}
async function submitGenerateBins() {
if (!generateBinsAreaId.value) { ElMessage.warning('请选择库区'); return }
saving.value = true
try {
const r = await wmsApi.generateBins(generateBinsAreaId.value, { ...generateBinsForm })
ElMessage.success(`已生成 ${r.created} 个货位`)
generateBinsVisible.value = false
await loadAll()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : String(e))
} finally { saving.value = false }
}
async function loadTransportRules() { transportRules.value = await wmsApi.listTransportRules() }
async function loadTransportTasks() { transportTasks.value = await wmsApi.listTransportTasks({ status: taskStatusFilter.value || undefined }) }
@@ -388,10 +544,30 @@ async function submitDialog() {
saving.value = true
const kind = dialogKind.value
try {
JSON.parse((kind === 'storage' ? storageForm : kind === 'material' ? materialForm : kind === 'rule' ? ruleForm : masterForm).extend || '{}')
if (kind === 'area') await wmsApi.saveArea(masterForm)
else if (kind === 'container') await wmsApi.saveContainer(masterForm)
const extendSrc = kind === 'storage' ? storageForm
: kind === 'material' ? materialForm
: kind === 'materialType' ? materialTypeForm
: kind === 'rule' ? ruleForm
: masterForm
JSON.parse(extendSrc.extend || '{}')
if (kind === 'area') {
await wmsApi.saveArea({
...masterForm,
warehouseId: undefined,
layoutMode: areaLayoutMode.value,
state: 'Default'
})
}
else if (kind === 'container') {
await wmsApi.saveContainer({
...masterForm,
barcode: containerBarcode.value,
length: 0, width: 0, height: 0,
status: 'EmptyMaterial'
})
}
else if (kind === 'storage') await wmsApi.saveStorage(storageForm)
else if (kind === 'materialType') await wmsApi.saveMaterialType(materialTypeForm)
else if (kind === 'material') await wmsApi.saveMaterial(materialForm)
else if (kind === 'location') await wmsApi.saveContainerLocation(locationForm)
else if (kind === 'rule') {
@@ -400,7 +576,7 @@ async function submitDialog() {
JSON.parse(ruleForm.taskOptionsJson || '{}')
await wmsApi.saveTransportRule(ruleForm)
}
else await wmsApi.saveContainerMaterial(containerMaterialForm)
else await wmsApi.bindMaterial(containerMaterialForm)
ElMessage.success('已保存')
dialogVisible.value = false
await loadAll()
@@ -419,9 +595,20 @@ async function confirmDelete(message: string) {
async function removeArea(row: WarehouseArea) { await confirmDelete('删除该库区?'); await wmsApi.deleteArea(row.id, row.version); await loadAll() }
async function removeStorage(row: Storage) { await confirmDelete('删除该库位?'); await wmsApi.deleteStorage(row.id, row.version); await loadAll() }
async function removeContainer(row: Container) { await confirmDelete('删除该容器?'); await wmsApi.deleteContainer(row.id, row.version); await loadAll() }
async function removeMaterialType(row: MaterialType) { await confirmDelete('删除该物料类型?'); await wmsApi.deleteMaterialType(row.id, row.version); await loadAll() }
async function removeMaterial(row: Material) { await confirmDelete('删除该物料?'); await wmsApi.deleteMaterial(row.id, row.version); await loadAll() }
async function archiveMaterial(row: Material) {
await confirmDelete(`归档物料 ${row.code}?归档后仍保留主数据,可后续 purge。`)
await wmsApi.archiveMaterial(row.id, row.version)
ElMessage.success('已归档')
await loadAll()
}
async function removeLocation(row: ContainerLocation) { await confirmDelete('解绑该容器位置?'); await wmsApi.deleteContainerLocation(row.containerId); await loadAll() }
async function removeContainerMaterial(row: ContainerMaterial) { await confirmDelete('卸载该容器物料?'); await wmsApi.deleteContainerMaterial(row.id); await loadAll() }
async function removeContainerMaterial(row: ContainerMaterial, archive = false) {
await confirmDelete(archive ? '解绑并归档该物料?' : '解绑该容器物料?')
await wmsApi.deleteContainerMaterial(row.id, undefined, archive)
await loadAll()
}
async function removeRule(row: WmsTransportRule) { await confirmDelete('删除该规则?'); await wmsApi.deleteTransportRule(row.id, row.version); await loadTransportRules() }
async function reserveTask(row: WmsTransportTask) { await wmsApi.reserveTransportTask(row.id); ElMessage.success('已预占'); await loadTransportTasks() }
async function completeTask(row: WmsTransportTask) { await wmsApi.completeTransportTask(row.id); ElMessage.success('已完成'); await loadAll() }
@@ -439,6 +626,10 @@ async function batchDeleteContainers() {
const r = await batchContainers.runBatch({ actionLabel: '批量删除', confirmMessage: '确认删除选中的 {n} 个容器?', run: (row) => wmsApi.deleteContainer(row.id, row.version) })
if (r.ok > 0) await loadAll()
}
async function batchDeleteMaterialTypes() {
const r = await batchMaterialTypes.runBatch({ actionLabel: '批量删除', confirmMessage: '确认删除选中的 {n} 个物料类型?', run: (row) => wmsApi.deleteMaterialType(row.id, row.version) })
if (r.ok > 0) await loadAll()
}
async function batchDeleteMaterials() {
const r = await batchMaterials.runBatch({ actionLabel: '批量删除', confirmMessage: '确认删除选中的 {n} 个物料?', run: (row) => wmsApi.deleteMaterial(row.id, row.version) })
if (r.ok > 0) await loadAll()
@@ -500,13 +691,15 @@ function baseMaster(row?: WarehouseArea | Container): MasterDataPayload {
function baseStorage(row?: Storage): StoragePayload {
return {
...baseCommon(row), areaId: row?.areaId ?? areas.value[0]?.id ?? '', code: row?.code ?? '', name: row?.name ?? '',
storageType: row?.storageType ?? 'Storage', siteId: row?.siteId ?? '', capacity: row?.capacity ?? 0,
status: row?.status ?? 'Available', usage: row?.usage ?? '', priority: row?.priority ?? 0, zoneCode: row?.zoneCode ?? '',
storageType: row?.storageType ?? 'Storage', locationKind: row?.locationKind ?? 'Station',
columnNo: row?.columnNo ?? 0, levelNo: row?.levelNo ?? 1, depthNo: row?.depthNo ?? 1,
siteId: row?.siteId ?? '', siteCode: row?.siteCode ?? row?.siteId ?? '', barcode: row?.barcode ?? '', capacity: 1,
status: row?.status ?? 'Empty', usage: row?.usage ?? '', priority: row?.priority ?? 0, zoneCode: row?.zoneCode ?? '',
allowInbound: row?.allowInbound ?? true, allowOutbound: row?.allowOutbound ?? true, enabled: row?.enabled ?? true
}
}
function baseRule(row?: WmsTransportRule): TransportRulePayload {
const defaultSource = JSON.stringify({ schemaVersion: 1, storage: { requireOccupied: true, allowOutbound: true }, container: { excludeReserved: true }, material: { statusIn: ['Loaded'] }, ranking: [{ field: 'enteredAt', direction: 'asc' }] }, null, 2)
const defaultSource = JSON.stringify({ schemaVersion: 1, storage: { requireOccupied: true, allowOutbound: true }, container: { excludeReserved: true }, material: {}, ranking: [{ field: 'enteredAt', direction: 'asc' }] }, null, 2)
const defaultTarget = JSON.stringify({ schemaVersion: 1, storage: { requireEmpty: true, allowInbound: true, requireSiteId: true }, ranking: [{ field: 'storagePriority', direction: 'desc' }] }, null, 2)
const defaultOptions = JSON.stringify({ autoReserve: true, requireManualConfirm: false, allowSameStorage: false, reservationTtlSeconds: 600, dispatchMode: 'Manual', maxCandidateCount: 20 }, null, 2)
return {
@@ -517,14 +710,31 @@ function baseRule(row?: WmsTransportRule): TransportRulePayload {
taskOptionsJson: row?.taskOptionsJson ?? defaultOptions
}
}
function baseMaterialType(row?: MaterialType): MaterialTypePayload {
return {
...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '',
spec: row?.spec ?? '', unit: row?.unit ?? 'pcs', category: row?.category ?? '',
barcodePrefix: row?.barcodePrefix ?? '', enabled: row?.enabled ?? true
}
}
function baseMaterial(row?: Material): MaterialPayload {
return { ...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '', spec: row?.spec ?? '', unit: row?.unit ?? 'pcs', category: row?.category ?? '', enabled: row?.enabled ?? true }
return {
...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '', typeCode: row?.typeCode ?? materialTypes.value[0]?.code ?? '',
barcode: row?.barcode ?? '', spec: row?.spec ?? '', unit: row?.unit ?? 'pcs', category: row?.category ?? '',
lifecycleStatus: row?.lifecycleStatus ?? 'Active', enabled: row?.enabled ?? true
}
}
function baseLocation(row?: ContainerLocation): ContainerLocationPayload {
return { ...baseCommon(row), containerId: row?.containerId ?? containers.value[0]?.id ?? '', locationType: row?.locationType ?? 'Storage', locationId: row?.locationId ?? '', status: row?.status ?? 'Active', enteredAt: row?.enteredAt, source: 'Manual', reason: '' }
}
function baseContainerMaterial(row?: ContainerMaterial): ContainerMaterialPayload {
return { ...baseCommon(row), containerId: row?.containerId ?? containers.value[0]?.id ?? '', materialId: row?.materialId ?? materials.value[0]?.id ?? '', quantity: row?.quantity ?? 1, batchNo: row?.batchNo ?? '', serialNo: row?.serialNo ?? '', status: row?.status ?? 'Loaded', loadedAt: row?.loadedAt, source: 'Manual', reason: '' }
function baseContainerMaterial(row?: ContainerMaterial): BindMaterialPayload {
return {
...baseCommon(row),
containerId: row?.containerId ?? containers.value[0]?.id ?? '',
materialId: row?.materialId ?? materials.value[0]?.id ?? '',
source: 'Manual',
reason: ''
}
}
onMounted(loadAll)
@@ -0,0 +1,159 @@
<template>
<div class="facade-panel">
<div class="toolbar">
<el-select v-model="areaId" placeholder="选择库区" style="width: 200px">
<el-option v-for="a in areas" :key="a.id" :label="`${a.code} ${a.name}`" :value="a.id" />
</el-select>
<el-select v-model="depthNo" placeholder="进深" style="width: 120px">
<el-option v-for="d in depthOptions" :key="d" :label="`进深 ${d}`" :value="d" />
</el-select>
<span class="hint">×列立面 Grid 货位点击单元格可编辑</span>
</div>
<div v-if="!areaId" class="empty">请选择库区</div>
<div v-else-if="columns.length === 0" class="empty">该库区暂无网格货位可先批量生成网格</div>
<div v-else class="facade-scroll">
<table class="facade-table">
<thead>
<tr>
<th class="corner">\</th>
<th v-for="c in columns" :key="c">{{ c }}</th>
</tr>
</thead>
<tbody>
<tr v-for="lv in levelsDesc" :key="lv">
<th>{{ lv }}</th>
<td
v-for="col in columns"
:key="`${lv}-${col}`"
:class="cellClass(cellAt(col, lv))"
:title="cellTitle(cellAt(col, lv))"
@click="onCellClick(cellAt(col, lv))"
>
<template v-if="cellAt(col, lv)">
<div class="code">{{ cellAt(col, lv)!.code }}</div>
<div class="status">{{ statusLabel(cellAt(col, lv)!.status) }}</div>
</template>
<span v-else class="miss"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { WarehouseArea, Storage } from '@/types/wms'
import { STORAGE_STATUSES, labelOf } from './wmsOptions'
const props = defineProps<{
areas: WarehouseArea[]
storages: Storage[]
}>()
const emit = defineEmits<{
edit: [Storage]
}>()
const areaId = ref('')
const depthNo = ref(1)
watch(
() => props.areas,
(list) => {
if (!areaId.value && list.length) areaId.value = list[0].id
},
{ immediate: true }
)
const gridBins = computed(() =>
props.storages.filter(
(s) => s.areaId === areaId.value && s.locationKind === 'Grid' && s.depthNo === depthNo.value
)
)
const depthOptions = computed(() => {
const set = new Set(
props.storages
.filter((s) => s.areaId === areaId.value && s.locationKind === 'Grid')
.map((s) => s.depthNo || 1)
)
const arr = [...set].sort((a, b) => a - b)
return arr.length ? arr : [1]
})
watch(depthOptions, (opts) => {
if (!opts.includes(depthNo.value)) depthNo.value = opts[0]
})
const columns = computed(() => {
const set = new Set(gridBins.value.map((s) => s.columnNo || 0).filter((n) => n > 0))
return [...set].sort((a, b) => a - b)
})
const levelsDesc = computed(() => {
const set = new Set(gridBins.value.map((s) => s.levelNo || 1))
return [...set].sort((a, b) => b - a)
})
const cellMap = computed(() => {
const map = new Map<string, Storage>()
for (const s of gridBins.value) {
map.set(`${s.columnNo}:${s.levelNo}`, s)
}
return map
})
function cellAt(col: number, level: number) {
return cellMap.value.get(`${col}:${level}`)
}
function statusLabel(status: string) {
return labelOf(STORAGE_STATUSES, status)
}
function cellClass(s?: Storage) {
if (!s) return 'cell empty-slot'
if (!s.enabled || s.status === 'Disabled') return 'cell disabled'
if (s.status === 'FullContainer') return 'cell full'
if (s.status === 'EmptyContainer') return 'cell empty-c'
return 'cell vacant'
}
function cellTitle(s?: Storage) {
if (!s) return '无货位'
return `${s.code} · ${s.name}\n列${s.columnNo}${s.levelNo}${s.depthNo}\n${statusLabel(s.status)}`
}
function onCellClick(s?: Storage) {
if (s) emit('edit', s)
}
</script>
<style scoped>
.facade-panel { min-height: 280px; }
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.hint { font-size: 12px; color: var(--mg-text-muted, #888); }
.empty { padding: 48px; text-align: center; color: var(--mg-text-muted, #888); }
.facade-scroll { overflow: auto; max-height: 520px; border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 6px; }
.facade-table { border-collapse: collapse; font-size: 12px; min-width: 100%; }
.facade-table th,
.facade-table td { border: 1px solid rgba(0, 0, 0, 0.08); padding: 6px 8px; text-align: center; min-width: 72px; }
.facade-table thead th,
.facade-table tbody th { background: rgba(0, 0, 0, 0.03); font-weight: 600; position: sticky; }
.facade-table thead th { top: 0; z-index: 2; }
.facade-table tbody th { left: 0; z-index: 1; }
.corner { left: 0; z-index: 3 !important; }
.cell { cursor: pointer; transition: background 0.15s; }
.cell:hover { outline: 2px solid rgba(64, 158, 255, 0.45); outline-offset: -2px; }
.cell .code { font-weight: 600; line-height: 1.2; }
.cell .status { margin-top: 2px; opacity: 0.75; font-size: 11px; }
.miss { color: #bbb; }
.vacant { background: #e8f5e9; }
.empty-c { background: #fff8e1; }
.full { background: #ffebee; }
.disabled { background: #eceff1; color: #90a4ae; cursor: default; }
.empty-slot { background: #fafafa; cursor: default; }
</style>
@@ -13,6 +13,7 @@
<el-form-item label="编码" prop="code"><el-input v-model="masterForm.code" :disabled="isEditing" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="masterForm.name" /></el-form-item>
<el-form-item label="类型"><el-select v-model="masterForm.type"><el-option v-for="o in AREA_TYPES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="布局"><el-select v-model="areaLayoutMode"><el-option v-for="o in AREA_LAYOUT_MODES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="排序"><el-input-number v-model="masterForm.sortOrder" :min="0" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="masterForm.enabled" /></el-form-item>
</template>
@@ -21,7 +22,7 @@
<el-form-item label="编码" prop="code"><el-input v-model="masterForm.code" :disabled="isEditing" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="masterForm.name" /></el-form-item>
<el-form-item label="容器类型"><el-select v-model="masterForm.type"><el-option v-for="o in CONTAINER_TYPES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="状态"><el-select v-model="masterForm.status"><el-option v-for="o in CONTAINER_STATUSES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="条码"><el-input v-model="containerBarcode" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="masterForm.enabled" /></el-form-item>
</template>
@@ -29,7 +30,13 @@
<el-form-item label="库区"><el-select v-model="storageForm.areaId"><el-option v-for="a in areas" :key="a.id" :label="a.name" :value="a.id" /></el-select></el-form-item>
<el-form-item label="编码" prop="code"><el-input v-model="storageForm.code" :disabled="isEditing" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="storageForm.name" /></el-form-item>
<el-form-item label="货位种类"><el-select v-model="storageForm.locationKind"><el-option v-for="o in LOCATION_KINDS" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="库位类型"><el-select v-model="storageForm.storageType"><el-option v-for="o in STORAGE_TYPES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<template v-if="storageForm.locationKind === 'Grid'">
<el-form-item label="列"><el-input-number v-model="storageForm.columnNo" :min="0" /></el-form-item>
<el-form-item label="层"><el-input-number v-model="storageForm.levelNo" :min="1" /></el-form-item>
<el-form-item label="进深"><el-input-number v-model="storageForm.depthNo" :min="1" /></el-form-item>
</template>
<el-form-item label="站点">
<el-select
v-model="storageForm.siteId"
@@ -39,27 +46,52 @@
placeholder="搜索站点 ID 或名称"
:filter-method="onSiteFilter"
class="wms-field-control"
@change="onSiteChange"
>
<el-option v-for="s in displayedSites" :key="s.id" :label="s.label" :value="s.id" />
</el-select>
<span v-if="sitesLoadError" class="field-hint field-hint-warn">{{ sitesLoadError }}</span>
<span v-else class="field-hint">主数据来自 SimpleLite保存值为站点 id</span>
</el-form-item>
<el-form-item label="状态"><el-select v-model="storageForm.status"><el-option v-for="o in STORAGE_STATUSES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="条码"><el-input v-model="storageForm.barcode" /></el-form-item>
<el-form-item label="优先级"><el-input-number v-model="storageForm.priority" :min="0" /></el-form-item>
<el-form-item label="区域"><el-input v-model="storageForm.zoneCode" /></el-form-item>
<el-form-item label="容量"><el-input-number v-model="storageForm.capacity" :min="0" /><span class="field-hint">展示用途业务占用按 1 库位 1 容器</span></el-form-item>
<el-form-item label="允许入库"><el-switch v-model="storageForm.allowInbound" /></el-form-item>
<el-form-item label="允许出库"><el-switch v-model="storageForm.allowOutbound" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="storageForm.enabled" /></el-form-item>
</template>
<template v-else-if="kind === 'materialType'">
<el-form-item label="编码" prop="code"><el-input v-model="materialTypeForm.code" :disabled="isEditing" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="materialTypeForm.name" /></el-form-item>
<el-form-item label="规格"><el-input v-model="materialTypeForm.spec" /></el-form-item>
<el-form-item label="单位"><el-select v-model="materialTypeForm.unit" filterable allow-create><el-option v-for="o in MATERIAL_UNITS" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="类别"><el-input v-model="materialTypeForm.category" /></el-form-item>
<el-form-item label="条码前缀"><el-input v-model="materialTypeForm.barcodePrefix" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="materialTypeForm.enabled" /></el-form-item>
</template>
<template v-else-if="kind === 'material'">
<el-form-item label="编码" prop="code"><el-input v-model="materialForm.code" :disabled="isEditing" /></el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="materialForm.name" /></el-form-item>
<el-form-item label="物料类型">
<el-select
v-model="materialForm.typeCode"
filterable
clearable
allow-create
placeholder="选择或输入类型编码"
class="wms-field-control"
@change="onMaterialTypeChange"
>
<el-option v-for="t in materialTypes" :key="t.id" :label="`${t.code} ${t.name}`" :value="t.code" />
</el-select>
</el-form-item>
<el-form-item label="条码"><el-input v-model="materialForm.barcode" /></el-form-item>
<el-form-item label="规格"><el-input v-model="materialForm.spec" /></el-form-item>
<el-form-item label="单位"><el-select v-model="materialForm.unit" filterable allow-create><el-option v-for="o in MATERIAL_UNITS" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="类别"><el-input v-model="materialForm.category" /></el-form-item>
<el-form-item label="生命周期"><el-select v-model="materialForm.lifecycleStatus"><el-option v-for="o in MATERIAL_LIFECYCLES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="启用"><el-switch v-model="materialForm.enabled" /></el-form-item>
</template>
@@ -78,11 +110,9 @@
<template v-else-if="kind === 'containerMaterial'">
<el-form-item label="容器"><el-select v-model="containerMaterialForm.containerId" :disabled="isEditing"><el-option v-for="c in containers" :key="c.id" :label="c.code" :value="c.id" /></el-select></el-form-item>
<el-form-item label="物料"><el-select v-model="containerMaterialForm.materialId" :disabled="isEditing"><el-option v-for="m in materials" :key="m.id" :label="m.code" :value="m.id" /></el-select></el-form-item>
<el-form-item label="数量" prop="quantity"><el-input-number v-model="containerMaterialForm.quantity" :min="0.0001" :precision="4" /></el-form-item>
<el-form-item label="批次"><el-input v-model="containerMaterialForm.batchNo" :disabled="isEditing" /></el-form-item>
<el-form-item label="序列号"><el-input v-model="containerMaterialForm.serialNo" :disabled="isEditing" /></el-form-item>
<el-form-item label="状态"><el-select v-model="containerMaterialForm.status"><el-option v-for="o in CONTAINER_MATERIAL_STATUSES" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item>
<el-form-item label="物料"><el-select v-model="containerMaterialForm.materialId" filterable :disabled="isEditing"><el-option v-for="m in materials" :key="m.id" :label="`${m.code} ${m.name}`" :value="m.id" /></el-select></el-form-item>
<el-form-item label="原因" prop="reason"><el-input v-model="containerMaterialForm.reason" placeholder="绑定原因" /></el-form-item>
<p class="field-hint">按单个物料实体绑定无数量</p>
</template>
<template v-else-if="kind === 'rule'">
@@ -117,17 +147,17 @@
import { computed, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type {
WarehouseArea, Storage, Container, Material,
MasterDataPayload, StoragePayload, MaterialPayload,
ContainerLocationPayload, ContainerMaterialPayload, TransportRulePayload
WarehouseArea, Storage, Container, Material, MaterialType,
MasterDataPayload, StoragePayload, MaterialPayload, MaterialTypePayload,
ContainerLocationPayload, BindMaterialPayload, TransportRulePayload
} from '@/types/wms'
import {
AREA_TYPES, STORAGE_TYPES, STORAGE_STATUSES, CONTAINER_TYPES, CONTAINER_STATUSES,
CONTAINER_LOCATION_STATUSES, CONTAINER_MATERIAL_STATUSES, MATERIAL_UNITS
AREA_TYPES, AREA_LAYOUT_MODES, STORAGE_TYPES, LOCATION_KINDS, CONTAINER_TYPES,
CONTAINER_LOCATION_STATUSES, MATERIAL_UNITS, MATERIAL_LIFECYCLES
} from './wmsOptions'
import { useSimpleLiteSites } from './useSimpleLiteSites'
export type DialogKind = 'area' | 'storage' | 'container' | 'material' | 'location' | 'containerMaterial' | 'rule'
export type DialogKind = 'area' | 'storage' | 'container' | 'materialType' | 'material' | 'location' | 'containerMaterial' | 'rule'
const props = defineProps<{
visible: boolean
@@ -137,12 +167,14 @@ const props = defineProps<{
storages: Storage[]
containers: Container[]
materials: Material[]
materialTypes: MaterialType[]
occupiedStorageIds: Set<string>
masterForm: MasterDataPayload
storageForm: StoragePayload
materialForm: MaterialPayload
materialTypeForm: MaterialTypePayload
locationForm: ContainerLocationPayload
containerMaterialForm: ContainerMaterialPayload
containerMaterialForm: BindMaterialPayload
ruleForm: TransportRulePayload
}>()
@@ -152,9 +184,24 @@ const emit = defineEmits<{
}>()
const formRef = ref<FormInstance>()
const areaLayoutMode = defineModel<string>('areaLayoutMode', { default: 'Flat' })
const containerBarcode = defineModel<string>('containerBarcode', { default: '' })
const { sites: simpleLiteSites, loading: sitesLoading, loadError: sitesLoadError, load: loadSimpleLiteSites } = useSimpleLiteSites()
const siteSearch = ref('')
function onSiteChange(id: string) {
props.storageForm.siteCode = id || ''
}
function onMaterialTypeChange(code: string) {
const t = props.materialTypes.find((x) => x.code === code)
if (!t) return
if (!props.materialForm.name) props.materialForm.name = t.name
if (!props.materialForm.spec) props.materialForm.spec = t.spec
if (!props.materialForm.unit) props.materialForm.unit = t.unit || 'pcs'
if (!props.materialForm.category) props.materialForm.category = t.category
}
const siteOptionsWithCurrent = computed(() => {
const opts = [...simpleLiteSites.value]
const current = props.storageForm.siteId?.trim()
@@ -178,6 +225,7 @@ function onSiteFilter(query: string) {
const isEditing = computed(() => {
if (props.kind === 'storage') return !!props.storageForm.id
if (props.kind === 'materialType') return !!props.materialTypeForm.id
if (props.kind === 'material') return !!props.materialForm.id
if (props.kind === 'location') return !!props.locationForm.id
if (props.kind === 'containerMaterial') return !!props.containerMaterialForm.id
@@ -187,6 +235,7 @@ const isEditing = computed(() => {
const editCode = computed(() => {
if (props.kind === 'storage') return props.storageForm.code
if (props.kind === 'materialType') return props.materialTypeForm.code
if (props.kind === 'material') return props.materialForm.code
if (props.kind === 'rule') return props.ruleForm.code
return props.masterForm.code
@@ -194,7 +243,7 @@ const editCode = computed(() => {
const title = computed(() => {
const names: Record<DialogKind, string> = {
area: '库区', storage: '库位', container: '容器', material: '物料',
area: '库区', storage: '库位', container: '容器', materialType: '物料类型', material: '物料',
location: '容器位置', containerMaterial: '容器物料', rule: '搬运规则'
}
const code = editCode.value?.trim()
@@ -207,6 +256,7 @@ const availableStorages = computed(() =>
const formModel = computed(() => {
switch (props.kind) {
case 'storage': return props.storageForm
case 'materialType': return props.materialTypeForm
case 'material': return props.materialForm
case 'location': return props.locationForm
case 'containerMaterial': return props.containerMaterialForm
@@ -231,8 +281,7 @@ const isLock = computed({
const rules: FormRules = {
code: [{ required: true, message: '请输入编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
reason: [{ required: true, message: '请填写原因', trigger: 'blur' }],
quantity: [{ required: true, message: '请输入数量', trigger: 'change' }]
reason: [{ required: true, message: '请填写原因', trigger: 'blur' }]
}
watch(() => props.visible, (v) => {
@@ -4,6 +4,11 @@ export const AREA_TYPES = [
{ value: 'FinishedGoods', label: '成品区' }
]
export const AREA_LAYOUT_MODES = [
{ value: 'Flat', label: '平面' },
{ value: 'Grid', label: '网格' }
]
export const STORAGE_TYPES = [
{ value: 'Storage', label: '普通库位' },
{ value: 'LineSide', label: '线边库' },
@@ -12,23 +17,33 @@ export const STORAGE_TYPES = [
{ value: 'FinishedGoods', label: '成品库' }
]
export const LOCATION_KINDS = [
{ value: 'Station', label: '接驳位' },
{ value: 'Grid', label: '网格货位' }
]
export const STORAGE_STATUSES = [
{ value: 'Available', label: '可用' },
{ value: 'Idle', label: '空' },
{ value: 'Occupied', label: '已占用' },
{ value: 'Empty', label: '空货位' },
{ value: 'EmptyContainer', label: '空容器' },
{ value: 'FullContainer', label: '满容器' },
{ value: 'Disabled', label: '停用' }
]
export const STORAGE_STATUS_TYPE: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
Empty: 'success',
EmptyContainer: 'warning',
FullContainer: 'danger',
Disabled: 'info'
}
export const CONTAINER_TYPES = [
{ value: 'Box', label: '料箱' },
{ value: 'Pallet', label: '托盘' }
]
export const CONTAINER_STATUSES = [
{ value: 'Idle', label: '空' },
{ value: 'Loaded', label: '已装载' },
{ value: 'Disabled', label: '停用' },
{ value: 'Exception', label: '异常' }
{ value: 'EmptyMaterial', label: '空物料' },
{ value: 'FullMaterial', label: '有物料' }
]
export const CONTAINER_LOCATION_STATUSES = [
@@ -37,12 +52,6 @@ export const CONTAINER_LOCATION_STATUSES = [
{ value: 'Exception', label: '异常' }
]
export const CONTAINER_MATERIAL_STATUSES = [
{ value: 'Loaded', label: '已装载' },
{ value: 'Adjusted', label: '已调整' },
{ value: 'Frozen', label: '冻结' }
]
export const MATERIAL_UNITS = [
{ value: 'pcs', label: 'pcs' },
{ value: 'kg', label: 'kg' },
@@ -50,6 +59,17 @@ export const MATERIAL_UNITS = [
{ value: 'set', label: 'set' }
]
export const MATERIAL_LIFECYCLES = [
{ value: 'Active', label: '在用' },
{ value: 'Archived', label: '已归档' }
]
export const STOCK_EVENT_TYPES = [
{ value: 'Bind', label: '绑定' },
{ value: 'Unbind', label: '解绑' },
{ value: 'ContainerMove', label: '容器移位' }
]
export const TRANSPORT_TRIGGER_LABELS: Record<string, string> = {
MaterialCall: '呼叫物料',
FinishedGoodsOffline: '成品下线',