wanshenmean
2026-03-31 9b77acb859f0866f3a854d2a2842072b2fe9cca8
feat(wms): 完善库存三维看板与库存/货位变更追踪

围绕库存三维展示、SignalR 增量推送以及库存/货位变更留痕做了一组联动更新,
让前端能够更准确地展示货位状态、库存明细与出库时间,同时让后端在库存新增、
更新、删除、移库预占和货位状态调整时自动写入变更记录。

前端库存看板:
- 重构 stockChat 三维场景,改为分层 instanced mesh 渲染货架、托盘、货物与高亮框
- 调整交互方式,支持 Shift+左键旋转、默认平移、点击命中判断和聚焦动画
- 新增货位形态图例、空托盘展示、库存详情抽屉字段扩展和库存明细字段精简
- 优化整体视觉样式、地面光照、图例与工具栏表现,提升三维仓储视图可读性

后台库存监控:
- 将库存监控服务改为“启动时全量快照 + 运行时按时间戳增量检查”
- 新增库存所在货位映射,识别库存移库时源货位与目标货位的双边刷新
- 扩展推送载荷,补充 OutboundDate、SerialNumber、InboundOrderRowNo 等字段
- 仅在关键快照字段变化时推送 SignalR 消息,减少全表扫描和无效广播

库存与货位变更记录:
- 为记录服务接口补充新增货位变更记录与库存变更记录的统一入口
- 在库存服务中接入新增、更新、删除、异步更新的自动留痕,并根据变更内容推断变更类型
- 在货位服务中接入单条/批量更新留痕,并在创建移库任务时记录源货位、目标货位和库存预占变化
- 扩展库存数量变更记录模型,补充前后状态、前后货位 ID/编码 等审计字段

配置与其他:
- 将自动出库任务开关调整为禁用状态
- 引入与本次改动相关的 DTO、Hub、接口定义同步更新
- 提交当前工作区内同步变更的 Visual Studio Copilot 索引数据库文件

Constraint: 用户要求提交当前工作区全部变更,包含 .vs 下已修改文件
Rejected: 拆分为前后端多个提交 | 与用户“提交所有变更”的要求不一致
Confidence: medium
Scope-risk: broad
Not-tested: 未在本地执行前后端构建、自动化测试或运行时联调验证
已修改17个文件
2711 ■■■■ 文件已修改
Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue 1827 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/.vs/WIDESEA_WMSServer/CopilotIndices/18.0.988.22099/CodeChunks.db 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/.vs/WIDESEA_WMSServer/CopilotIndices/18.0.988.22099/SemanticSymbols.db 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_BasicService/LocationInfoService.cs 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_DTO/Stock/Stock3DLayoutDTO.cs 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_IRecordService/ILocationStatusChangeRecordService.cs 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_IRecordService/IRecordService.cs 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_IRecordService/IStockQuantityChangeRecordService.cs 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_Model/Models/Record/Dt_StockQuantityChangeRecord.cs 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_RecordService/LocationStatusChangeRecordService.cs 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_RecordService/RecordService.cs 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_RecordService/StockQuantityChangeRecordService.cs 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_StockService/StockInfoService.cs 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_TaskInfoService/TaskService.cs 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs 348 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/Hubs/StockHub.cs 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/appsettings.json 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue
@@ -1,133 +1,116 @@
<template>
  <div class="stock-chat-container">
    <!-- 仓库 Tabs -->
    <el-tabs v-model="activeWarehouse" @tab-change="onWarehouseChange">
      <el-tab-pane
        v-for="wh in warehouseList"
        :key="wh.warehouseId || wh.id"
        :label="wh.warehouseName"
        :name="wh.warehouseId || wh.id"
      />
    </el-tabs>
    <div class="stock-chat-container">
        <el-tabs v-model="activeWarehouse" @tab-change="onWarehouseChange">
            <el-tab-pane
                v-for="wh in warehouseList"
                :key="wh.warehouseId || wh.id"
                :label="wh.warehouseName"
                :name="wh.warehouseId || wh.id"
            />
        </el-tabs>
    <!-- 工具栏 -->
    <div class="toolbar">
      <el-select v-model="filterStockStatus" placeholder="库存状态筛选" style="width: 160px" clearable>
        <el-option label="组盘暂存(1)" :value="1" />
        <el-option label="入库确认(3)" :value="3" />
        <el-option label="入库完成(6)" :value="6" />
        <el-option label="出库锁定(7)" :value="7" />
        <el-option label="出库完成(8)" :value="8" />
        <el-option label="空托盘(22)" :value="22" />
      </el-select>
      <el-select v-model="filterMaterielCode" placeholder="物料筛选" style="width: 140px" clearable>
        <el-option v-for="code in materielCodeList" :key="code" :label="code" :value="code" />
      </el-select>
      <el-select v-model="filterBatchNo" placeholder="批次筛选" style="width: 140px" clearable>
        <el-option v-for="batch in batchNoList" :key="batch" :label="batch" :value="batch" />
      </el-select>
      <el-button @click="resetCamera">重置视角</el-button>
      <el-button type="primary" @click="refreshData" :loading="refreshing">刷新数据</el-button>
    </div>
    <!-- 3D Canvas -->
    <div ref="canvasContainer" class="canvas-container" />
    <!-- 状态图例 -->
    <div class="legend">
      <div class="legend-title">货位状态</div>
      <div v-for="item in legendItems" :key="item.status" class="legend-item">
        <span class="color-box" :style="{ background: item.color }" />
        <span>{{ item.label }}</span>
      </div>
    </div>
    <!-- 详情侧边面板 -->
    <el-drawer v-model="detailDialogVisible" title="库存详情" direction="rtl" size="500px">
      <div v-if="selectedLocation" class="detail-content">
        <el-descriptions :column="2" border>
          <el-descriptions-item label="货位编号">{{ selectedLocation.locationCode }}</el-descriptions-item>
          <el-descriptions-item label="货位状态">{{ getLocationStatusText(selectedLocation.locationStatus) }}</el-descriptions-item>
          <el-descriptions-item label="托盘编号">{{ selectedLocation.palletCode || '无' }}</el-descriptions-item>
          <el-descriptions-item label="库存状态">{{ getStockStatusText(selectedLocation.stockStatus) }}</el-descriptions-item>
          <el-descriptions-item label="总库存">{{ selectedLocation.stockQuantity }}{{ selectedLocation.unit || '' }}</el-descriptions-item>
        </el-descriptions>
        <!-- 库存明细表格 -->
        <div v-if="selectedLocation.details && selectedLocation.details.length > 0" class="detail-table">
          <h4>库存明细</h4>
          <el-table :data="selectedLocation.details" border size="small" max-height="400">
            <el-table-column prop="materielCode" label="物料编码" width="100" />
            <el-table-column prop="materielName" label="物料名称" min-width="120" show-overflow-tooltip />
            <el-table-column prop="batchNo" label="批次号" width="100" show-overflow-tooltip />
            <el-table-column prop="stockQuantity" label="数量" width="70" align="right" />
            <el-table-column prop="unit" label="单位" width="50" align="center" />
            <el-table-column prop="effectiveDate" label="有效期" width="100" />
          </el-table>
        <div class="toolbar">
            <el-select v-model="filterStockStatus" placeholder="库存状态筛选" style="width: 160px" clearable>
                <el-option label="组盘暂存(1)" :value="1" />
                <el-option label="入库确认(3)" :value="3" />
                <el-option label="入库完成(6)" :value="6" />
                <el-option label="出库锁定(7)" :value="7" />
                <el-option label="出库完成(8)" :value="8" />
                <el-option label="空托盘(22)" :value="22" />
            </el-select>
            <el-select v-model="filterMaterielCode" placeholder="物料编码筛选" style="width: 140px" clearable>
                <el-option v-for="code in materielCodeList" :key="code" :label="code" :value="code" />
            </el-select>
            <el-select v-model="filterBatchNo" placeholder="批次筛选" style="width: 140px" clearable>
                <el-option v-for="batch in batchNoList" :key="batch" :label="batch" :value="batch" />
            </el-select>
            <el-button @click="resetCamera">重置视角</el-button>
            <el-button type="primary" @click="refreshData" :loading="refreshing">刷新数据</el-button>
        </div>
        <div v-else class="no-detail">
          <el-empty description="暂无库存明细" />
        <div ref="canvasContainer" class="canvas-container" />
        <div class="legend">
            <div class="legend-title">货位状态</div>
            <div class="legend-subtitle">形态说明</div>
            <div class="legend-item legend-item--shape">
                <span class="shape-pill shape-pill--empty" />
                <span>空货位</span>
            </div>
            <div class="legend-item legend-item--shape">
                <span class="shape-pill shape-pill--pallet" />
                <span>空托盘位</span>
            </div>
            <div class="legend-item legend-item--shape">
                <span class="shape-pill shape-pill--cargo" />
                <span>有货位</span>
            </div>
            <div class="legend-divider" />
            <div class="legend-subtitle">状态颜色</div>
            <div v-for="item in legendItems" :key="item.status" class="legend-item">
                <span class="color-box" :style="{ background: item.color }" />
                <span>{{ item.label }}</span>
            </div>
        </div>
      </div>
    </el-drawer>
  </div>
        <el-drawer v-model="detailDialogVisible" title="库存详情" direction="rtl" size="500px">
            <div v-if="selectedLocation" class="detail-content">
                <el-descriptions :column="2" border>
                    <el-descriptions-item label="货位编号">{{ selectedLocation.locationCode }}</el-descriptions-item>
                    <el-descriptions-item label="货位状态">{{ getLocationStatusText(selectedLocation.locationStatus) }}</el-descriptions-item>
                    <el-descriptions-item label="托盘编号">{{ selectedLocation.palletCode || '无' }}</el-descriptions-item>
                    <el-descriptions-item label="库存状态">{{ getStockStatusText(selectedLocation.stockStatus) }}</el-descriptions-item>
                    <el-descriptions-item label="总库存">{{ selectedLocation.stockQuantity }}{{ selectedLocation.unit || '' }}</el-descriptions-item>
                    <el-descriptions-item label="出库日期">{{ selectedLocation.outboundDate }}{{ selectedLocation.unit || '' }}</el-descriptions-item>
                </el-descriptions>
                <div v-if="selectedLocation.details && selectedLocation.details.length > 0" class="detail-table">
                    <h4>库存明细</h4>
                    <el-table :data="selectedLocation.details" border size="small" max-height="400">
                        <el-table-column prop="materielName" label="物料名称" min-width="140" show-overflow-tooltip />
                        <el-table-column prop="serialNumber" label="电芯条码" min-width="160" show-overflow-tooltip />
                        <el-table-column prop="inboundOrderRowNo" label="通道号" min-width="100" show-overflow-tooltip />
                    </el-table>
                </div>
                <div v-else class="no-detail">
                    <el-empty description="暂无库存明细" />
                </div>
            </div>
        </el-drawer>
    </div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, watch, nextTick, getCurrentInstance } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick, getCurrentInstance } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import * as signalR from '@microsoft/signalr'
const { proxy } = getCurrentInstance()
// SignalR 连接
let connection = null
// 颜色常量 - 基于实际枚举值
// 货位状态(locationStatus): 0=空闲, 1=锁定, 10=有货锁定, 20=空闲锁定, 99=大托盘锁定, 100=有货
// 库存状态(stockStatus): 1=组盘暂存, 3=入库确认, 6=入库完成, 7=出库锁定, 8=出库完成, 9=移库锁定等
const COLOR_MAP = {
  // 货位状态颜色
  LOC_FREE: 0x90EE90,         // 0=空闲 - 浅绿色
  LOC_LOCK: 0xFF6B6B,        // 1=锁定 - 红色
  LOC_INSTOCK_LOCK: 0xFFA500, // 10=有货锁定 - 橙色
  LOC_FREE_LOCK: 0xFFD700,   // 20=空闲锁定 - 金色
  LOC_PALLET_LOCK: 0x9370DB,  // 99=大托盘锁定 - 紫色
  LOC_INSTOCK: 0x409EFF,     // 100=有货 - 蓝色
  // 库存状态颜色
  STOCK_PENDING: 0x00CED1,      // 1=组盘暂存 - 深青色
  STOCK_CONFIRMED: 0x87CEEB,     // 3=入库确认 - 天蓝色
  STOCK_COMPLETED: 0x32CD32,     // 6=入库完成 - 亮绿色
  STOCK_OUT_LOCK: 0xFF6347,     // 7=出库锁定 - 番茄红
  STOCK_OUT_COMPLETED: 0x228B22, // 8=出库完成 - 森林绿
  STOCK_TRANSFER_LOCK: 0xFF8C00, // 9=移库锁定 - 深橙色
  STOCK_COMPLETED_NO_ORDER: 0x20B2AA, // 10=入库完成未建出库单
  STOCK_RETURN: 0xFF4500,       // 11=退库
  STOCK_MANUAL_PENDING: 0x48D1CC, // 12=手动组盘暂存
  STOCK_MANUAL_CONFIRMED: 0x7FFFD4, // 13=手动组盘入库确认
  STOCK_PICK_COMPLETED: 0x6B8E23, // 14=拣选完成
  STOCK_MES_RETURN: 0xDC143C,   // 21=MES退库
  STOCK_EMPTY_TRAY: 0xDDA0DD,   // 22=空托盘库存 - 梅红色
  STOCK_GROUP_CANCEL: 0xDEB887, // 99=组盘撤销 - 暗金色
  STOCK_IN_CANCEL: 0xA0522D,    // 199=入库撤销 - 赭色
    LOC_FREE: 0x90EE90,
    LOC_LOCK: 0xFF6B6B,
    LOC_INSTOCK_LOCK: 0xFFA500,
    LOC_FREE_LOCK: 0xFFD700,
    LOC_PALLET_LOCK: 0x9370DB,
    LOC_INSTOCK: 0x409EFF,
    STOCK_EMPTY_TRAY: 0xDDA0DD
}
// 图例项 - 货位状态
const legendItems = [
  { status: 'loc_free', label: '空闲(0)', color: '#90EE90' },
  { status: 'loc_lock', label: '锁定(1)', color: '#FF6B6B' },
  { status: 'loc_instock_lock', label: '有货锁定(10)', color: '#FFA500' },
  { status: 'loc_free_lock', label: '空闲锁定(20)', color: '#FFD700' },
  { status: 'loc_pallet_lock', label: '大托盘锁定(99)', color: '#9370DB' },
  { status: 'loc_instock', label: '有货(100)', color: '#409EFF' },
    { status: 'loc_free', label: '空闲(0)', color: '#90EE90' },
    { status: 'loc_lock', label: '锁定(1)', color: '#FF6B6B' },
    { status: 'loc_instock_lock', label: '有货锁定(10)', color: '#FFA500' },
    { status: 'loc_free_lock', label: '空闲锁定(20)', color: '#FFD700' },
    { status: 'loc_pallet_lock', label: '托盘锁定(99)', color: '#9370DB' },
    { status: 'loc_instock', label: '有货(100)', color: '#409EFF' },
    { status: 'stock_empty_tray', label: '空托盘(22)', color: '#DDA0DD' }
]
// Refs
const canvasContainer = ref(null)
// 状态
const activeWarehouse = ref(null)
const warehouseList = ref([])
const filterStockStatus = ref(null)
@@ -139,546 +122,1250 @@
const selectedLocation = ref(null)
const refreshing = ref(false)
// Three.js 相关
let scene, camera, renderer, controls, raycaster, mouse
let locationMesh = null
let locationData = []
let originalLocationData = [] // 保存原始完整数据,用于筛选恢复
let scene = null
let camera = null
let renderer = null
let controls = null
let raycaster = null
let mouse = null
let animationId = null
let locationIdToInstanceId = new Map() // locationId -> instanceId 映射
// SignalR 初始化
function initSignalR() {
  proxy.http.post('api/User/GetCurrentUserInfo').then((result) => {
    connection = new signalR.HubConnectionBuilder()
      .withAutomaticReconnect()
      .withUrl(`${proxy.http.ipAddress}stockHub?userName=${result.data.userName}`)
      .build();
    connection.start().catch((err) => console.log('SignalR连接失败:', err));
    connection.on('StockUpdated', (update) => {
      console.log('收到库存更新:', update)
      // 更新对应货位的数据
      const idx = locationData.findIndex(x => x.locationId === update.locationId);
      if (idx !== -1) {
        locationData[idx].stockQuantity = update.stockQuantity;
        locationData[idx].stockStatus = update.stockStatus;
        locationData[idx].palletCode = update.palletCode;
        locationData[idx].locationStatus = update.locationStatus;
        // 更新库存明细
        if (update.details && update.details.length > 0) {
          locationData[idx].details = update.details;
        }
        // 通过映射找到实例ID,更新颜色
        const instanceId = locationIdToInstanceId.get(update.locationId);
        if (instanceId !== undefined) {
          updateInstanceColor(instanceId, update.locationStatus);
        }
      }
    });
  });
let locationMeshes = null
let pickableMeshes = []
let selectionOutline = null
let pointerDownHit = null
let pointerDownPosition = null
let aisleModels = []
let isShiftPressed = false
const preventCanvasContextMenu = (event) => event.preventDefault()
const preventRightMouseDefault = (event) => {
    if (event.button === 2) {
        event.preventDefault()
    }
}
// 更新单个货位颜色
function updateInstanceColor(instanceId, locationStatus) {
  if (!locationMesh) return;
  // 根据货位状态获取颜色
  const color = getLocationColorByStatus(locationStatus);
  locationMesh.setColorAt(instanceId, new THREE.Color(color));
  locationMesh.instanceColor.needsUpdate = true;
function updateControlMouseBinding(forceShiftPressed = isShiftPressed) {
    if (!controls) {
        return
    }
    controls.mouseButtons = {
        LEFT: forceShiftPressed ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN,
        MIDDLE: THREE.MOUSE.DOLLY,
        RIGHT: null
    }
}
// 根据货位状态获取颜色
function onKeyDown(event) {
    if (event.key !== 'Shift' || isShiftPressed) {
        return
    }
    isShiftPressed = true
    updateControlMouseBinding()
}
function onKeyUp(event) {
    if (event.key !== 'Shift') {
        return
    }
    isShiftPressed = false
    updateControlMouseBinding()
}
function onWindowBlur() {
    isShiftPressed = false
    updateControlMouseBinding()
}
let locationData = []
let originalLocationData = []
let renderedLocations = []
const locationIdToInstanceId = new Map()
const CELL_SPACING_X = 2
const CELL_SPACING_Z = 2.2
const AISLE_GAP_Z = 4.8
const BASE_COLOR = 0x748294
const PALLET_COLOR = 0xb89c74
const POST_COLOR = 0x75859d
function getLocationColorByStatus(locStatus) {
  switch (locStatus) {
    case 0: return COLOR_MAP.LOC_FREE           // 空闲
    case 1: return COLOR_MAP.LOC_LOCK            // 锁定
    case 10: return COLOR_MAP.LOC_INSTOCK_LOCK   // 有货锁定
    case 20: return COLOR_MAP.LOC_FREE_LOCK      // 空闲锁定
    case 99: return COLOR_MAP.LOC_PALLET_LOCK    // 大托盘锁定
    case 100: return COLOR_MAP.LOC_INSTOCK      // 有货
    default: return COLOR_MAP.LOC_FREE // 默认空闲色
  }
    switch (locStatus) {
        case 1:
            return COLOR_MAP.LOC_LOCK
        case 10:
            return COLOR_MAP.LOC_INSTOCK_LOCK
        case 20:
            return COLOR_MAP.LOC_FREE_LOCK
        case 22:
            return COLOR_MAP.STOCK_EMPTY_TRAY
        case 99:
            return COLOR_MAP.LOC_PALLET_LOCK
        case 100:
            return COLOR_MAP.LOC_INSTOCK
        case 0:
        default:
            return COLOR_MAP.LOC_FREE
    }
}
// 获取货位颜色 - 只根据货位状态
function getLocationColor(location) {
  const locStatus = location.locationStatus
  // 根据货位状态判断颜色
  switch (locStatus) {
    case 0: return COLOR_MAP.LOC_FREE           // 空闲
    case 1: return COLOR_MAP.LOC_LOCK            // 锁定
    case 10: return COLOR_MAP.LOC_INSTOCK_LOCK   // 有货锁定
    case 20: return COLOR_MAP.LOC_FREE_LOCK      // 空闲锁定
    case 99: return COLOR_MAP.LOC_PALLET_LOCK    // 大托盘锁定
    case 100: return COLOR_MAP.LOC_INSTOCK      // 有货
    default: return COLOR_MAP.LOC_FREE // 默认空闲色
  }
}
// 获取货位状态文本
function getLocationStatusText(status) {
  const map = {
    0: '空闲',
    1: '锁定',
    10: '有货锁定',
    20: '空闲锁定',
    99: '大托盘锁定',
    100: '有货'
  }
  return map[status] || '未知(' + status + ')'
}
// 获取库存状态文本
function getStockStatusText(status) {
  const map = {
    0: '无库存',
    1: '组盘暂存',
    3: '入库确认',
    6: '入库完成',
    7: '出库锁定',
    8: '出库完成',
    9: '移库锁定',
    10: '入库完成未建出库单',
    11: '退库',
    12: '手动组盘暂存',
    13: '手动组盘入库确认',
    14: '拣选完成',
    21: 'MES退库',
    22: '空托盘库存',
    99: '组盘撤销',
    199: '入库撤销'
  }
  return map[status] || '未知(' + status + ')'
}
// 加载仓库列表
async function loadWarehouseList() {
  try {
    const res = await proxy.http.get('/api/Warehouse/GetAll')
    if (res.status && res.data) {
      warehouseList.value = res.data
      if (res.data.length > 0) {
        activeWarehouse.value = res.data[0].warehouseId || res.data[0].id
        await loadWarehouseData(activeWarehouse.value)
      }
    if (location.stockStatus === 22) {
        return COLOR_MAP.STOCK_EMPTY_TRAY
    }
  } catch (e) {
    console.error('加载仓库列表失败', e)
  }
    return getLocationColorByStatus(location.locationStatus)
}
// 加载仓库货位数据
async function loadWarehouseData(warehouseId) {
  try {
    const res = await proxy.http.get(`/api/StockInfo/Get3DLayout?warehouseId=${warehouseId}`)
    if (res.status && res.data) {
      const data = res.data
      originalLocationData = data.locations || [] // 保存原始完整数据
      locationData = [...originalLocationData] // 当前显示数据
      // 使用后端返回的筛选列表
      materielCodeList.value = data.materielCodeList || []
      batchNoList.value = data.batchNoList || []
      // 渲染货位
      renderLocations()
function hasCargo(location) {
    return Number(location.stockQuantity || 0) > 0 || ((location.details && location.details.length > 0) || false)
}
function hasPallet(location) {
    if (location.stockStatus === 22 || location.locationStatus === 99) {
        return true
    }
  } catch (e) {
    console.error('加载货位数据失败', e)
  }
}
// 初始化 Three.js 场景
function initThreeJS() {
  if (!canvasContainer.value) return
  const width = canvasContainer.value.clientWidth
  const height = canvasContainer.value.clientHeight
  // 创建场景
  scene = new THREE.Scene()
  scene.background = new THREE.Color(0x1a1a2e)
  // 创建相机
  camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
  camera.position.set(20, 20, 20)
  camera.lookAt(0, 0, 0)
  // 创建渲染器
  renderer = new THREE.WebGLRenderer({ antialias: true })
  renderer.setPixelRatio(window.devicePixelRatio)
  renderer.setSize(width, height)
  canvasContainer.value.appendChild(renderer.domElement)
  // 创建轨道控制器
  controls = new OrbitControls(camera, renderer.domElement)
  controls.enableDamping = true
  controls.dampingFactor = 0.05
  // 创建环境光
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
  scene.add(ambientLight)
  // 创建主定向光
  const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0)
  directionalLight.position.set(50, 100, 50)
  scene.add(directionalLight)
  // 创建补光
  const fillLight = new THREE.DirectionalLight(0xffffff, 0.3)
  fillLight.position.set(-50, 50, -50)
  scene.add(fillLight)
  // 创建地面
  const groundGeometry = new THREE.PlaneGeometry(100, 100)
  const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x2d2d2d })
  const ground = new THREE.Mesh(groundGeometry, groundMaterial)
  ground.rotation.x = -Math.PI / 2
  ground.position.y = -0.5
  scene.add(ground)
  // 创建网格
  const gridHelper = new THREE.GridHelper(100, 50)
  scene.add(gridHelper)
  // 创建射线检测器
  raycaster = new THREE.Raycaster()
  mouse = new THREE.Vector2()
  // 添加点击事件
  canvasContainer.value.addEventListener('mousedown', onCanvasClick)
  // 开始渲染循环
  animate()
}
// 渲染货位
function renderLocations() {
  if (!scene) return
  console.log('渲染货位,原始数据总数:', originalLocationData.length)
  // 如果原始数据为空,尝试重新加载
  if (originalLocationData.length === 0) {
    console.warn('原始数据为空,重新加载...')
    if (activeWarehouse.value) {
      loadWarehouseData(activeWarehouse.value)
    if (hasCargo(location)) {
        return true
    }
    return
  }
  // 移除旧的货位网格
  console.log("🚀 ~ renderLocations ~ locationMesh:", locationMesh)
  if (locationMesh) {
    scene.remove(locationMesh)
    locationMesh.geometry.dispose()
    if (Array.isArray(locationMesh.material)) {
      locationMesh.material.forEach(m => m.dispose())
    } else {
      locationMesh.material.dispose()
    }
    locationMesh = null
  }
  // 过滤数据 - 始终从原始完整数据过滤
  let filteredData = [...originalLocationData]
  if (filterStockStatus.value !== null) {
    filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value)
  }
  if (filterMaterielCode.value) {
    filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value)
  }
  if (filterBatchNo.value) {
    filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value)
  }
  console.log('过滤后数据数量:', filteredData.length, '筛选条件:', filterStockStatus.value, filterMaterielCode.value, filterBatchNo.value)
  // 创建 InstancedMesh
  const geometry = new THREE.BoxGeometry(1.5, 1, 1.5)
  const material = new THREE.MeshStandardMaterial({
    color: 0xffffff,
    roughness: 0.5,
    metalness: 0.1
  })
  // 如果过滤后无数据,创建空的 InstancedMesh
  if (filteredData.length === 0) {
    locationMesh = new THREE.InstancedMesh(geometry, material, 0)
    scene.add(locationMesh)
    return
  }
  locationMesh = new THREE.InstancedMesh(geometry, material, filteredData.length)
  // 清空并重建映射
  locationIdToInstanceId.clear()
  const dummy = new THREE.Object3D()
  const color = new THREE.Color()
  filteredData.forEach((location, i) => {
    const x = (location.column - 1) * 2
    const y = location.layer * 1.5
    const z = (location.row - 1) * 2
    dummy.position.set(x, y, z)
    dummy.updateMatrix()
    locationMesh.setMatrixAt(i, dummy.matrix)
    // 设置颜色
    color.setHex(getLocationColor(location))
    locationMesh.setColorAt(i, color)
    // 建立映射: locationId -> instanceId (i)
    locationIdToInstanceId.set(location.locationId, i)
    if (i === 0) {
      console.log('First location:', location, { x, y, z })
    }
  })
  locationMesh.instanceMatrix.needsUpdate = true
  if (locationMesh.instanceColor) locationMesh.instanceColor.needsUpdate = true
  scene.add(locationMesh)
    return Boolean(location.palletCode)
}
// 动画循环
function animate() {
  animationId = requestAnimationFrame(animate)
  controls.update()
  renderer.render(scene, camera)
function getShelfAccentColor(location) {
    return BASE_COLOR
}
// 点击画布
function onCanvasClick(event) {
  if (!canvasContainer.value || !locationMesh) return
function getPalletColor(location) {
    return PALLET_COLOR
}
  const rect = canvasContainer.value.getBoundingClientRect()
  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
function getCargoColor(location) {
    const cargoColor = new THREE.Color(getLocationColor(location))
    return cargoColor.offsetHSL(0, 0.04, 0.08).getHex()
}
  raycaster.setFromCamera(mouse, camera)
  const intersects = raycaster.intersectObject(locationMesh)
function getCargoLidColor(location) {
    const lidColor = new THREE.Color(getCargoColor(location))
    return lidColor.offsetHSL(0, -0.02, 0.06).getHex()
}
  if (intersects.length > 0) {
    const instanceId = intersects[0].instanceId
    // 获取原始数据索引(考虑过滤后的数据)
function getFilteredLocations() {
    let filteredData = [...originalLocationData]
    if (filterStockStatus.value !== null) {
      filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value)
        filteredData = filteredData.filter((loc) => loc.stockStatus === filterStockStatus.value)
    }
    if (filterMaterielCode.value) {
      filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value)
        filteredData = filteredData.filter((loc) => loc.materielCode === filterMaterielCode.value)
    }
    if (filterBatchNo.value) {
      filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value)
        filteredData = filteredData.filter((loc) => loc.batchNo === filterBatchNo.value)
    }
    if (instanceId < filteredData.length) {
      selectedLocation.value = filteredData[instanceId]
      detailDialogVisible.value = true
      // 聚焦相机
      focusCamera(selectedLocation.value)
    }
  }
    return filteredData
}
// 聚焦相机到货位
function matchesCurrentFilters(location) {
    if (filterStockStatus.value !== null && location.stockStatus !== filterStockStatus.value) {
        return false
    }
    if (filterMaterielCode.value && location.materielCode !== filterMaterielCode.value) {
        return false
    }
    if (filterBatchNo.value && location.batchNo !== filterBatchNo.value) {
        return false
    }
    return true
}
function getLocationStatusText(status) {
    const map = {
        0: '空闲',
        1: '锁定',
        10: '有货锁定',
        20: '空闲锁定',
        99: '托盘锁定',
        100: '有货'
    }
    return map[status] || `未知(${status})`
}
function getStockStatusText(status) {
    const map = {
        0: '无库存',
        1: '组盘暂存',
        3: '入库确认',
        6: '入库完成',
        7: '出库锁定',
        8: '出库完成',
        9: '移库锁定',
        10: '入库完成未建出库单',
        11: '退库',
        12: '手动组盘暂存',
        13: '手动组盘入库确认',
        14: '拣选完成',
        21: 'MES退库',
        22: '空托盘',
        99: '组盘撤销',
        199: '入库撤销'
    }
    return map[status] || `未知(${status})`
}
function createFloorTexture() {
    const canvas = document.createElement('canvas')
    canvas.width = 512
    canvas.height = 512
    const context = canvas.getContext('2d')
    const gradient = context.createLinearGradient(0, 0, 512, 512)
    gradient.addColorStop(0, '#f7f9fc')
    gradient.addColorStop(1, '#dde5ef')
    context.fillStyle = gradient
    context.fillRect(0, 0, 512, 512)
    context.strokeStyle = 'rgba(122, 142, 168, 0.16)'
    context.lineWidth = 2
    for (let index = 0; index <= 8; index++) {
        const offset = index * 64
        context.beginPath()
        context.moveTo(offset, 0)
        context.lineTo(offset, 512)
        context.stroke()
        context.beginPath()
        context.moveTo(0, offset)
        context.lineTo(512, offset)
        context.stroke()
    }
    context.fillStyle = 'rgba(255, 255, 255, 0.3)'
    context.fillRect(0, 242, 512, 28)
    const texture = new THREE.CanvasTexture(canvas)
    texture.wrapS = THREE.RepeatWrapping
    texture.wrapT = THREE.RepeatWrapping
    texture.repeat.set(6, 6)
    return texture
}
function createBackgroundTexture() {
    const canvas = document.createElement('canvas')
    canvas.width = 16
    canvas.height = 512
    const context = canvas.getContext('2d')
    const gradient = context.createLinearGradient(0, 0, 0, 512)
    gradient.addColorStop(0, '#eef4fb')
    gradient.addColorStop(0.45, '#dbe6f3')
    gradient.addColorStop(1, '#c5d1df')
    context.fillStyle = gradient
    context.fillRect(0, 0, 16, 512)
    return new THREE.CanvasTexture(canvas)
}
function getLayoutInfo() {
    const rowValues = [...new Set(renderedLocations.map((item) => Number(item.row || 0)).filter((value) => value > 0))].sort((a, b) => a - b)
    const columnValues = [...new Set(renderedLocations.map((item) => Number(item.column || 0)).filter((value) => value > 0))].sort((a, b) => a - b)
    const layerValues = [...new Set(renderedLocations.map((item) => Number(item.layer || 0)).filter((value) => value >= 0))].sort((a, b) => a - b)
    const rowCount = rowValues.length
    const splitAfterRow = rowCount > 2 ? 2 : 1
    return {
        rowValues,
        columnValues,
        layerValues,
        rowCount,
        splitAfterRow,
        minColumn: columnValues.length > 0 ? columnValues[0] : 1,
        maxColumn: columnValues.length > 0 ? columnValues[columnValues.length - 1] : 1,
        maxLayer: layerValues.length > 0 ? layerValues[layerValues.length - 1] : 1
    }
}
function getLocationWorldPosition(location, layoutInfo = getLayoutInfo()) {
    const x = (Number(location.column || 1) - 1) * CELL_SPACING_X
    const row = Number(location.row || 1)
    const zBase = (row - 1) * CELL_SPACING_Z
    const z = row > layoutInfo.splitAfterRow ? zBase + AISLE_GAP_Z : zBase
    const y = Number(location.layer || 0) * 1.5
    return { x, y, z }
}
function clearAisleModels() {
    if (!scene || aisleModels.length === 0) {
        aisleModels = []
        return
    }
    aisleModels.forEach((mesh) => {
        scene.remove(mesh)
        if (mesh.geometry) {
            mesh.geometry.dispose()
        }
        if (mesh.material) {
            if (Array.isArray(mesh.material)) {
                mesh.material.forEach((item) => item.dispose())
            } else {
                mesh.material.dispose()
            }
        }
    })
    aisleModels = []
}
function renderAisleEquipment(layoutInfo) {
    clearAisleModels()
    if (!scene || layoutInfo.rowCount < 2) {
        return
    }
    const leftRowZ = (layoutInfo.splitAfterRow - 1) * CELL_SPACING_Z
    const rightRowZ = layoutInfo.splitAfterRow * CELL_SPACING_Z + AISLE_GAP_Z
    const aisleCenterZ = (leftRowZ + rightRowZ) / 2
    const minX = (layoutInfo.minColumn - 1) * CELL_SPACING_X - 1.6
    const maxX = (layoutInfo.maxColumn - 1) * CELL_SPACING_X + 1.6
    const railLength = Math.max(maxX - minX, 4)
    const craneHeight = Math.max(layoutInfo.maxLayer * 1.5 + 1.9, 4)
    const craneX = minX + railLength / 2
    const craneBaseY = -0.5
    const mastSpacing = 0.92
    const mastThickness = 0.16
    const carriageY = Math.min(craneHeight * 0.48, craneHeight - 0.78)
    const leftDepth = layoutInfo.splitAfterRow
    const rightDepth = Math.max(layoutInfo.rowCount - layoutInfo.splitAfterRow, 1)
    const leftForkLength = leftDepth > 1 ? 1.72 : 1.02
    const rightForkLength = rightDepth > 1 ? 1.72 : 1.02
    const forwardSign = rightDepth > leftDepth ? 1 : -1
    function addAisleMesh(geometry, material, position, rotation = null, castShadow = true) {
        const mesh = new THREE.Mesh(geometry, material)
        mesh.position.copy(position)
        if (rotation) {
            mesh.rotation.set(rotation.x, rotation.y, rotation.z)
        }
        mesh.castShadow = castShadow
        mesh.receiveShadow = true
        scene.add(mesh)
        aisleModels.push(mesh)
        return mesh
    }
    const railGeometry = new THREE.BoxGeometry(railLength, 0.08, 0.14)
    const railMaterial = new THREE.MeshStandardMaterial({
        color: 0x58677b,
        roughness: 0.45,
        metalness: 0.55
    })
    const railOffsets = [-0.36, 0.36]
    railOffsets.forEach((offset) => {
        const rail = new THREE.Mesh(railGeometry, railMaterial.clone())
        rail.position.set(craneX, -0.42, aisleCenterZ + offset)
        rail.castShadow = true
        rail.receiveShadow = true
        scene.add(rail)
        aisleModels.push(rail)
    })
    const sleeperGeometry = new THREE.BoxGeometry(0.18, 0.05, 0.94)
    for (let x = minX; x <= maxX; x += 1.2) {
        const sleeper = new THREE.Mesh(sleeperGeometry, new THREE.MeshStandardMaterial({
            color: 0x8f6a49,
            roughness: 0.88,
            metalness: 0.03
        }))
        sleeper.position.set(x, -0.46, aisleCenterZ)
        sleeper.receiveShadow = true
        scene.add(sleeper)
        aisleModels.push(sleeper)
    }
    const machineYellow = new THREE.MeshStandardMaterial({
        color: 0xe9a63d,
        roughness: 0.42,
        metalness: 0.32
    })
    const machineDark = new THREE.MeshStandardMaterial({
        color: 0x334155,
        roughness: 0.54,
        metalness: 0.26
    })
    const machineSteel = new THREE.MeshStandardMaterial({
        color: 0x7b8aa0,
        roughness: 0.42,
        metalness: 0.58
    })
    const guardRed = new THREE.MeshStandardMaterial({
        color: 0xd05b45,
        roughness: 0.5,
        metalness: 0.18
    })
    addAisleMesh(
        new THREE.BoxGeometry(1.34, 0.22, 1.12),
        machineDark.clone(),
        new THREE.Vector3(craneX, craneBaseY + 0.11, aisleCenterZ)
    )
    addAisleMesh(
        new THREE.BoxGeometry(1.08, 0.18, 0.92),
        machineYellow.clone(),
        new THREE.Vector3(craneX, craneBaseY + 0.3, aisleCenterZ)
    )
    const wheelOffsets = [
        [-0.42, -0.3],
        [-0.42, 0.3],
        [0.42, -0.3],
        [0.42, 0.3]
    ]
    wheelOffsets.forEach((offset) => {
        addAisleMesh(
            new THREE.CylinderGeometry(0.11, 0.11, 0.12, 16),
            machineSteel.clone(),
            new THREE.Vector3(craneX + offset[0], craneBaseY + 0.09, aisleCenterZ + offset[1]),
            new THREE.Vector3(Math.PI / 2, 0, 0)
        )
    })
    const mastPositions = [-mastSpacing / 2, mastSpacing / 2]
    mastPositions.forEach((offsetX) => {
        addAisleMesh(
            new THREE.BoxGeometry(mastThickness, craneHeight, mastThickness),
            machineYellow.clone(),
            new THREE.Vector3(craneX + offsetX, craneBaseY + craneHeight / 2, aisleCenterZ)
        )
    })
    addAisleMesh(
        new THREE.BoxGeometry(mastSpacing + 0.34, 0.18, 0.38),
        machineDark.clone(),
        new THREE.Vector3(craneX, craneBaseY + craneHeight - 0.12, aisleCenterZ)
    )
    addAisleMesh(
        new THREE.BoxGeometry(mastSpacing + 0.18, 0.1, 0.28),
        machineSteel.clone(),
        new THREE.Vector3(craneX, craneBaseY + 0.92, aisleCenterZ)
    )
    addAisleMesh(
        new THREE.BoxGeometry(mastSpacing + 0.14, 0.44, 0.72),
        machineDark.clone(),
        new THREE.Vector3(craneX, carriageY, aisleCenterZ)
    )
    addAisleMesh(
        new THREE.BoxGeometry(mastSpacing + 0.28, 0.08, 0.82),
        machineYellow.clone(),
        new THREE.Vector3(craneX, carriageY + 0.22, aisleCenterZ)
    )
    addAisleMesh(
        new THREE.BoxGeometry(0.26, 0.07, 0.46),
        machineSteel.clone(),
        new THREE.Vector3(craneX, carriageY - 0.04, aisleCenterZ)
    )
    addAisleMesh(
        new THREE.BoxGeometry(0.12, 0.06, leftForkLength),
        machineSteel.clone(),
        new THREE.Vector3(craneX - 0.12, carriageY - 0.04, aisleCenterZ - (0.23 + leftForkLength / 2))
    )
    addAisleMesh(
        new THREE.BoxGeometry(0.12, 0.06, rightForkLength),
        machineSteel.clone(),
        new THREE.Vector3(craneX + 0.12, carriageY - 0.04, aisleCenterZ + (0.23 + rightForkLength / 2))
    )
    addAisleMesh(
        new THREE.BoxGeometry(0.4, 0.54, 0.26),
        guardRed.clone(),
        new THREE.Vector3(craneX, carriageY + 0.04, aisleCenterZ - (0.38 * forwardSign))
    )
    addAisleMesh(
        new THREE.BoxGeometry(0.78, 0.05, 0.28),
        machineDark.clone(),
        new THREE.Vector3(craneX, craneBaseY + 0.66, aisleCenterZ - (0.22 * forwardSign))
    )
    addAisleMesh(
        new THREE.BoxGeometry(0.72, 0.38, 0.04),
        machineSteel.clone(),
        new THREE.Vector3(craneX, craneBaseY + 0.86, aisleCenterZ - (0.36 * forwardSign))
    )
    const noseGeometry = new THREE.BoxGeometry(0.52, 0.28, 0.22)
    addAisleMesh(
        noseGeometry,
        machineYellow.clone(),
        new THREE.Vector3(craneX, carriageY + 0.02, aisleCenterZ + (0.48 * forwardSign))
    )
}
function clearLocationMeshes() {
    pickableMeshes = []
    pointerDownHit = null
    if (!locationMeshes) {
        return
    }
    Object.values(locationMeshes).forEach((mesh) => {
        scene.remove(mesh)
        mesh.geometry.dispose()
        mesh.material.dispose()
    })
    locationMeshes = null
}
function updateInstanceVisual(instanceId, location) {
    if (!locationMeshes) {
        return
    }
    const layoutInfo = getLayoutInfo()
    const { x, y, z } = getLocationWorldPosition(location, layoutInfo)
    const postTopY = y + 1.54
    const postBottomY = -0.5
    const postHeight = postTopY - postBottomY
    const postCenterY = postBottomY + (postHeight / 2)
    const showPallet = hasPallet(location)
    const showCargo = hasCargo(location)
    const dummy = new THREE.Object3D()
    const shelfColor = new THREE.Color(getShelfAccentColor(location))
    const palletColor = new THREE.Color(getPalletColor(location))
    const cargoColor = new THREE.Color(getCargoColor(location))
    const cargoLidColor = new THREE.Color(getCargoLidColor(location))
    const postOffsets = [
        [-0.76, -0.76],
        [-0.76, 0.76],
        [0.76, -0.76],
        [0.76, 0.76]
    ]
    dummy.position.set(x, y + 0.12, z)
    dummy.rotation.set(0, 0, 0)
    dummy.scale.set(1, 1, 1)
    dummy.updateMatrix()
    locationMeshes.base.setMatrixAt(instanceId, dummy.matrix)
    locationMeshes.base.setColorAt(instanceId, shelfColor)
    dummy.position.set(x, y + 0.34, z)
    dummy.scale.set(showPallet ? 1 : 0.001, showPallet ? 1 : 0.001, showPallet ? 1 : 0.001)
    dummy.updateMatrix()
    locationMeshes.pallet.setMatrixAt(instanceId, dummy.matrix)
    locationMeshes.pallet.setColorAt(instanceId, palletColor)
    dummy.position.set(x, y + 0.86, z)
    dummy.scale.set(showCargo ? 1 : 0.001, showCargo ? 1 : 0.001, showCargo ? 1 : 0.001)
    dummy.updateMatrix()
    locationMeshes.cargoBody.setMatrixAt(instanceId, dummy.matrix)
    locationMeshes.cargoBody.setColorAt(instanceId, cargoColor)
    dummy.position.set(x, y + 1.25, z)
    dummy.scale.set(showCargo ? 1 : 0.001, showCargo ? 1 : 0.001, showCargo ? 1 : 0.001)
    dummy.updateMatrix()
    locationMeshes.cargoLid.setMatrixAt(instanceId, dummy.matrix)
    locationMeshes.cargoLid.setColorAt(instanceId, cargoLidColor)
    postOffsets.forEach((offset, postIndex) => {
        dummy.position.set(x + offset[0], postCenterY, z + offset[1])
        dummy.rotation.set(0, 0, 0)
        dummy.scale.set(1, postHeight, 1)
        dummy.updateMatrix()
        locationMeshes.posts.setMatrixAt(instanceId * 4 + postIndex, dummy.matrix)
    })
    locationMeshes.base.instanceMatrix.needsUpdate = true
    locationMeshes.pallet.instanceMatrix.needsUpdate = true
    locationMeshes.cargoBody.instanceMatrix.needsUpdate = true
    locationMeshes.cargoLid.instanceMatrix.needsUpdate = true
    locationMeshes.posts.instanceMatrix.needsUpdate = true
    locationMeshes.base.instanceColor.needsUpdate = true
    locationMeshes.pallet.instanceColor.needsUpdate = true
    locationMeshes.cargoBody.instanceColor.needsUpdate = true
    locationMeshes.cargoLid.instanceColor.needsUpdate = true
}
function syncLocationUpdate(list, update) {
    const index = list.findIndex((item) => item.locationId === update.locationId)
    if (index === -1) {
        return
    }
    list[index] = {
        ...list[index],
        stockQuantity: update.stockQuantity,
        stockStatus: update.stockStatus,
        palletCode: update.palletCode,
        locationStatus: update.locationStatus,
        details: update.details && update.details.length > 0 ? update.details : list[index].details
    }
}
function initSignalR() {
    proxy.http.post('api/User/GetCurrentUserInfo').then((result) => {
        connection = new signalR.HubConnectionBuilder()
            .withAutomaticReconnect()
            .withUrl(`${proxy.http.ipAddress}stockHub?userName=${result.data.userName}`)
            .build()
        connection.start().catch((error) => console.log('SignalR连接失败:', error))
        connection.on('StockUpdated', (update) => {
            console.log("🚀 ~ initSignalR ~ update:", update)
            syncLocationUpdate(locationData, update)
            syncLocationUpdate(originalLocationData, update)
            const nextLocation = originalLocationData.find((item) => item.locationId === update.locationId)
            const instanceId = locationIdToInstanceId.get(update.locationId)
            const shouldRender = nextLocation && matchesCurrentFilters(nextLocation)
            if (!nextLocation) {
                return
            }
            if (instanceId !== undefined && !shouldRender) {
                renderLocations()
                return
            }
            if (instanceId === undefined && shouldRender) {
                renderLocations()
                return
            }
            if (instanceId !== undefined && shouldRender) {
                renderedLocations[instanceId] = nextLocation
                updateInstanceVisual(instanceId, nextLocation)
            }
        })
    })
}
async function loadWarehouseList() {
    try {
        const res = await proxy.http.get('/api/Warehouse/GetAll')
        if (res.status && res.data) {
            warehouseList.value = res.data
            if (res.data.length > 0) {
                activeWarehouse.value = res.data[0].warehouseId || res.data[0].id
                await loadWarehouseData(activeWarehouse.value)
            }
        }
    } catch (error) {
        console.error('加载仓库列表失败', error)
    }
}
async function loadWarehouseData(warehouseId) {
    try {
        const res = await proxy.http.get(`/api/StockInfo/Get3DLayout?warehouseId=${warehouseId}`)
        if (res.status && res.data) {
            const data = res.data
            originalLocationData = data.locations || []
            locationData = [...originalLocationData]
            materielCodeList.value = data.materielCodeList || []
            batchNoList.value = data.batchNoList || []
            renderLocations()
        }
    } catch (error) {
        console.error('加载货位数据失败', error)
    }
}
function initThreeJS() {
    if (!canvasContainer.value) {
        return
    }
    const width = canvasContainer.value.clientWidth
    const height = canvasContainer.value.clientHeight
    scene = new THREE.Scene()
    scene.background = createBackgroundTexture()
    scene.fog = new THREE.Fog(0xdeE7f1, 36, 92)
    camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)
    camera.position.set(22, 20, 22)
    camera.lookAt(0, 0, 0)
    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
    renderer.setPixelRatio(window.devicePixelRatio)
    renderer.setSize(width, height)
    renderer.shadowMap.enabled = true
    renderer.shadowMap.type = THREE.PCFSoftShadowMap
    renderer.outputColorSpace = THREE.SRGBColorSpace
    canvasContainer.value.appendChild(renderer.domElement)
    canvasContainer.value.addEventListener('contextmenu', preventCanvasContextMenu)
    canvasContainer.value.addEventListener('mousedown', preventRightMouseDefault)
    renderer.domElement.addEventListener('contextmenu', preventCanvasContextMenu)
    renderer.domElement.addEventListener('mousedown', preventRightMouseDefault)
    controls = new OrbitControls(camera, renderer.domElement)
    controls.enableDamping = true
    controls.dampingFactor = 0.06
    controls.enablePan = true
    controls.screenSpacePanning = true
    controls.zoomToCursor = true
    controls.panSpeed = 1.15
    controls.rotateSpeed = 0.75
    controls.minDistance = 2
    controls.maxDistance = 160
    controls.maxPolarAngle = Math.PI
    controls.touches = {
        ONE: THREE.TOUCH.PAN,
        TWO: THREE.TOUCH.DOLLY_ROTATE
    }
    updateControlMouseBinding()
    const hemisphereLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d0d9, 1.15)
    scene.add(hemisphereLight)
    const keyLight = new THREE.DirectionalLight(0xffffff, 1.25)
    keyLight.position.set(24, 36, 18)
    keyLight.castShadow = true
    keyLight.shadow.mapSize.width = 2048
    keyLight.shadow.mapSize.height = 2048
    keyLight.shadow.camera.near = 1
    keyLight.shadow.camera.far = 120
    keyLight.shadow.camera.left = -44
    keyLight.shadow.camera.right = 44
    keyLight.shadow.camera.top = 44
    keyLight.shadow.camera.bottom = -44
    scene.add(keyLight)
    const fillLight = new THREE.DirectionalLight(0xd9ebff, 0.55)
    fillLight.position.set(-18, 18, -24)
    scene.add(fillLight)
    const rimLight = new THREE.DirectionalLight(0xfff3d6, 0.35)
    rimLight.position.set(0, 16, 30)
    scene.add(rimLight)
    const groundGeometry = new THREE.PlaneGeometry(120, 120)
    const groundMaterial = new THREE.MeshStandardMaterial({
        color: 0xe4eaf1,
        map: createFloorTexture(),
        roughness: 0.94,
        metalness: 0.02
    })
    const ground = new THREE.Mesh(groundGeometry, groundMaterial)
    ground.rotation.x = -Math.PI / 2
    ground.position.y = -0.5
    ground.receiveShadow = true
    scene.add(ground)
    const gridHelper = new THREE.GridHelper(100, 20, 0x8da0b8, 0xc8d2de)
    gridHelper.position.y = -0.49
    gridHelper.material.transparent = true
    gridHelper.material.opacity = 0.18
    scene.add(gridHelper)
    raycaster = new THREE.Raycaster()
    mouse = new THREE.Vector2()
    const outlineGeometry = new THREE.BoxGeometry(2.3, 2.1, 2.3)
    const outlineMaterial = new THREE.MeshBasicMaterial({
        color: 0xffc857,
        wireframe: true,
        transparent: true,
        opacity: 0.92
    })
    selectionOutline = new THREE.Mesh(outlineGeometry, outlineMaterial)
    selectionOutline.visible = false
    scene.add(selectionOutline)
    canvasContainer.value.addEventListener('pointerdown', onCanvasPointerDown)
    canvasContainer.value.addEventListener('pointerup', onCanvasPointerUp)
    animate()
}
function renderLocations() {
    if (!scene) {
        return
    }
    if (originalLocationData.length === 0) {
        clearLocationMeshes()
        clearAisleModels()
        renderedLocations = []
        if (selectionOutline) {
            selectionOutline.visible = false
        }
        return
    }
    clearLocationMeshes()
    renderedLocations = getFilteredLocations()
    locationIdToInstanceId.clear()
    if (renderedLocations.length === 0) {
        clearAisleModels()
        if (selectionOutline) {
            selectionOutline.visible = false
        }
        return
    }
    const layoutInfo = getLayoutInfo()
    renderAisleEquipment(layoutInfo)
    const baseGeometry = new THREE.BoxGeometry(1.76, 0.24, 1.76)
    const palletGeometry = new THREE.BoxGeometry(1.42, 0.16, 1.42)
    const cargoBodyGeometry = new THREE.BoxGeometry(1.08, 0.7, 1.08)
    const cargoLidGeometry = new THREE.BoxGeometry(1.16, 0.08, 1.16)
    const postGeometry = new THREE.BoxGeometry(0.1, 1, 0.1)
    locationMeshes = {
        base: new THREE.InstancedMesh(baseGeometry, new THREE.MeshPhysicalMaterial({
            color: 0xffffff,
            roughness: 0.46,
            metalness: 0.28,
            clearcoat: 0.18
        }), renderedLocations.length),
        pallet: new THREE.InstancedMesh(palletGeometry, new THREE.MeshStandardMaterial({
            color: 0xffffff,
            roughness: 0.84,
            metalness: 0.04
        }), renderedLocations.length),
        cargoBody: new THREE.InstancedMesh(cargoBodyGeometry, new THREE.MeshPhysicalMaterial({
            color: 0xffffff,
            roughness: 0.5,
            metalness: 0.05,
            clearcoat: 0.22,
            clearcoatRoughness: 0.28
        }), renderedLocations.length),
        cargoLid: new THREE.InstancedMesh(cargoLidGeometry, new THREE.MeshStandardMaterial({
            color: 0xffffff,
            roughness: 0.44,
            metalness: 0.08
        }), renderedLocations.length),
        posts: new THREE.InstancedMesh(postGeometry, new THREE.MeshStandardMaterial({
            color: POST_COLOR,
            roughness: 0.48,
            metalness: 0.24
        }), renderedLocations.length * 4)
    }
    Object.values(locationMeshes).forEach((mesh) => {
        mesh.castShadow = true
        mesh.receiveShadow = true
    })
    pickableMeshes = [locationMeshes.base, locationMeshes.pallet, locationMeshes.cargoBody, locationMeshes.cargoLid]
    renderedLocations.forEach((location, index) => {
        updateInstanceVisual(index, location)
        locationIdToInstanceId.set(location.locationId, index)
    })
    Object.values(locationMeshes).forEach((mesh) => {
        mesh.instanceMatrix.needsUpdate = true
        if (mesh.instanceColor) {
            mesh.instanceColor.needsUpdate = true
        }
        scene.add(mesh)
    })
}
function animate() {
    animationId = requestAnimationFrame(animate)
    if (controls) {
        controls.update()
    }
    if (selectionOutline && selectionOutline.visible) {
        selectionOutline.material.opacity = 0.74 + Math.sin(Date.now() * 0.006) * 0.15
    }
    if (renderer && scene && camera) {
        renderer.render(scene, camera)
    }
}
function focusCamera(location) {
  if (!camera || !controls) return
  const targetX = (location.column - 1) * 2
  const targetY = location.layer * 1.5
  const targetZ = (location.row - 1) * 2
  const offsetX = 5
  const offsetY = 5
  const offsetZ = 5
  const targetPosition = new THREE.Vector3(targetX + offsetX, targetY + offsetY, targetZ + offsetZ)
  // 使用 lerp 平滑移动
  const startPosition = camera.position.clone()
  const duration = 500
  const startTime = Date.now()
  function lerpMove() {
    const elapsed = Date.now() - startTime
    const t = Math.min(elapsed / duration, 1)
    const easeT = 1 - Math.pow(1 - t, 3) // easeOutCubic
    camera.position.lerpVectors(startPosition, targetPosition, easeT)
    controls.target.set(targetX, targetY, targetZ)
    if (t < 1) {
      requestAnimationFrame(lerpMove)
    if (!camera || !controls) {
        return
    }
  }
  lerpMove()
    const { x: targetX, y, z: targetZ } = getLocationWorldPosition(location)
    const targetY = y + 0.9
    const currentDirection = camera.position.clone().sub(controls.target).normalize()
    const fallbackDirection = new THREE.Vector3(1, 0.85, 1).normalize()
    const focusDirection = currentDirection.lengthSq() > 0 ? currentDirection : fallbackDirection
    const targetPosition = new THREE.Vector3(targetX, targetY, targetZ).add(focusDirection.multiplyScalar(8.5))
    const startPosition = camera.position.clone()
    const duration = 500
    const startTime = Date.now()
    if (selectionOutline) {
        selectionOutline.position.set(targetX, location.layer * 1.5 + 0.9, targetZ)
        selectionOutline.visible = true
    }
    function lerpMove() {
        const elapsed = Date.now() - startTime
        const progress = Math.min(elapsed / duration, 1)
        const easedProgress = 1 - Math.pow(1 - progress, 3)
        camera.position.lerpVectors(startPosition, targetPosition, easedProgress)
        controls.target.set(targetX, targetY, targetZ)
        if (progress < 1) {
            requestAnimationFrame(lerpMove)
        }
    }
    lerpMove()
}
// 重置相机
function getCanvasHit(event) {
    if (!canvasContainer.value || pickableMeshes.length === 0) {
        return null
    }
    const rect = canvasContainer.value.getBoundingClientRect()
    mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
    mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
    raycaster.setFromCamera(mouse, camera)
    const intersects = raycaster.intersectObjects(pickableMeshes, false)
    if (intersects.length === 0) {
        return null
    }
    const instanceId = intersects[0].instanceId
    if (instanceId === undefined || instanceId >= renderedLocations.length) {
        return null
    }
    return {
        instanceId,
        location: renderedLocations[instanceId]
    }
}
function onCanvasPointerDown(event) {
    updateControlMouseBinding(event.shiftKey)
    if (event.button !== 0) {
        pointerDownHit = null
        pointerDownPosition = null
        return
    }
    if (event.shiftKey) {
        pointerDownHit = null
        pointerDownPosition = null
        return
    }
    const hit = getCanvasHit(event)
    pointerDownHit = hit
    pointerDownPosition = {
        x: event.clientX,
        y: event.clientY
    }
}
function onCanvasPointerUp(event) {
    updateControlMouseBinding(event.shiftKey)
    if (event.button !== 0) {
        pointerDownHit = null
        pointerDownPosition = null
        return
    }
    if (event.shiftKey) {
        pointerDownHit = null
        pointerDownPosition = null
        return
    }
    const hit = getCanvasHit(event)
    if (!pointerDownHit || !hit || !pointerDownPosition) {
        pointerDownHit = null
        pointerDownPosition = null
        return
    }
    const moveDistance = Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y)
    const isSameLocation = pointerDownHit.instanceId === hit.instanceId
    if (!isSameLocation || moveDistance > 6) {
        pointerDownHit = null
        pointerDownPosition = null
        return
    }
    selectedLocation.value = hit.location
    detailDialogVisible.value = true
    focusCamera(selectedLocation.value)
    pointerDownHit = null
    pointerDownPosition = null
}
function resetCamera() {
  if (!camera || !controls) return
  camera.position.set(20, 20, 20)
  controls.target.set(0, 0, 0)
}
// 刷新数据
async function refreshData() {
  refreshing.value = true
  try {
    // 重置筛选条件
    filterStockStatus.value = null
    filterMaterielCode.value = null
    filterBatchNo.value = null
    // 重新加载当前仓库数据
    if (activeWarehouse.value) {
      await loadWarehouseData(activeWarehouse.value)
    if (!camera || !controls) {
        return
    }
  } finally {
    refreshing.value = false
  }
    camera.position.set(22, 20, 22)
    controls.target.set(0, 0, 0)
    if (selectionOutline) {
        selectionOutline.visible = false
    }
}
// 仓库切换
async function refreshData() {
    refreshing.value = true
    try {
        filterStockStatus.value = null
        filterMaterielCode.value = null
        filterBatchNo.value = null
        if (activeWarehouse.value) {
            await loadWarehouseData(activeWarehouse.value)
        }
    } finally {
        refreshing.value = false
    }
}
async function onWarehouseChange(warehouseId) {
  await loadWarehouseData(warehouseId)
    await loadWarehouseData(warehouseId)
}
// 监听筛选变化
watch(filterStockStatus, async () => {
  await nextTick()
  if (originalLocationData.length > 0) {
    renderLocations()
  }
})
watch(filterMaterielCode, async () => {
  await nextTick()
  if (originalLocationData.length > 0) {
    renderLocations()
  }
})
watch(filterBatchNo, async () => {
  await nextTick()
  if (originalLocationData.length > 0) {
    renderLocations()
  }
})
// 窗口大小变化
function onWindowResize() {
  if (!canvasContainer.value || !camera || !renderer) return
  const width = canvasContainer.value.clientWidth
  const height = canvasContainer.value.clientHeight
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height)
    if (!canvasContainer.value || !camera || !renderer) {
        return
    }
    const width = canvasContainer.value.clientWidth
    const height = canvasContainer.value.clientHeight
    camera.aspect = width / height
    camera.updateProjectionMatrix()
    renderer.setSize(width, height)
}
// 组件挂载
onMounted(() => {
  initThreeJS()
  loadWarehouseList()
  initSignalR()
  window.addEventListener('resize', onWindowResize)
watch(filterStockStatus, async () => {
    await nextTick()
    renderLocations()
})
// 组件卸载
watch(filterMaterielCode, async () => {
    await nextTick()
    renderLocations()
})
watch(filterBatchNo, async () => {
    await nextTick()
    renderLocations()
})
onMounted(() => {
    initThreeJS()
    loadWarehouseList()
    initSignalR()
    window.addEventListener('keydown', onKeyDown)
    window.addEventListener('keyup', onKeyUp)
    window.addEventListener('blur', onWindowBlur)
    window.addEventListener('resize', onWindowResize)
})
onUnmounted(() => {
  if (animationId) {
    cancelAnimationFrame(animationId)
  }
  if (canvasContainer.value) {
    canvasContainer.value.removeEventListener('mousedown', onCanvasClick)
  }
  window.removeEventListener('resize', onWindowResize)
  if (renderer) {
    renderer.dispose()
  }
  if (connection) {
    connection.stop()
  }
    if (animationId) {
        cancelAnimationFrame(animationId)
    }
    if (canvasContainer.value) {
        canvasContainer.value.removeEventListener('contextmenu', preventCanvasContextMenu)
        canvasContainer.value.removeEventListener('mousedown', preventRightMouseDefault)
        canvasContainer.value.removeEventListener('pointerdown', onCanvasPointerDown)
        canvasContainer.value.removeEventListener('pointerup', onCanvasPointerUp)
    }
    window.removeEventListener('keydown', onKeyDown)
    window.removeEventListener('keyup', onKeyUp)
    window.removeEventListener('blur', onWindowBlur)
    window.removeEventListener('resize', onWindowResize)
    clearLocationMeshes()
    clearAisleModels()
    if (selectionOutline) {
        scene.remove(selectionOutline)
        selectionOutline.geometry.dispose()
        selectionOutline.material.dispose()
    }
    if (renderer) {
        renderer.domElement.removeEventListener('contextmenu', preventCanvasContextMenu)
        renderer.domElement.removeEventListener('mousedown', preventRightMouseDefault)
        renderer.dispose()
    }
    if (connection) {
        connection.stop()
    }
})
</script>
<style scoped>
.stock-chat-container {
  width: 100%;
  height: calc(100vh - 120px);
  position: relative;
  overflow: visible;
    width: 100%;
    height: calc(100vh - 120px);
    position: relative;
    overflow: visible;
    background: linear-gradient(180deg, #f6f9fc 0%, #edf2f7 100%);
    border-radius: 12px;
}
.toolbar {
  position: absolute;
  top: 60px;
  left: 20px;
  z-index: 10;
  display: flex;
  gap: 10px;
  background: rgba(255, 255, 255, 0.9);
  padding: 10px;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
    position: absolute;
    top: 60px;
    left: 20px;
    z-index: 10;
    display: flex;
    gap: 10px;
    background: rgba(255, 255, 255, 0.82);
    padding: 12px;
    border-radius: 12px;
    border: 1px solid rgba(148, 163, 184, 0.18);
    box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
    backdrop-filter: blur(14px);
}
.canvas-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
    width: 100%;
    height: 100%;
    overflow: hidden;
    border-radius: 12px;
}
.legend {
  position: absolute;
  bottom: 20px;
  right: 20px;
  background: rgba(0, 0, 0, 0.7);
  padding: 10px;
  border-radius: 4px;
  color: white;
  z-index: 10;
  max-height: 400px;
  overflow-y: auto;
    position: absolute;
    bottom: 20px;
    right: 20px;
    z-index: 10;
    width: 220px;
    background: rgba(15, 23, 42, 0.78);
    padding: 14px 16px;
    border-radius: 14px;
    color: #fff;
    box-shadow: 0 16px 36px rgba(15, 23, 42, 0.24);
    backdrop-filter: blur(10px);
    max-height: 460px;
    overflow-y: auto;
}
.legend-title {
  font-weight: bold;
  font-size: 13px;
  margin-bottom: 6px;
  color: #fff;
    font-weight: 700;
    font-size: 14px;
    margin-bottom: 8px;
}
.legend-subtitle {
    font-size: 12px;
    color: rgba(226, 232, 240, 0.8);
    margin-bottom: 6px;
}
.legend-divider {
  height: 1px;
  background: rgba(255, 255, 255, 0.3);
  margin: 8px 0;
    height: 1px;
    background: rgba(255, 255, 255, 0.16);
    margin: 10px 0;
}
.legend-item {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
  font-size: 12px;
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 6px;
    font-size: 12px;
}
.legend-item--shape {
    color: rgba(255, 255, 255, 0.92);
}
.color-box {
  width: 16px;
  height: 16px;
  border-radius: 2px;
  flex-shrink: 0;
    width: 16px;
    height: 16px;
    border-radius: 5px;
    flex-shrink: 0;
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
}
.shape-pill {
    display: inline-block;
    width: 24px;
    height: 12px;
    border-radius: 6px;
    position: relative;
    flex-shrink: 0;
}
.shape-pill::after {
    content: '';
    position: absolute;
    left: 2px;
    right: 2px;
    bottom: -4px;
    height: 4px;
    border-radius: 4px;
}
.shape-pill--empty {
    width: 26px;
    height: 6px;
    border-radius: 4px;
    background: linear-gradient(180deg, #b8c6d8 0%, #7f90a8 100%);
}
.shape-pill--empty::after {
    display: none;
}
.shape-pill--pallet {
    background: linear-gradient(180deg, #e7c997 0%, #bf8851 100%);
}
.shape-pill--pallet::after {
    background: #7f8fa5;
}
.shape-pill--cargo {
    background: linear-gradient(180deg, #7ab6ff 0%, #2b7fff 100%);
    height: 14px;
}
.shape-pill--cargo::after {
    background: #bf8851;
}
.detail-content {
  padding: 20px;
    padding: 20px;
}
.detail-table {
  margin-top: 20px;
    margin-top: 20px;
}
.detail-table h4 {
  margin-bottom: 10px;
  color: #303133;
    margin-bottom: 10px;
    color: #303133;
}
.no-detail {
  margin-top: 20px;
    margin-top: 20px;
}
:deep(.el-tabs__nav-wrap::after) {
    background-color: rgba(148, 163, 184, 0.22);
}
:deep(.el-drawer__body) {
  padding: 20px;
    padding: 20px;
}
</style>
Code/WMS/WIDESEA_WMSServer/.vs/WIDESEA_WMSServer/CopilotIndices/18.0.988.22099/CodeChunks.db
Binary files differ
Code/WMS/WIDESEA_WMSServer/.vs/WIDESEA_WMSServer/CopilotIndices/18.0.988.22099/SemanticSymbols.db
Binary files differ
Code/WMS/WIDESEA_WMSServer/WIDESEA_BasicService/LocationInfoService.cs
@@ -11,6 +11,7 @@
using WIDESEA_DTO.Basic;
using WIDESEA_DTO.Task;
using WIDESEA_IBasicService;
using WIDESEA_IRecordService;
using WIDESEA_Model.Models;
namespace WIDESEA_BasicService
@@ -23,6 +24,7 @@
        private readonly IMapper _mapper;
        private readonly IRepository<Dt_Task> _taskRepository;
        private readonly IRepository<Dt_StockInfo> _stockInfoRepository;
        private readonly IRecordService _recordService;
        /// <summary>
        /// 构造函数
@@ -34,11 +36,13 @@
            IRepository<Dt_LocationInfo> baseDal,
            IRepository<Dt_Task> taskRepository,
            IRepository<Dt_StockInfo> stockInfoRepository,
            IMapper mapper) : base(baseDal)
            IMapper mapper,
            IRecordService recordService) : base(baseDal)
        {
            _taskRepository = taskRepository;
            _stockInfoRepository = stockInfoRepository;
            _mapper = mapper;
            _recordService = recordService;
        }
        /// <summary>
@@ -172,7 +176,48 @@
        /// <returns>更新是否成功</returns>
        public async Task<bool> UpdateLocationInfoAsync(Dt_LocationInfo locationInfo)
        {
            return await BaseDal.UpdateDataAsync(locationInfo);
            var beforeLocation = await BaseDal.QueryFirstAsync(x => x.Id == locationInfo.Id);
            var result = await BaseDal.UpdateDataAsync(locationInfo);
            if (!result)
                return false;
            return beforeLocation == null
                || await _recordService.AddLocationChangeRecordAsync(beforeLocation, locationInfo, LocationChangeType.HandUpdate, remark: "货位更新");
        }
        public override WebResponseContent UpdateData(Dt_LocationInfo entity)
        {
            var beforeLocation = BaseDal.QueryFirst(x => x.Id == entity.Id);
            var result = base.UpdateData(entity);
            if (!result.Status || beforeLocation == null)
                return result;
            var saveRecordResult = _recordService.AddLocationChangeRecordAsync(beforeLocation, entity, LocationChangeType.HandUpdate, remark: "货位更新").GetAwaiter().GetResult();
            return saveRecordResult ? result : WebResponseContent.Instance.Error("货位状态变更记录保存失败");
        }
        public override WebResponseContent UpdateData(List<Dt_LocationInfo> entities)
        {
            var beforeLocations = entities
                .Select(entity => BaseDal.QueryFirst(x => x.Id == entity.Id))
                .Where(location => location != null)
                .ToDictionary(location => location!.Id, location => location!);
            var result = base.UpdateData(entities);
            if (!result.Status)
                return result;
            foreach (var entity in entities)
            {
                if (!beforeLocations.TryGetValue(entity.Id, out var beforeLocation))
                    continue;
                var saveRecordResult = _recordService.AddLocationChangeRecordAsync(beforeLocation, entity, LocationChangeType.HandUpdate, remark: "批量货位更新").GetAwaiter().GetResult();
                if (!saveRecordResult)
                    return WebResponseContent.Instance.Error("货位状态变更记录保存失败");
            }
            return result;
        }
        /// <summary>
@@ -281,6 +326,9 @@
            };
            var createdTask = await _taskRepository.Db.Insertable(newTransferTask).ExecuteReturnEntityAsync();
            var beforeStock = CloneStockSnapshot(stockInfo);
            var beforeSourceLocation = stockInfo.LocationDetails == null ? null : CloneLocationSnapshot(stockInfo.LocationDetails);
            var beforeTargetLocation = CloneLocationSnapshot(emptyLocation);
            // 创建移库任务后,立即锁定库存和相关货位,避免并发重复分配
            stockInfo.StockStatus = StockStatusEmun.移库锁定.GetHashCode();
@@ -302,9 +350,104 @@
                throw new Exception("创建移库任务后更新库存状态或货位状态失败");
            }
            var saveStockRecordResult = await _recordService.AddStockChangeRecordAsync(
                beforeStock,
                stockInfo,
                StockChangeTypeEnum.Relocation,
                createdTask.TaskNum,
                createdTask.OrderNo,
                "移库任务预占库存");
            if (!saveStockRecordResult)
            {
                throw new Exception("创建移库任务后记录库存变更失败");
            }
            if (beforeSourceLocation != null && stockInfo.LocationDetails != null)
            {
                var saveSourceLocationRecordResult = await _recordService.AddLocationChangeRecordAsync(
                    beforeSourceLocation,
                    stockInfo.LocationDetails,
                    LocationChangeType.RelocationAssignLocation,
                    createdTask.TaskNum,
                    createdTask.OrderNo,
                    null,
                    "移库任务锁定源货位");
                if (!saveSourceLocationRecordResult)
                {
                    throw new Exception("创建移库任务后记录源货位变更失败");
                }
            }
            var saveTargetLocationRecordResult = await _recordService.AddLocationChangeRecordAsync(
                beforeTargetLocation,
                emptyLocation,
                LocationChangeType.RelocationAssignLocation,
                createdTask.TaskNum,
                createdTask.OrderNo,
                null,
                "移库任务锁定目标货位");
            if (!saveTargetLocationRecordResult)
            {
                throw new Exception("创建移库任务后记录目标货位变更失败");
            }
            return createdTask;
        }
        private static Dt_LocationInfo CloneLocationSnapshot(Dt_LocationInfo location)
        {
            return new Dt_LocationInfo
            {
                Id = location.Id,
                WarehouseId = location.WarehouseId,
                LocationCode = location.LocationCode,
                LocationName = location.LocationName,
                RoadwayNo = location.RoadwayNo,
                Row = location.Row,
                Column = location.Column,
                Layer = location.Layer,
                Depth = location.Depth,
                LocationType = location.LocationType,
                LocationStatus = location.LocationStatus,
                EnableStatus = location.EnableStatus,
                Remark = location.Remark
            };
        }
        private static Dt_StockInfo CloneStockSnapshot(Dt_StockInfo stockInfo)
        {
            return new Dt_StockInfo
            {
                Id = stockInfo.Id,
                PalletCode = stockInfo.PalletCode,
                PalletType = stockInfo.PalletType,
                LocationId = stockInfo.LocationId,
                LocationCode = stockInfo.LocationCode,
                WarehouseId = stockInfo.WarehouseId,
                StockStatus = stockInfo.StockStatus,
                Remark = stockInfo.Remark,
                OutboundDate = stockInfo.OutboundDate,
                Details = stockInfo.Details?.Select(detail => new Dt_StockInfoDetail
                {
                    Id = detail.Id,
                    StockId = detail.StockId,
                    MaterielCode = detail.MaterielCode,
                    MaterielName = detail.MaterielName,
                    OrderNo = detail.OrderNo,
                    BatchNo = detail.BatchNo,
                    ProductionDate = detail.ProductionDate,
                    EffectiveDate = detail.EffectiveDate,
                    SerialNumber = detail.SerialNumber,
                    StockQuantity = detail.StockQuantity,
                    OutboundQuantity = detail.OutboundQuantity,
                    Status = detail.Status,
                    Unit = detail.Unit,
                    InboundOrderRowNo = detail.InboundOrderRowNo,
                    Remark = detail.Remark
                }).ToList()
            };
        }
        /// <summary>
        /// 检查货位是否需要移库
        /// </summary>
Code/WMS/WIDESEA_WMSServer/WIDESEA_DTO/Stock/Stock3DLayoutDTO.cs
@@ -1,3 +1,5 @@
using SqlSugar;
namespace WIDESEA_DTO.Stock
{
    /// <summary>
@@ -117,6 +119,11 @@
        public string? BatchNo { get; set; }
        /// <summary>
        /// 出库日期
        /// </summary>
        public DateTime OutboundDate { get; set; }
        /// <summary>
        /// 库存明细列表
        /// </summary>
        public List<StockDetailItemDTO> Details { get; set; } = new();
@@ -137,5 +144,7 @@
        public string? EffectiveDate { get; set; }
        public string? OrderNo { get; set; }
        public int Status { get; set; }
        public string SerialNumber { get; set; }
        public int InboundOrderRowNo { get; set; }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_IRecordService/ILocationStatusChangeRecordService.cs
@@ -1,4 +1,5 @@
using WIDESEA_Core.BaseRepository;
using WIDESEA_Common.LocationEnum;
using WIDESEA_Core.BaseRepository;
using WIDESEA_Core.BaseServices;
using WIDESEA_Model.Models;
@@ -13,5 +14,17 @@
        /// 获取货位状态变更记录仓储接口
        /// </summary>
        IRepository<Dt_LocationStatusChangeRecord> Repository { get; }
        /// <summary>
        /// 记录货位状态变更。
        /// </summary>
        Task<bool> AddChangeRecordAsync(
            Dt_LocationInfo beforeLocation,
            Dt_LocationInfo afterLocation,
            LocationChangeType changeType,
            int? taskNum = null,
            string? orderNo = null,
            int? orderId = null,
            string? remark = null);
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_IRecordService/IRecordService.cs
@@ -1,4 +1,7 @@
using WIDESEA_Core;
using WIDESEA_Common.LocationEnum;
using WIDESEA_Common.StockEnum;
using WIDESEA_Core;
using WIDESEA_Model.Models;
namespace WIDESEA_IRecordService
{
@@ -16,5 +19,28 @@
        /// 库存数量变更记录服务
        /// </summary>
        IStockQuantityChangeRecordService StockQuantityChangeRecordService { get; }
        /// <summary>
        /// 新增货位状态变更记录
        /// </summary>
        Task<bool> AddLocationChangeRecordAsync(
            Dt_LocationInfo beforeLocation,
            Dt_LocationInfo afterLocation,
            LocationChangeType changeType,
            int? taskNum = null,
            string? orderNo = null,
            int? orderId = null,
            string? remark = null);
        /// <summary>
        /// 新增库存变更记录
        /// </summary>
        Task<bool> AddStockChangeRecordAsync(
            Dt_StockInfo? beforeStock,
            Dt_StockInfo? afterStock,
            StockChangeTypeEnum changeType,
            int? taskNum = null,
            string? orderNo = null,
            string? remark = null);
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_IRecordService/IStockQuantityChangeRecordService.cs
@@ -1,4 +1,5 @@
using WIDESEA_Core.BaseRepository;
using WIDESEA_Common.StockEnum;
using WIDESEA_Core.BaseRepository;
using WIDESEA_Core.BaseServices;
using WIDESEA_Model.Models;
@@ -13,5 +14,16 @@
        /// 获取库存数量变更记录仓储接口
        /// </summary>
        IRepository<Dt_StockQuantityChangeRecord> Repository { get; }
        /// <summary>
        /// 记录库存变更。
        /// </summary>
        Task<bool> AddChangeRecordAsync(
            Dt_StockInfo? beforeStock,
            Dt_StockInfo? afterStock,
            StockChangeTypeEnum changeType,
            int? taskNum = null,
            string? orderNo = null,
            string? remark = null);
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_Model/Models/Record/Dt_StockQuantityChangeRecord.cs
@@ -93,6 +93,42 @@
        public float AfterQuantity { get; set; }
        /// <summary>
        /// 变动前库存状态
        /// </summary>
        [SugarColumn(IsNullable = false, ColumnDescription = "变动前库存状态", DefaultValue = "0")]
        public int BeforeStatus { get; set; }
        /// <summary>
        /// 变动后库存状态
        /// </summary>
        [SugarColumn(IsNullable = false, ColumnDescription = "变动后库存状态", DefaultValue = "0")]
        public int AfterStatus { get; set; }
        /// <summary>
        /// 变动前货位主键
        /// </summary>
        [SugarColumn(IsNullable = true, ColumnDescription = "变动前货位主键")]
        public int? BeforeLocationId { get; set; }
        /// <summary>
        /// 变动后货位主键
        /// </summary>
        [SugarColumn(IsNullable = true, ColumnDescription = "变动后货位主键")]
        public int? AfterLocationId { get; set; }
        /// <summary>
        /// 变动前货位编号
        /// </summary>
        [SugarColumn(IsNullable = true, Length = 30, ColumnDescription = "变动前货位编号")]
        public string BeforeLocationCode { get; set; }
        /// <summary>
        /// 变动后货位编号
        /// </summary>
        [SugarColumn(IsNullable = true, Length = 30, ColumnDescription = "变动后货位编号")]
        public string AfterLocationCode { get; set; }
        /// <summary>
        /// 备注
        /// </summary>
        [SugarColumn(IsNullable = true, ColumnDescription = "备注")]
Code/WMS/WIDESEA_WMSServer/WIDESEA_RecordService/LocationStatusChangeRecordService.cs
@@ -1,4 +1,5 @@
using WIDESEA_Core.BaseRepository;
using WIDESEA_Common.LocationEnum;
using WIDESEA_Core.BaseRepository;
using WIDESEA_Core.BaseServices;
using WIDESEA_IRecordService;
using WIDESEA_Model.Models;
@@ -13,7 +14,6 @@
        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="baseDal">基础数据访问对象</param>
        public LocationStatusChangeRecordService(IRepository<Dt_LocationStatusChangeRecord> baseDal) : base(baseDal)
        {
        }
@@ -22,5 +22,39 @@
        /// 获取货位状态变更记录仓储接口
        /// </summary>
        public IRepository<Dt_LocationStatusChangeRecord> Repository => BaseDal;
        /// <summary>
        /// 记录货位状态变更。
        /// </summary>
        public async Task<bool> AddChangeRecordAsync(
            Dt_LocationInfo beforeLocation,
            Dt_LocationInfo afterLocation,
            LocationChangeType changeType,
            int? taskNum = null,
            string? orderNo = null,
            int? orderId = null,
            string? remark = null)
        {
            if (beforeLocation == null || afterLocation == null)
                return false;
            if (beforeLocation.LocationStatus == afterLocation.LocationStatus)
                return true;
            Dt_LocationStatusChangeRecord record = new Dt_LocationStatusChangeRecord
            {
                LocationId = afterLocation.Id,
                LocationCode = afterLocation.LocationCode,
                BeforeStatus = beforeLocation.LocationStatus,
                AfterStatus = afterLocation.LocationStatus,
                ChangeType = (int)changeType,
                OrderId = orderId,
                OrderNo = orderNo,
                TaskNum = taskNum,
                Remark = remark
            };
            return await BaseDal.AddDataAsync(record) > 0;
        }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_RecordService/RecordService.cs
@@ -1,4 +1,7 @@
using WIDESEA_IRecordService;
using WIDESEA_Common.LocationEnum;
using WIDESEA_Common.StockEnum;
using WIDESEA_IRecordService;
using WIDESEA_Model.Models;
namespace WIDESEA_RecordService
{
@@ -20,8 +23,6 @@
        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="locationStatusChangeRecordService">货位状态变更记录服务</param>
        /// <param name="stockQuantityChangeRecordService">库存数量变更记录服务</param>
        public RecordService(
            ILocationStatusChangeRecordService locationStatusChangeRecordService,
            IStockQuantityChangeRecordService stockQuantityChangeRecordService)
@@ -29,5 +30,47 @@
            LocationStatusChangeRecordService = locationStatusChangeRecordService;
            StockQuantityChangeRecordService = stockQuantityChangeRecordService;
        }
        /// <summary>
        /// 新增货位状态变更记录
        /// </summary>
        public Task<bool> AddLocationChangeRecordAsync(
            Dt_LocationInfo beforeLocation,
            Dt_LocationInfo afterLocation,
            LocationChangeType changeType,
            int? taskNum = null,
            string? orderNo = null,
            int? orderId = null,
            string? remark = null)
        {
            return LocationStatusChangeRecordService.AddChangeRecordAsync(
                beforeLocation,
                afterLocation,
                changeType,
                taskNum,
                orderNo,
                orderId,
                remark);
        }
        /// <summary>
        /// 新增库存变更记录
        /// </summary>
        public Task<bool> AddStockChangeRecordAsync(
            Dt_StockInfo? beforeStock,
            Dt_StockInfo? afterStock,
            StockChangeTypeEnum changeType,
            int? taskNum = null,
            string? orderNo = null,
            string? remark = null)
        {
            return StockQuantityChangeRecordService.AddChangeRecordAsync(
                beforeStock,
                afterStock,
                changeType,
                taskNum,
                orderNo,
                remark);
        }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_RecordService/StockQuantityChangeRecordService.cs
@@ -1,4 +1,4 @@
using MapsterMapper;
using WIDESEA_Common.StockEnum;
using WIDESEA_Core.BaseRepository;
using WIDESEA_Core.BaseServices;
using WIDESEA_IRecordService;
@@ -11,21 +11,87 @@
    /// </summary>
    public partial class StockQuantityChangeRecordService : ServiceBase<Dt_StockQuantityChangeRecord, IRepository<Dt_StockQuantityChangeRecord>>, IStockQuantityChangeRecordService
    {
        private readonly IMapper _mapper;
        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="baseDal">基础数据访问对象</param>
        /// <param name="mapper">对象映射器</param>
        public StockQuantityChangeRecordService(IRepository<Dt_StockQuantityChangeRecord> baseDal, IMapper mapper) : base(baseDal)
        public StockQuantityChangeRecordService(IRepository<Dt_StockQuantityChangeRecord> baseDal) : base(baseDal)
        {
            _mapper = mapper;
        }
        /// <summary>
        /// 获取库存数量变更记录仓储接口
        /// </summary>
        public IRepository<Dt_StockQuantityChangeRecord> Repository => BaseDal;
        /// <summary>
        /// 记录库存变更。
        /// </summary>
        public async Task<bool> AddChangeRecordAsync(
            Dt_StockInfo? beforeStock,
            Dt_StockInfo? afterStock,
            StockChangeTypeEnum changeType,
            int? taskNum = null,
            string? orderNo = null,
            string? remark = null)
        {
            if (beforeStock == null && afterStock == null)
                return false;
            var beforeQuantity = GetStockQuantity(beforeStock);
            var afterQuantity = GetStockQuantity(afterStock);
            var beforeStatus = beforeStock?.StockStatus ?? 0;
            var afterStatus = afterStock?.StockStatus ?? 0;
            int? beforeLocationId = beforeStock?.LocationId > 0 ? beforeStock.LocationId : (int?)null;
            int? afterLocationId = afterStock?.LocationId > 0 ? afterStock.LocationId : (int?)null;
            var beforeLocationCode = beforeStock?.LocationCode;
            var afterLocationCode = afterStock?.LocationCode;
            if (beforeQuantity == afterQuantity &&
                beforeStatus == afterStatus &&
                beforeLocationId == afterLocationId &&
                beforeLocationCode == afterLocationCode)
            {
                return true;
            }
            var currentStock = afterStock ?? beforeStock!;
            Dt_StockQuantityChangeRecord record = new Dt_StockQuantityChangeRecord
            {
                StockDetailId = currentStock.Id,
                PalleCode = currentStock.PalletCode,
                MaterielCode = GetFirstValue(currentStock.Details?.Select(x => x.MaterielCode)),
                MaterielName = GetFirstValue(currentStock.Details?.Select(x => x.MaterielName)),
                BatchNo = GetFirstValue(currentStock.Details?.Select(x => x.BatchNo)),
                SerilNumber = GetFirstValue(currentStock.Details?.Select(x => x.SerialNumber)),
                OrderNo = orderNo,
                TaskNum = taskNum,
                ChangeType = (int)changeType,
                ChangeQuantity = afterQuantity - beforeQuantity,
                BeforeQuantity = beforeQuantity,
                AfterQuantity = afterQuantity,
                BeforeStatus = beforeStatus,
                AfterStatus = afterStatus,
                BeforeLocationId = beforeLocationId,
                AfterLocationId = afterLocationId,
                BeforeLocationCode = beforeLocationCode,
                AfterLocationCode = afterLocationCode,
                Remark = remark
            };
            return await BaseDal.AddDataAsync(record) > 0;
        }
        private static float GetStockQuantity(Dt_StockInfo? stockInfo)
        {
            if (stockInfo?.Details == null || !stockInfo.Details.Any())
                return 0;
            return stockInfo.Details.Sum(x => x.StockQuantity);
        }
        private static string GetFirstValue(IEnumerable<string>? values)
        {
            return values?.FirstOrDefault() ?? string.Empty;
        }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_StockService/StockInfoService.cs
@@ -1,8 +1,10 @@
using WIDESEA_Common.StockEnum;
using WIDESEA_Core;
using WIDESEA_Core.BaseRepository;
using WIDESEA_Core.BaseServices;
using WIDESEA_DTO.Stock;
using WIDESEA_IBasicService;
using WIDESEA_IRecordService;
using WIDESEA_IStockService;
using WIDESEA_Model.Models;
@@ -27,15 +29,21 @@
        /// 仓库信息服务接口(用于获取仓库基本信息)
        /// </summary>
        private readonly IWarehouseService _warehouseService;
        private readonly IRecordService _recordService;
        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="baseDal">基础数据访问对象</param>
        public StockInfoService(IRepository<Dt_StockInfo> baseDal, ILocationInfoService locationInfoService, IWarehouseService warehouseService) : base(baseDal)
        public StockInfoService(
            IRepository<Dt_StockInfo> baseDal,
            ILocationInfoService locationInfoService,
            IWarehouseService warehouseService,
            IRecordService recordService) : base(baseDal)
        {
            _locationInfoService = locationInfoService;
            _warehouseService = warehouseService;
            _recordService = recordService;
        }
        /// <summary>
@@ -79,7 +87,100 @@
        /// <returns>更新是否成功</returns>
        public async Task<bool> UpdateStockAsync(Dt_StockInfo stockInfo)
        {
            return await BaseDal.UpdateDataAsync(stockInfo);
            var beforeStock = await BaseDal.QueryDataNavFirstAsync(x => x.Id == stockInfo.Id);
            var result = await BaseDal.UpdateDataAsync(stockInfo);
            if (!result)
                return false;
            var afterStock = await BaseDal.QueryDataNavFirstAsync(x => x.Id == stockInfo.Id) ?? stockInfo;
            var changeType = ResolveChangeType(beforeStock, afterStock);
            return await _recordService.AddStockChangeRecordAsync(beforeStock, afterStock, changeType, remark: "库存更新");
        }
        public override WebResponseContent AddData(Dt_StockInfo entity)
        {
            var result = base.AddData(entity);
            if (!result.Status)
                return result;
            var saveRecordResult = _recordService.AddStockChangeRecordAsync(null, entity, StockChangeTypeEnum.Inbound, remark: "库存新增").GetAwaiter().GetResult();
            return saveRecordResult ? result : WebResponseContent.Instance.Error("库存变更记录保存失败");
        }
        public override WebResponseContent UpdateData(Dt_StockInfo entity)
        {
            var beforeStock = BaseDal.QueryFirst(x => x.Id == entity.Id);
            var result = base.UpdateData(entity);
            if (!result.Status)
                return result;
            var changeType = ResolveChangeType(beforeStock, entity);
            var saveRecordResult = _recordService.AddStockChangeRecordAsync(beforeStock, entity, changeType, remark: "库存更新").GetAwaiter().GetResult();
            return saveRecordResult ? result : WebResponseContent.Instance.Error("库存变更记录保存失败");
        }
        public override WebResponseContent DeleteData(Dt_StockInfo entity)
        {
            var beforeStock = CloneStockSnapshot(entity);
            var result = base.DeleteData(entity);
            if (!result.Status)
                return result;
            var saveRecordResult = _recordService.AddStockChangeRecordAsync(beforeStock, null, StockChangeTypeEnum.Outbound, remark: "库存删除").GetAwaiter().GetResult();
            return saveRecordResult ? result : WebResponseContent.Instance.Error("库存变更记录保存失败");
        }
        private static StockChangeTypeEnum ResolveChangeType(Dt_StockInfo? beforeStock, Dt_StockInfo? afterStock)
        {
            if (beforeStock == null)
                return StockChangeTypeEnum.Inbound;
            if (afterStock == null)
                return StockChangeTypeEnum.Outbound;
            if (!string.Equals(beforeStock.LocationCode, afterStock.LocationCode, StringComparison.OrdinalIgnoreCase))
                return StockChangeTypeEnum.Relocation;
            if (beforeStock.StockStatus != afterStock.StockStatus)
                return StockChangeTypeEnum.StockLock;
            var beforeQuantity = beforeStock.Details?.Sum(x => x.StockQuantity) ?? 0;
            var afterQuantity = afterStock.Details?.Sum(x => x.StockQuantity) ?? 0;
            return afterQuantity >= beforeQuantity ? StockChangeTypeEnum.Inbound : StockChangeTypeEnum.Outbound;
        }
        private static Dt_StockInfo CloneStockSnapshot(Dt_StockInfo stockInfo)
        {
            return new Dt_StockInfo
            {
                Id = stockInfo.Id,
                PalletCode = stockInfo.PalletCode,
                PalletType = stockInfo.PalletType,
                LocationId = stockInfo.LocationId,
                LocationCode = stockInfo.LocationCode,
                WarehouseId = stockInfo.WarehouseId,
                StockStatus = stockInfo.StockStatus,
                Remark = stockInfo.Remark,
                OutboundDate = stockInfo.OutboundDate,
                Details = stockInfo.Details?.Select(detail => new Dt_StockInfoDetail
                {
                    Id = detail.Id,
                    StockId = detail.StockId,
                    MaterielCode = detail.MaterielCode,
                    MaterielName = detail.MaterielName,
                    OrderNo = detail.OrderNo,
                    BatchNo = detail.BatchNo,
                    ProductionDate = detail.ProductionDate,
                    EffectiveDate = detail.EffectiveDate,
                    SerialNumber = detail.SerialNumber,
                    StockQuantity = detail.StockQuantity,
                    OutboundQuantity = detail.OutboundQuantity,
                    Status = detail.Status,
                    Unit = detail.Unit,
                    InboundOrderRowNo = detail.InboundOrderRowNo,
                    Remark = detail.Remark
                }).ToList()
            };
        }
        /// <summary>
Code/WMS/WIDESEA_WMSServer/WIDESEA_TaskInfoService/TaskService.cs
@@ -19,6 +19,7 @@
using WIDESEA_DTO.Stock;
using WIDESEA_DTO.Task;
using WIDESEA_IBasicService;
using WIDESEA_IRecordService;
using WIDESEA_IStockService;
using WIDESEA_ITaskInfoService;
using WIDESEA_Model.Models;
@@ -37,6 +38,7 @@
        private readonly ITask_HtyService _task_HtyService;
        private readonly IStockInfo_HtyService _stockInfo_HtyService;
        private readonly IUnitOfWorkManage _unitOfWorkManage;
        private readonly IRecordService _recordService;
        public IRepository<Dt_Task> Repository => BaseDal;
@@ -60,7 +62,8 @@
            IMesService mesService,
            ITask_HtyService task_HtyService,
            IStockInfo_HtyService stockInfo_HtyService,
            IUnitOfWorkManage unitOfWorkManage) : base(BaseDal)
            IUnitOfWorkManage unitOfWorkManage,
            IRecordService recordService) : base(BaseDal)
        {
            _mapper = mapper;
            _stockInfoService = stockInfoService;
@@ -72,6 +75,7 @@
            _task_HtyService = task_HtyService;
            _stockInfo_HtyService = stockInfo_HtyService;
            _unitOfWorkManage = unitOfWorkManage;
            _recordService = recordService;
        }
        /// <summary>
@@ -165,6 +169,5 @@
            return _roundRobinService.GetNextAddress(matchedPrefix, addresses);
        }
    }
}
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs
@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -14,20 +16,35 @@
namespace WIDESEA_WMSServer.BackgroundServices
{
    /// <summary>
    /// 库存监控后台服务
    /// 定期检查库存和货位数据变化并通过SignalR推送到前端
    /// 库存监控后台服务。
    /// 启动时初始化一次全量快照,后续仅按更新时间增量检查受影响货位,并通过 SignalR 推送变化。
    /// </summary>
    public class StockMonitorBackgroundService : BackgroundService
    {
        private const int MonitorIntervalMs = 3000;
        private readonly ILogger<StockMonitorBackgroundService> _logger;
        private readonly IHubContext<StockHub> _hubContext;
        private readonly IServiceProvider _serviceProvider;
        // 货位状态快照:key = LocationId
        private ConcurrentDictionary<int, LocationSnapshot> _lastLocationSnapshots = new();
        /// <summary>
        /// 货位状态快照:key = LocationId。
        /// </summary>
        private readonly ConcurrentDictionary<int, LocationSnapshot> _lastLocationSnapshots = new();
        // 监控间隔(毫秒)
        private const int MonitorIntervalMs = 3000;
        /// <summary>
        /// 库存所在货位映射:key = StockId, value = LocationId。
        /// 用于识别库存从旧货位移动到新货位时,两边都需要推送刷新。
        /// </summary>
        private readonly ConcurrentDictionary<int, int> _lastStockLocationMap = new();
        /// <summary>
        /// 上次增量检查时间戳。
        /// 分别跟踪货位、库存主表、库存明细,避免每次全表扫描。
        /// </summary>
        private DateTime _lastLocationCheckTime = DateTime.MinValue;
        private DateTime _lastStockCheckTime = DateTime.MinValue;
        private DateTime _lastDetailCheckTime = DateTime.MinValue;
        public StockMonitorBackgroundService(
            ILogger<StockMonitorBackgroundService> logger,
@@ -43,8 +60,9 @@
        {
            _logger.LogInformation("库存监控后台服务已启动");
            // 等待应用完全启动
            // 等待应用初始化完成,避免启动阶段与其他初始化任务争抢数据库资源。
            await Task.Delay(5000, stoppingToken);
            await InitializeSnapshotsAsync();
            while (!stoppingToken.IsCancellationRequested)
            {
@@ -54,7 +72,7 @@
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "检查数据变化时发生错误");
                    _logger.LogError(ex, "检查库存变更时发生异常");
                }
                await Task.Delay(MonitorIntervalMs, stoppingToken);
@@ -64,110 +82,252 @@
        }
        /// <summary>
        /// 检查货位和库存变化
        /// 初始化全量快照。仅在服务启动时执行一次,后续走增量检查。
        /// </summary>
        private async Task InitializeSnapshotsAsync()
        {
            using var scope = _serviceProvider.CreateScope();
            var stockService = scope.ServiceProvider.GetRequiredService<IStockInfoService>();
            var locationRepo = scope.ServiceProvider.GetRequiredService<IRepository<Dt_LocationInfo>>();
            var initializedAt = DateTime.Now;
            var allLocations = await locationRepo.Db.Queryable<Dt_LocationInfo>()
                .Where(x => x.LocationStatus != 99)
                .ToListAsync();
            var allStocks = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
                .Includes(x => x.Details)
                .Where(x => x.LocationId != 0)
                .ToListAsync();
            var stockDict = allStocks
                .Where(x => x.LocationId > 0)
                .GroupBy(x => x.LocationId)
                .ToDictionary(x => x.Key, x => x.OrderByDescending(item => item.ModifyDate ?? item.CreateDate).First());
            foreach (var location in allLocations)
            {
                stockDict.TryGetValue(location.Id, out var stock);
                _lastLocationSnapshots[location.Id] = BuildLocationSnapshot(location, stock);
            }
            foreach (var stock in allStocks.Where(x => x.LocationId > 0))
            {
                _lastStockLocationMap[stock.Id] = stock.LocationId;
            }
            _lastLocationCheckTime = initializedAt;
            _lastStockCheckTime = initializedAt;
            _lastDetailCheckTime = initializedAt;
            _logger.LogInformation("库存监控快照初始化完成,货位数={LocationCount},库存数={StockCount}", allLocations.Count, allStocks.Count);
        }
        /// <summary>
        /// 增量检查货位和库存变化。
        /// 只查询上次检查之后发生变化的货位、库存和明细,再回查受影响货位的当前完整数据。
        /// </summary>
        private async Task CheckChangesAsync()
        {
            using var scope = _serviceProvider.CreateScope();
            var stockService = scope.ServiceProvider.GetRequiredService<IStockInfoService>();
            var locationRepo = scope.ServiceProvider.GetRequiredService<IRepository<Dt_LocationInfo>>();
            var checkStartedAt = DateTime.Now;
            // 1. 获取所有货位数据
            var allLocations = await locationRepo.QueryDataAsync(x => x.LocationStatus != 99); // 排除禁用的货位
            // 2. 获取所有库存数据(包含明细)
            var allStockData = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
                .Includes(x => x.Details)
            var changedLocationIds = await locationRepo.Db.Queryable<Dt_LocationInfo>()
                .Where(x => x.LocationStatus != 99 && (x.ModifyDate ?? x.CreateDate) > _lastLocationCheckTime)
                .Select(x => x.Id)
                .ToListAsync();
            // 构建库存字典:LocationId -> StockInfo
            var stockDict = allStockData
                .Where(s => s.LocationId > 0)
                .ToDictionary(s => s.LocationId, s => s);
            // 构建当前货位快照字典
            var currentSnapshots = new ConcurrentDictionary<int, LocationSnapshot>();
            foreach (var location in allLocations)
            {
                // 获取该货位的库存信息
                stockDict.TryGetValue(location.Id, out var stock);
                // 计算库存数量
                float totalQuantity = 0;
                string detailsHash = string.Empty;
                if (stock?.Details != null && stock.Details.Any())
            var changedStocks = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
                .Where(x => (x.ModifyDate ?? x.CreateDate) > _lastStockCheckTime)
                .Select(x => new StockLocationChange
                {
                    totalQuantity = stock.Details.Sum(d => d.StockQuantity);
                    detailsHash = GenerateDetailsHash(stock.Details.ToList());
                    StockId = x.Id,
                    LocationId = x.LocationId
                })
                .ToListAsync();
            var changedDetailStockIds = await stockService.Repository.Db.Queryable<Dt_StockInfoDetail>()
                .Where(x => (x.ModifyDate ?? x.CreateDate) > _lastDetailCheckTime)
                .Select(x => x.StockId)
                .Distinct()
                .ToListAsync();
            var affectedLocationIds = new HashSet<int>(changedLocationIds);
            foreach (var stock in changedStocks)
            {
                var previousLocationId = _lastStockLocationMap.TryGetValue(stock.StockId, out var oldLocationId)
                    ? oldLocationId
                    : 0;
                if (previousLocationId > 0 && previousLocationId != stock.LocationId)
                {
                    affectedLocationIds.Add(previousLocationId);
                }
                var snapshot = new LocationSnapshot
                if (stock.LocationId > 0)
                {
                    LocationId = location.Id,
                    WarehouseId = location.WarehouseId,
                    LocationCode = location.LocationCode,
                    LocationStatus = location.LocationStatus,
                    PalletCode = stock?.PalletCode,
                    StockStatus = stock?.StockStatus ?? 0,
                    StockQuantity = totalQuantity,
                    DetailsHash = detailsHash
                };
                currentSnapshots.TryAdd(location.Id, snapshot);
                // 检查是否有变化
                if (_lastLocationSnapshots.TryGetValue(location.Id, out var lastSnapshot))
                    affectedLocationIds.Add(stock.LocationId);
                    _lastStockLocationMap[stock.StockId] = stock.LocationId;
                }
                else
                {
                    // 检测变化:货位状态、库存状态、数量、明细变化
                    if (lastSnapshot.LocationStatus != snapshot.LocationStatus ||
                        lastSnapshot.StockStatus != snapshot.StockStatus ||
                        lastSnapshot.PalletCode != snapshot.PalletCode ||
                        Math.Abs(lastSnapshot.StockQuantity - snapshot.StockQuantity) > 0.001f ||
                        lastSnapshot.DetailsHash != snapshot.DetailsHash)
                    {
                        // 构建更新DTO
                        var update = new StockUpdateDTO
                        {
                            LocationId = snapshot.LocationId,
                            WarehouseId = snapshot.WarehouseId,
                            PalletCode = snapshot.PalletCode,
                            StockQuantity = snapshot.StockQuantity,
                            StockStatus = snapshot.StockStatus,
                            LocationStatus = snapshot.LocationStatus,
                            Details = BuildDetailDtos(stock?.Details?.ToList())
                        };
                        await _hubContext.Clients.All.SendAsync("StockUpdated", update);
                        _logger.LogDebug("数据变化推送: LocationId={LocationId}, LocStatus={LocStatus}, StockStatus={StockStatus}, Quantity={Quantity}",
                            snapshot.LocationId, snapshot.LocationStatus, snapshot.StockStatus, snapshot.StockQuantity);
                    }
                    _lastStockLocationMap.TryRemove(stock.StockId, out _);
                }
            }
            // 更新快照数据
            _lastLocationSnapshots = currentSnapshots;
            if (changedDetailStockIds.Count > 0)
            {
                var detailAffectedLocationIds = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
                    .Where(x => changedDetailStockIds.Contains(x.Id) && x.LocationId != 0)
                    .Select(x => x.LocationId)
                    .Distinct()
                    .ToListAsync();
                foreach (var locationId in detailAffectedLocationIds)
                {
                    affectedLocationIds.Add(locationId);
                }
            }
            if (affectedLocationIds.Count == 0)
            {
                UpdateCheckTimes(checkStartedAt);
                return;
            }
            var affectedLocations = await locationRepo.Db.Queryable<Dt_LocationInfo>()
                .Where(x => affectedLocationIds.Contains(x.Id) && x.LocationStatus != 99)
                .ToListAsync();
            var locationDict = affectedLocations.ToDictionary(x => x.Id, x => x);
            var affectedStocks = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
                .Includes(x => x.Details)
                .Where(x => affectedLocationIds.Contains(x.LocationId))
                .ToListAsync();
            var stockDict = affectedStocks
                .Where(x => x.LocationId > 0)
                .GroupBy(x => x.LocationId)
                .ToDictionary(x => x.Key, x => x.OrderByDescending(item => item.ModifyDate ?? item.CreateDate).First());
            foreach (var locationId in affectedLocationIds)
            {
                if (!locationDict.TryGetValue(locationId, out var location))
                {
                    _lastLocationSnapshots.TryRemove(locationId, out _);
                    continue;
                }
                stockDict.TryGetValue(locationId, out var stock);
                var snapshot = BuildLocationSnapshot(location, stock);
                if (HasSnapshotChanged(locationId, snapshot))
                {
                    var update = new StockUpdateDTO
                    {
                        LocationId = snapshot.LocationId,
                        WarehouseId = snapshot.WarehouseId,
                        PalletCode = snapshot.PalletCode,
                        StockQuantity = snapshot.StockQuantity,
                        StockStatus = snapshot.StockStatus,
                        LocationStatus = snapshot.LocationStatus,
                        OutboundDate = snapshot.OutboundDate,
                        Details = BuildDetailDtos(stock?.Details?.ToList())
                    };
                    await _hubContext.Clients.All.SendAsync("StockUpdated", update);
                    _logger.LogDebug(
                        "增量库存变更推送,LocationId={LocationId},LocationStatus={LocationStatus},StockStatus={StockStatus},Quantity={Quantity}",
                        snapshot.LocationId,
                        snapshot.LocationStatus,
                        snapshot.StockStatus,
                        snapshot.OutboundDate,
                        snapshot.StockQuantity);
                }
                _lastLocationSnapshots[locationId] = snapshot;
            }
            UpdateCheckTimes(checkStartedAt);
        }
        /// <summary>
        /// 生成明细数据哈希
        /// 构建货位快照,用于判断货位是否需要推送更新。
        /// </summary>
        private LocationSnapshot BuildLocationSnapshot(Dt_LocationInfo location, Dt_StockInfo stock)
        {
            float totalQuantity = 0;
            string detailsHash = string.Empty;
            if (stock?.Details != null && stock.Details.Any())
            {
                totalQuantity = stock.Details.Sum(d => d.StockQuantity);
                detailsHash = GenerateDetailsHash(stock.Details.ToList());
            }
            return new LocationSnapshot
            {
                LocationId = location.Id,
                WarehouseId = location.WarehouseId,
                LocationCode = location.LocationCode,
                LocationStatus = location.LocationStatus,
                PalletCode = stock?.PalletCode,
                StockStatus = stock?.StockStatus ?? 0,
                StockQuantity = totalQuantity,
                DetailsHash = detailsHash,
                OutboundDate = stock?.OutboundDate ?? default
            };
        }
        /// <summary>
        /// 对比快照变化,仅在关键字段变化时触发推送。
        /// </summary>
        private bool HasSnapshotChanged(int locationId, LocationSnapshot snapshot)
        {
            if (!_lastLocationSnapshots.TryGetValue(locationId, out var lastSnapshot))
            {
                return true;
            }
            return lastSnapshot.LocationStatus != snapshot.LocationStatus ||
                   lastSnapshot.StockStatus != snapshot.StockStatus ||
                   lastSnapshot.PalletCode != snapshot.PalletCode ||
                   Math.Abs(lastSnapshot.StockQuantity - snapshot.StockQuantity) > 0.001f ||
                   lastSnapshot.DetailsHash != snapshot.DetailsHash;
        }
        /// <summary>
        /// 生成库存明细哈希,确保明细内容变化时能触发前端刷新。
        /// </summary>
        private string GenerateDetailsHash(List<Dt_StockInfoDetail> details)
        {
            if (details == null || !details.Any()) return string.Empty;
            if (details == null || !details.Any())
            {
                return string.Empty;
            }
            var hashString = string.Join("|", details
                .OrderBy(d => d.Id)
                .Select(d => $"{d.Id}:{d.MaterielCode}:{d.BatchNo}:{d.StockQuantity}"));
            var hashString = string.Join(
                "|",
                details
                    .OrderBy(d => d.Id)
                    .Select(d => $"{d.Id}:{d.MaterielCode}:{d.BatchNo}:{d.StockQuantity}:{d.SerialNumber}:{d.InboundOrderRowNo}:{d.Status}"));
            return hashString.GetHashCode().ToString();
        }
        /// <summary>
        /// 构建明细DTO列表
        /// 构建推送给前端的明细数据。
        /// </summary>
        private List<StockDetailUpdateDTO> BuildDetailDtos(List<Dt_StockInfoDetail> details)
        {
            if (details == null || !details.Any()) return new List<StockDetailUpdateDTO>();
            if (details == null || !details.Any())
            {
                return new List<StockDetailUpdateDTO>();
            }
            return details.Select(d => new StockDetailUpdateDTO
            {
@@ -177,12 +337,24 @@
                BatchNo = d.BatchNo,
                StockQuantity = d.StockQuantity,
                Unit = d.Unit,
                Status = d.Status
                Status = d.Status,
                SerialNumber = d.SerialNumber,
                InboundOrderRowNo = d.InboundOrderRowNo
            }).ToList();
        }
        /// <summary>
        /// 货位快照
        /// 更新各类增量检查时间。
        /// </summary>
        private void UpdateCheckTimes(DateTime checkStartedAt)
        {
            _lastLocationCheckTime = checkStartedAt;
            _lastStockCheckTime = checkStartedAt;
            _lastDetailCheckTime = checkStartedAt;
        }
        /// <summary>
        /// 货位快照。
        /// </summary>
        private class LocationSnapshot
        {
@@ -194,6 +366,16 @@
            public int StockStatus { get; set; }
            public float StockQuantity { get; set; }
            public string DetailsHash { get; set; }
            public DateTime OutboundDate { get; set; }
        }
        /// <summary>
        /// 库存变更定位信息。
        /// </summary>
        private class StockLocationChange
        {
            public int StockId { get; set; }
            public int LocationId { get; set; }
        }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/Hubs/StockHub.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.SignalR;
using SqlSugar;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -26,6 +27,7 @@
        public float StockQuantity { get; set; }
        public int StockStatus { get; set; }
        public int LocationStatus { get; set; }
        public DateTime OutboundDate { get; set; }
        public List<StockDetailUpdateDTO> Details { get; set; } = new();
    }
@@ -41,5 +43,7 @@
        public float StockQuantity { get; set; }
        public string Unit { get; set; }
        public int Status { get; set; }
        public string SerialNumber { get; set; }
        public int InboundOrderRowNo { get; set; }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/appsettings.json
@@ -63,7 +63,7 @@
  "PDAVersion": "4",
  "WebSocketPort": 9296,
  "AutoOutboundTask": {
    "Enable": true, /// 是否启用自动出库任务
    "Enable": false, /// 是否启用自动出库任务
    "CheckIntervalSeconds": 300, /// 检查间隔(秒)
    "TargetAddresses": { /// 按巷道前缀配置目标地址(支持多出库口)
      "GW": [ "11001", "11010", "11068" ],