优化仓库分类管理,新增兼容历史分类编码的支持,重构相关服务和控制器以提升系统的可维护性和扩展性。同时,增强原材料卡片和库区管理的查询逻辑,确保数据展示的准确性和一致性。

This commit is contained in:
geht
2026-05-15 11:19:12 +08:00
parent ffc390f3de
commit 5d7335d1a7
23 changed files with 1568 additions and 12 deletions

View File

@@ -0,0 +1,16 @@
package org.jeecg.modules.xslmes.capacity;
import org.jeecg.modules.xslmes.entity.MesXslWarehouseArea;
import java.util.List;
/**
* 库区展示用「实际存放量」数据源贡献者(可多实现并按 Spring Order 编排)
*/
public interface WarehouseAreaActualCapacityContribution {
/**
* @param areas 通常为列表/分页的一段记录(内存回填,不写库)
*/
void contribute(List<MesXslWarehouseArea> areas);
}

View File

@@ -0,0 +1,31 @@
package org.jeecg.modules.xslmes.capacity;
import lombok.RequiredArgsConstructor;
import org.jeecg.modules.xslmes.entity.MesXslWarehouseArea;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* 编排库区「实际存放量」展示回填(可多 {@link WarehouseAreaActualCapacityContribution} 扩展)。
*/
@Component
@RequiredArgsConstructor
public class WarehouseAreaDisplayedActualCapacityMediator {
private final List<WarehouseAreaActualCapacityContribution> contributions;
public void apply(Collection<MesXslWarehouseArea> areas) {
if (areas == null || areas.isEmpty()) {
return;
}
List<MesXslWarehouseArea> list = new ArrayList<>(areas);
List<WarehouseAreaActualCapacityContribution> ordered = contributions.stream().sorted(AnnotationAwareOrderComparator.INSTANCE).toList();
for (WarehouseAreaActualCapacityContribution c : ordered) {
c.contribute(list);
}
}
}

View File

@@ -0,0 +1,134 @@
package org.jeecg.modules.xslmes.capacity.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.xslmes.capacity.WarehouseAreaActualCapacityContribution;
import org.jeecg.modules.xslmes.config.XslMesWarehouseAreaCapacityProperties;
import org.jeecg.modules.xslmes.dto.MesXslAreaRemainQtySumDTO;
import org.jeecg.modules.system.service.ISysCategoryService;
import org.jeecg.modules.xslmes.entity.MesXslWarehouseArea;
import org.jeecg.modules.xslmes.mapper.MesXslRawMaterialCardMapper;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 用「原材料卡片」按库区(trim)汇总 remaining_quantity回填展示字段 actual_capacity不写库
*/
@Slf4j
@Component
@Order(100)
@RequiredArgsConstructor
public class RawMaterialCardRemainQtyCapacityContribution implements WarehouseAreaActualCapacityContribution {
private final XslMesWarehouseAreaCapacityProperties properties;
private final MesXslRawMaterialCardMapper mesXslRawMaterialCardMapper;
private final ISysCategoryService sysCategoryService;
@Override
public void contribute(List<MesXslWarehouseArea> areas) {
if (areas == null || areas.isEmpty()) {
return;
}
if (!properties.isEnabled()) {
return;
}
Set<String> allowedCategories = resolveAllowedWarehouseCategoryIds();
if (allowedCategories.isEmpty()) {
log.debug("[WarehouseAreaActualCapacity] 原材料库分类编码/ID 均未解析到有效 warehouse_category跳过 RawMaterialCard 策略");
return;
}
List<MesXslWarehouseArea> targets =
areas.stream().filter(a -> a != null && allowedCategories.contains(normId(a.getWarehouseCategory())) && StringUtils.isNotBlank(a.getAreaCode()))
.collect(Collectors.toList());
if (targets.isEmpty()) {
return;
}
Collection<String> codes =
targets.stream().map(MesXslWarehouseArea::getAreaCode).map(String::trim).filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList());
Map<String, Long> sumMap = loadSumMap(codes);
for (MesXslWarehouseArea a : targets) {
String ck = trimKey(a.getAreaCode());
long sum = sumMap.getOrDefault(ck, 0L);
int display;
try {
display = Math.toIntExact(Math.min(sum, Integer.MAX_VALUE));
} catch (ArithmeticException ex) {
display = Integer.MAX_VALUE;
}
a.setActualCapacity(display);
}
}
private Map<String, Long> loadSumMap(Collection<String> codes) {
if (codes.isEmpty()) {
return Collections.emptyMap();
}
List<MesXslAreaRemainQtySumDTO> rows = mesXslRawMaterialCardMapper.sumRemainingQtyByTrimmedWarehouseArea(codes);
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
Map<String, Long> map = new HashMap<>(Math.max(16, rows.size() * 2));
for (MesXslAreaRemainQtySumDTO row : rows) {
if (row == null || row.getAreaCode() == null) {
continue;
}
String k = row.getAreaCode().trim();
if (StringUtils.isBlank(k)) {
continue;
}
long v = row.getQtySum() == null ? 0L : row.getQtySum();
map.put(k, v);
}
return map;
}
private static String trimKey(String code) {
return code == null ? "" : code.trim();
}
private static String normId(String id) {
return StringUtils.trimToEmpty(id);
}
private static Set<String> normalizeIdSet(Collection<String> raw) {
if (raw == null) {
return Collections.emptySet();
}
return raw.stream().filter(StringUtils::isNotBlank).map(String::trim).collect(Collectors.toSet());
}
/** 配置的 id + 配置的 code 解析出的 id 合并 */
private Set<String> resolveAllowedWarehouseCategoryIds() {
Set<String> set = new HashSet<>(normalizeIdSet(properties.getRawMaterialWarehouseCategoryIds()));
Collection<String> codes = properties.getRawMaterialWarehouseCategoryCodes();
if (codes != null) {
for (String c : codes) {
if (StringUtils.isBlank(c)) {
continue;
}
try {
String id = sysCategoryService.queryIdByCode(c.trim());
if (StringUtils.isNotBlank(id)) {
set.add(id.trim());
}
} catch (Exception ex) {
log.warn("[WarehouseAreaActualCapacity] 解析原材料库分类编码失败 code={}: {}", c, ex.getMessage());
}
}
}
return set;
}
}

View File

@@ -0,0 +1,43 @@
package org.jeecg.modules.xslmes.config;
import lombok.Data;
import org.jeecg.modules.xslmes.constant.MesXslWarehouseCategory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 库区列表「实际存放量」展示口径配置(不写库)
* <p>
* 推荐使用 {@link #rawMaterialWarehouseCategoryCodes},启动时解析为 sys_category.id
* 避免在配置里维护易变的雪花 id也可用 {@link #rawMaterialWarehouseCategoryIds} 直接指定 id。
*/
@Data
@Component
@ConfigurationProperties(prefix = "xslmes.warehouse-area.display-actual-capacity")
public class XslMesWarehouseAreaCapacityProperties {
/**

View File

@@ -1,7 +1,19 @@
package org.jeecg.modules.xslmes.constant;
import org.apache.commons.lang3.StringUtils;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* MES 仓库分类(分类字典 sys_category根编码 {@link #ROOT_CODE}
*
* <p>说明:历史版本曾在编码上使用 F1/F2 后缀;当前业务树已不再按楼层区分,
* 「F1」「F2」仅可能作为编码后缀保留请以 {@link #RAW_MATERIAL_CATEGORY_CODES}、
* 客户/供应商库编码集合为准做兼容。</p>
*/
public final class MesXslWarehouseCategory {
@@ -10,8 +22,37 @@ public final class MesXslWarehouseCategory {
/** 根节点编码,与 JCategorySelect 的 pcode 一致 */
public static final String ROOT_CODE = "XSLMES_WH";
/** 客户库(二楼):须关联客户 */
public static final String CUSTOMER_CATEGORY_CODE = "XSLMES_WH_F2_KH";
/** 供应商库(二楼):须关联供应商 */
public static final String SUPPLIER_CATEGORY_CODE = "XSLMES_WH_F2_GYS";
/** 兼容历史:原材料库在库中可能出现的分类编码(新环境多为 F1老数据可能仍为 F2 */
public static final List<String> RAW_MATERIAL_CATEGORY_CODES =
Collections.unmodifiableList(Arrays.asList("XSLMES_WH_F1_YCL", "XSLMES_WH_F2_YCL"));
/** 兼容历史编号的「客户库」分类编码集合 */
private static final Set<String> CUSTOMER_CATEGORY_CODES =
Collections.unmodifiableSet(new HashSet<>(Arrays.asList("XSLMES_WH_F1_KH", "XSLMES_WH_F2_KH")));
/** 兼容历史编号的「供应商库」分类编码集合 */
private static final Set<String> SUPPLIER_CATEGORY_CODES =
Collections.unmodifiableSet(new HashSet<>(Arrays.asList("XSLMES_WH_F1_GYS", "XSLMES_WH_F2_GYS")));
/**
* @deprecated 请改用 {@link #isCustomerWarehouseCategoryCode(String)}
*/
@Deprecated
public static final String CUSTOMER_CATEGORY_CODE = "XSLMES_WH_F1_KH";
/**
* @deprecated 请改用 {@link #isSupplierWarehouseCategoryCode(String)}
*/
@Deprecated
public static final String SUPPLIER_CATEGORY_CODE = "XSLMES_WH_F1_GYS";
/** 是否为「客户库」分类编码(兼容 XSLMES_WH_F1_KH / XSLMES_WH_F2_KH */
public static boolean isCustomerWarehouseCategoryCode(String code) {
return CUSTOMER_CATEGORY_CODES.contains(StringUtils.trimToEmpty(code));
}
/** 是否为「供应商库」分类编码(兼容 XSLMES_WH_F1_GYS / XSLMES_WH_F2_GYS */
public static boolean isSupplierWarehouseCategoryCode(String code) {
return SUPPLIER_CATEGORY_CODES.contains(StringUtils.trimToEmpty(code));
}
}

View File

@@ -43,6 +43,7 @@ import org.jeecg.modules.xslmes.service.MesXslStompNotifyService;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@@ -702,6 +703,7 @@ public class MesXslDesktopAnonController {
HttpServletRequest req) {
QueryWrapper<MesXslWarehouseArea> qw = QueryGenerator.initQueryWrapper(mesXslWarehouseArea, req.getParameterMap());
IPage<MesXslWarehouseArea> page = warehouseAreaService.page(new Page<>(pageNo, pageSize), qw);
warehouseAreaService.enrichDisplayedActualCapacity(page.getRecords());
return Result.OK(page);
}
@@ -709,7 +711,11 @@ public class MesXslDesktopAnonController {
@GetMapping("/xslmes/mesXslWarehouseArea/anon/queryById")
public Result<MesXslWarehouseArea> warehouseAreaAnonQueryById(@RequestParam(name = "id") String id) {
MesXslWarehouseArea entity = warehouseAreaService.getById(id);
return entity != null ? Result.OK(entity) : Result.error("未找到对应数据");
if (entity == null) {
return Result.error("未找到对应数据");
}
warehouseAreaService.enrichDisplayedActualCapacity(Collections.singletonList(entity));
return Result.OK(entity);
}
@Operation(summary = "库区-免密添加")

View File

@@ -16,6 +16,7 @@ import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.common.util.oConvertUtils;
import org.apache.shiro.authz.annotation.Logical;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
@@ -81,7 +82,36 @@ public class MesXslRawMaterialCardController extends JeecgController<MesXslRawMa
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
// 库区条件:去掉实体上的值避免 QueryGenerator 再拼一班;用 TRIM 与看板聚合一致(避免首尾空格导致「有汇总无明细」)
String rawWarehouseArea = mesXslRawMaterialCard != null ? mesXslRawMaterialCard.getWarehouseArea() : null;
String areaEq = oConvertUtils.isNotEmpty(rawWarehouseArea) ? rawWarehouseArea.trim() : "";
boolean filterByWarehouseArea = !areaEq.isEmpty();
if (filterByWarehouseArea) {
mesXslRawMaterialCard.setWarehouseArea(null);
}
QueryWrapper<MesXslRawMaterialCard> queryWrapper = QueryGenerator.initQueryWrapper(mesXslRawMaterialCard, req.getParameterMap());
if (filterByWarehouseArea) {
queryWrapper.apply("TRIM(COALESCE(warehouse_area,'')) = {0}", areaEq);
}
// 看板等扩展查询remaining_quantity「有剩余 / 无剩余」(非实体字段,单独拼条件)
String remainQtyFilter = req.getParameter("remainQtyFilter");
if ("has".equals(remainQtyFilter)) {
queryWrapper.gt("remaining_quantity", 0);
} else if ("none".equals(remainQtyFilter)) {
queryWrapper.and(w -> w.isNull("remaining_quantity").or().le("remaining_quantity", 0));
}
String mixKw = req.getParameter("mixKeyword");
if (StringUtils.isNotBlank(mixKw)) {
final String kw = mixKw.trim();
queryWrapper.and(
w -> w.like("barcode", kw)
.or()
.like("batch_no", kw)
.or()
.like("material_name", kw)
.or()
.like("material_id", kw));
}
Page<MesXslRawMaterialCard> page = new Page<>(pageNo, pageSize);
IPage<MesXslRawMaterialCard> pageList = mesXslRawMaterialCardService.page(page, queryWrapper);
return Result.OK(pageList);

View File

@@ -0,0 +1,38 @@
package org.jeecg.modules.xslmes.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.xslmes.service.IMesXslRawMaterialWarehouseBoardService;
import org.jeecg.modules.xslmes.vo.MesXslRawMaterialWarehouseBoardVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 原材料库区看板:库区主数据 + 原材料卡片聚合
*/
@Tag(name = "原材料库区看板")
@RestController
@RequestMapping("/xslmes/mesXslRawMaterialWarehouseBoard")
@Slf4j
public class MesXslRawMaterialWarehouseBoardController {
@Autowired
private IMesXslRawMaterialWarehouseBoardService mesXslRawMaterialWarehouseBoardService;
@Operation(summary = "原材料库区看板-汇总数据")
@RequiresPermissions("xslmes:mes_xsl_raw_material_warehouse_board:list")
@GetMapping(value = "/board")
public Result<MesXslRawMaterialWarehouseBoardVO> board(
@RequestParam(name = "warehouseId", required = false) String warehouseId,
@RequestParam(name = "keyword", required = false) String keyword,
@RequestParam(name = "measureType", required = false) String measureType) {
MesXslRawMaterialWarehouseBoardVO data = mesXslRawMaterialWarehouseBoardService.queryBoard(warehouseId, keyword, measureType);
return Result.OK(data);
}
}

View File

@@ -3,6 +3,7 @@ package org.jeecg.modules.xslmes.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.shiro.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -13,15 +14,22 @@ import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.config.JeecgBaseConfig;
import org.jeecg.modules.xslmes.entity.MesXslWarehouseArea;
import org.jeecg.modules.xslmes.service.IMesXslWarehouseAreaService;
import org.jeecg.modules.xslmes.service.MesXslStompNotifyService;
import org.jeecgframework.poi.excel.def.NormalExcelConstants;
import org.jeecgframework.poi.excel.entity.ExportParams;
import org.jeecgframework.poi.excel.entity.enmus.ExcelType;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
@@ -36,6 +44,9 @@ public class MesXslWarehouseAreaController extends JeecgController<MesXslWarehou
@Autowired
private IMesXslWarehouseAreaService mesXslWarehouseAreaService;
@Autowired
private JeecgBaseConfig jeecgBaseConfig;
@Autowired
private MesXslStompNotifyService stompNotify;
@@ -49,6 +60,7 @@ public class MesXslWarehouseAreaController extends JeecgController<MesXslWarehou
QueryWrapper<MesXslWarehouseArea> queryWrapper = QueryGenerator.initQueryWrapper(mesXslWarehouseArea, req.getParameterMap());
Page<MesXslWarehouseArea> page = new Page<>(pageNo, pageSize);
IPage<MesXslWarehouseArea> pageList = mesXslWarehouseAreaService.page(page, queryWrapper);
mesXslWarehouseAreaService.enrichDisplayedActualCapacity(pageList.getRecords());
return Result.OK(pageList);
}
@@ -166,13 +178,33 @@ public class MesXslWarehouseAreaController extends JeecgController<MesXslWarehou
if (entity == null) {
return Result.error("未找到对应数据");
}
mesXslWarehouseAreaService.enrichDisplayedActualCapacity(Collections.singletonList(entity));
return Result.OK(entity);
}
@RequiresPermissions("xslmes:mes_xsl_warehouse_area:exportXls")
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, MesXslWarehouseArea mesXslWarehouseArea) {
return super.exportXls(request, mesXslWarehouseArea, MesXslWarehouseArea.class, "MES库区管理");
QueryWrapper<MesXslWarehouseArea> queryWrapper = QueryGenerator.initQueryWrapper(mesXslWarehouseArea, request.getParameterMap());
LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
String selections = request.getParameter("selections");
if (oConvertUtils.isNotEmpty(selections)) {
queryWrapper.in("id", Arrays.asList(selections.split(",")));
}
List<MesXslWarehouseArea> exportList = mesXslWarehouseAreaService.list(queryWrapper);
mesXslWarehouseAreaService.enrichDisplayedActualCapacity(exportList);
ModelAndView mv = new ModelAndView(new JeecgEntityExcelView());
mv.addObject(NormalExcelConstants.FILE_NAME, "MES库区管理");
mv.addObject(NormalExcelConstants.CLASS, MesXslWarehouseArea.class);
ExportParams exportParams = new ExportParams("MES库区管理报表", "导出人:" + sysUser.getRealname(), "MES库区管理", ExcelType.XSSF);
exportParams.setImageBasePath(jeecgBaseConfig.getPath().getUpload());
mv.addObject(NormalExcelConstants.PARAMS, exportParams);
mv.addObject(NormalExcelConstants.DATA_LIST, exportList);
String exportFields = request.getParameter(NormalExcelConstants.EXPORT_FIELDS);
if (oConvertUtils.isNotEmpty(exportFields)) {
mv.addObject(NormalExcelConstants.EXPORT_FIELDS, exportFields);
}
return mv;
}
@RequiresPermissions("xslmes:mes_xsl_warehouse_area:importExcel")

View File

@@ -0,0 +1,20 @@
package org.jeecg.modules.xslmes.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 按库区(trim 后编码)聚合剩余数量汇总
*/
@Data
public class MesXslAreaRemainQtySumDTO implements Serializable {
private static final long serialVersionUID = 1L;
/** 库区编码(已 trim */
private String areaCode;
/** 剩余数量合计 */
private Long qtySum;
}

View File

@@ -1,7 +1,12 @@
package org.jeecg.modules.xslmes.mapper;
import org.jeecg.modules.xslmes.entity.MesXslRawMaterialCard;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.jeecg.modules.xslmes.dto.MesXslAreaRemainQtySumDTO;
import org.jeecg.modules.xslmes.entity.MesXslRawMaterialCard;
import java.util.Collection;
import java.util.List;
/**
* @Description: 原材料卡片
@@ -10,4 +15,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
* @Version: V1.0
*/
public interface MesXslRawMaterialCardMapper extends BaseMapper<MesXslRawMaterialCard> {
/**
* 按 trim 后的库区编码汇总原材料卡片剩余数量(当前租户由拦截器补齐)
*/
List<MesXslAreaRemainQtySumDTO> sumRemainingQtyByTrimmedWarehouseArea(@Param("areaCodes") Collection<String> areaCodes);
}

View File

@@ -1,4 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.mapper.MesXslRawMaterialCardMapper">
<!-- 库区编码已与卡片 warehouse_area 按 TRIM 对齐(见库区/卡片筛选逻辑) -->
<select id="sumRemainingQtyByTrimmedWarehouseArea"
resultType="org.jeecg.modules.xslmes.dto.MesXslAreaRemainQtySumDTO">
SELECT TRIM(t.warehouse_area) AS areaCode,
COALESCE(SUM(COALESCE(t.remaining_quantity, 0)), 0) AS qtySum
FROM mes_xsl_raw_material_card t
WHERE t.warehouse_area IS NOT NULL
AND TRIM(t.warehouse_area) != ''
AND TRIM(t.warehouse_area) IN
<foreach collection="areaCodes" item="c" open="(" separator="," close=")">
#{c}
</foreach>
GROUP BY TRIM(t.warehouse_area)
</select>
</mapper>

View File

@@ -0,0 +1,18 @@
package org.jeecg.modules.xslmes.service;
import org.jeecg.modules.xslmes.vo.MesXslRawMaterialWarehouseBoardVO;
/**
* 原材料库区看板(库区 + 原材料卡片聚合)
*/
public interface IMesXslRawMaterialWarehouseBoardService {
/**
* 聚合看板数据
*
* @param warehouseId 所属仓库ID可空表示全部启用库区
* @param keyword 物料名称/条码/批次模糊筛选(可空);非空时仅展示仍有匹配卡片的库区
* @param measureType quantity=占用率按剩余数量与 maxCapacity 对齐weight=按剩余重量
*/
MesXslRawMaterialWarehouseBoardVO queryBoard(String warehouseId, String keyword, String measureType);
}

View File

@@ -3,6 +3,8 @@ package org.jeecg.modules.xslmes.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.entity.MesXslWarehouseArea;
import java.util.Collection;
/**
* MES 库区管理
*/
@@ -12,4 +14,9 @@ public interface IMesXslWarehouseAreaService extends IService<MesXslWarehouseAre
* 同租户下是否已存在相同库区编码(编辑时传 excludeId 排除自身)
*/
boolean existsSameAreaCode(String areaCode, String excludeId);
/**
* 按配置策略回填列表中的「实际存放量」展示值(不写库)
*/
void enrichDisplayedActualCapacity(Collection<MesXslWarehouseArea> areas);
}

View File

@@ -0,0 +1,248 @@
package org.jeecg.modules.xslmes.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.xslmes.entity.MesXslRawMaterialCard;
import org.jeecg.modules.xslmes.entity.MesXslWarehouseArea;
import org.jeecg.modules.xslmes.service.IMesXslRawMaterialCardService;
import org.jeecg.modules.xslmes.service.IMesXslRawMaterialWarehouseBoardService;
import org.jeecg.modules.xslmes.service.IMesXslWarehouseAreaService;
import org.jeecg.modules.xslmes.vo.MesXslRawMaterialWarehouseBoardVO;
import org.jeecg.modules.xslmes.vo.MesXslRawMaterialWarehouseBoardVO.BoardAreaCardVO;
import org.jeecg.modules.xslmes.vo.MesXslRawMaterialWarehouseBoardVO.BoardBandVO;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 原材料库区看板
*/
@Service
@RequiredArgsConstructor
public class MesXslRawMaterialWarehouseBoardServiceImpl implements IMesXslRawMaterialWarehouseBoardService {
private static final String MEASURE_WEIGHT = "weight";
/** 库区编码首段形如 1F、2P、03A 等,仅作看板分组带标题(与仓库分类层级无「楼层」对应关系) */
private static final Pattern BAND_FIRST_SEGMENT = Pattern.compile("^\\d+[A-Za-z]+$");
private final IMesXslWarehouseAreaService mesXslWarehouseAreaService;
private final IMesXslRawMaterialCardService mesXslRawMaterialCardService;
@Override
public MesXslRawMaterialWarehouseBoardVO queryBoard(String warehouseId, String keyword, String measureType) {
String mt = StringUtils.defaultIfBlank(measureType, "quantity").toLowerCase(Locale.ROOT).trim();
if (!MEASURE_WEIGHT.equals(mt)) {
mt = "quantity";
}
MesXslRawMaterialWarehouseBoardVO vo = new MesXslRawMaterialWarehouseBoardVO();
vo.setMeasureType(mt);
QueryWrapper<MesXslWarehouseArea> areaQw = new QueryWrapper<>();
areaQw.eq("status", "0");
if (StringUtils.isNotBlank(warehouseId)) {
areaQw.eq("warehouse_id", warehouseId.trim());
}
areaQw.orderByAsc("area_code");
List<MesXslWarehouseArea> areas = mesXslWarehouseAreaService.list(areaQw);
if (areas.isEmpty()) {
return vo;
}
mesXslWarehouseAreaService.enrichDisplayedActualCapacity(areas);
List<String> areaCodes = areas.stream().map(MesXslWarehouseArea::getAreaCode).filter(StringUtils::isNotBlank).map(String::trim).distinct().collect(Collectors.toList());
QueryWrapper<MesXslRawMaterialCard> cardQw = new QueryWrapper<>();
cardQw.in("warehouse_area", areaCodes);
cardQw.apply("(COALESCE(remaining_quantity,0) > 0 OR COALESCE(remaining_weight,0) > 0)");
String kw = StringUtils.trimToNull(keyword);
if (kw != null) {
cardQw.and(w ->
w.like("material_name", kw).or().like("barcode", kw).or().like("batch_no", kw));
}
List<MesXslRawMaterialCard> cards = mesXslRawMaterialCardService.list(cardQw);
Map<String, List<MesXslRawMaterialCard>> byArea =
cards.stream().filter(c -> StringUtils.isNotBlank(c.getWarehouseArea())).collect(Collectors.groupingBy(c -> c.getWarehouseArea().trim(), Collectors.toList()));
boolean filterByKeyword = kw != null;
Map<String, BoardBandVO> bandMap = new LinkedHashMap<>();
for (MesXslWarehouseArea area : areas) {
String code = StringUtils.trimToEmpty(area.getAreaCode());
if (code.isEmpty()) {
continue;
}
List<MesXslRawMaterialCard> list = byArea.getOrDefault(code, new ArrayList<>());
if (filterByKeyword && list.isEmpty()) {
continue;
}
BoardAreaCardVO cardVo = aggregateArea(area, list, mt);
BandKey bk = resolveBand(area.getAreaCode());
BoardBandVO band = bandMap.computeIfAbsent(bk.key, k -> {
BoardBandVO b = new BoardBandVO();
b.setBandKey(bk.key);
b.setBandLabel(bk.label);
b.setBandSort(bk.sort);
return b;
});
band.getAreas().add(cardVo);
}
List<BoardBandVO> bands =
bandMap.values().stream().sorted(Comparator.comparing(BoardBandVO::getBandSort, Comparator.nullsLast(Integer::compareTo)).thenComparing(BoardBandVO::getBandKey, Comparator.nullsLast(String::compareTo))).collect(Collectors.toList());
for (BoardBandVO band : bands) {
band.getAreas().sort(Comparator.comparing(BoardAreaCardVO::getAreaCode, Comparator.nullsLast(String::compareTo)));
}
vo.setBands(bands);
return vo;
}
private BoardAreaCardVO aggregateArea(MesXslWarehouseArea area, List<MesXslRawMaterialCard> list, String measureType) {
BoardAreaCardVO vo = new BoardAreaCardVO();
vo.setAreaId(area.getId());
vo.setAreaCode(area.getAreaCode());
vo.setAreaName(area.getAreaName());
vo.setWarehouseId(area.getWarehouseId());
vo.setWarehouseName(area.getWarehouseName());
vo.setMaxCapacity(area.getMaxCapacity());
vo.setActualCapacity(area.getActualCapacity());
vo.setCardCount(list.size());
int sumQty = 0;
BigDecimal sumW = BigDecimal.ZERO;
LinkedHashSet<String> materialNames = new LinkedHashSet<>();
for (MesXslRawMaterialCard c : list) {
if (c.getRemainingQuantity() != null) {
sumQty += c.getRemainingQuantity();
}
if (c.getRemainingWeight() != null) {
sumW = sumW.add(c.getRemainingWeight());
}
if (StringUtils.isNotBlank(c.getMaterialName())) {
materialNames.add(c.getMaterialName().trim());
}
}
vo.setCurrentQuantity(sumQty);
vo.setCurrentWeight(sumW.setScale(3, RoundingMode.HALF_UP));
vo.setMaterialKindCount(materialNames.size());
vo.setTopMaterialNames(materialNames.stream().limit(8).collect(Collectors.toList()));
Double usage = null;
Integer maxCap = area.getMaxCapacity();
if (maxCap != null && maxCap > 0) {
if (MEASURE_WEIGHT.equals(measureType)) {
usage = sumW.multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(maxCap), 1, RoundingMode.HALF_UP).doubleValue();
} else {
usage = BigDecimal.valueOf(sumQty).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(maxCap), 1, RoundingMode.HALF_UP).doubleValue();
}
if (usage > 100) {
usage = 100d;
}
if (usage < 0) {
usage = 0d;
}
}
vo.setUsagePercent(usage);
vo.setAlertLevel(resolveAlert(usage, list.isEmpty()));
return vo;
}
private static String resolveAlert(Double usagePercent, boolean emptyCards) {
if (emptyCards) {
return "empty";
}
if (usagePercent == null) {
return "unknown";
}
if (usagePercent <= 0) {
return "empty";
}
if (usagePercent < 30) {
return "low";
}
if (usagePercent < 90) {
return "normal";
}
if (usagePercent < 100) {
return "high";
}
return "full";
}
private BandKey resolveBand(String areaCode) {
if (StringUtils.isBlank(areaCode)) {
return new BandKey("zzz_other", "其它", 999_000);
}
String trimmed = areaCode.trim();
String head = trimmed.contains("-") ? trimmed.substring(0, trimmed.indexOf('-')) : trimmed;
if (BAND_FIRST_SEGMENT.matcher(head).matches()) {
String normalized = normalizeBandToken(head);
int sort = bandSort(normalized);
return new BandKey(normalized.toLowerCase(Locale.ROOT), normalized, sort);
}
String label = StringUtils.left(head, 16);
if (label.isEmpty()) {
label = trimmed.length() > 16 ? trimmed.substring(0, 16) : trimmed;
}
return new BandKey("z_" + label.toLowerCase(Locale.ROOT), label, 888_000 + Math.abs(label.hashCode() % 1000));
}
private static String normalizeBandToken(String raw) {
if (raw == null) {
return "其它";
}
String s = raw.toUpperCase(Locale.ROOT);
s = s.replace('', 'F').replace('', 'P');
return s;
}
/**
* 简单排序按前缀中的数字升序1F&lt;2F&lt;10F末尾字母次之
*/
private static int bandSort(String label) {
String digits = "";
String tail = "";
for (int i = 0; i < label.length(); i++) {
char ch = label.charAt(i);
if (Character.isDigit(ch)) {
digits += ch;
} else {
tail = label.substring(i);
break;
}
}
int num = 0;
try {
if (!digits.isEmpty()) {
num = Integer.parseInt(digits);
}
} catch (NumberFormatException ignored) {
num = 0;
}
return num * 100 + Math.min(Math.abs(tail.hashCode() % 99), 99);
}
private static final class BandKey {
final String key;
final String label;
final int sort;
BandKey(String key, String label, int sort) {
this.key = key;
this.label = label;
this.sort = sort;
}
}
}

View File

@@ -1,18 +1,25 @@
package org.jeecg.modules.xslmes.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.xslmes.capacity.WarehouseAreaDisplayedActualCapacityMediator;
import org.jeecg.modules.xslmes.entity.MesXslWarehouseArea;
import org.jeecg.modules.xslmes.mapper.MesXslWarehouseAreaMapper;
import org.jeecg.modules.xslmes.service.IMesXslWarehouseAreaService;
import org.springframework.stereotype.Service;
import java.util.Collection;
/**
* MES 库区管理
*/
@Service
@RequiredArgsConstructor
public class MesXslWarehouseAreaServiceImpl extends ServiceImpl<MesXslWarehouseAreaMapper, MesXslWarehouseArea> implements IMesXslWarehouseAreaService {
private final WarehouseAreaDisplayedActualCapacityMediator displayedActualCapacityMediator;
@Override
public boolean existsSameAreaCode(String areaCode, String excludeId) {
if (StringUtils.isBlank(areaCode)) {
@@ -24,4 +31,9 @@ public class MesXslWarehouseAreaServiceImpl extends ServiceImpl<MesXslWarehouseA
.ne(StringUtils.isNotBlank(excludeId), MesXslWarehouseArea::getId, excludeId)
.count() > 0L;
}
@Override
public void enrichDisplayedActualCapacity(Collection<MesXslWarehouseArea> areas) {
displayedActualCapacityMediator.apply(areas);
}
}

View File

@@ -41,11 +41,11 @@ public class MesXslWarehouseServiceImpl extends ServiceImpl<MesXslWarehouseMappe
return;
}
String code = baseMapper.queryCategoryCodeById(e.getWarehouseCategory());
if (!MesXslWarehouseCategory.CUSTOMER_CATEGORY_CODE.equals(code)) {
if (!MesXslWarehouseCategory.isCustomerWarehouseCategoryCode(code)) {
e.setCustomerId(null);
e.setCustomerShortName(null);
}
if (!MesXslWarehouseCategory.SUPPLIER_CATEGORY_CODE.equals(code)) {
if (!MesXslWarehouseCategory.isSupplierWarehouseCategoryCode(code)) {
e.setSupplierId(null);
e.setSupplierShortName(null);
}
@@ -56,11 +56,11 @@ public class MesXslWarehouseServiceImpl extends ServiceImpl<MesXslWarehouseMappe
return;
}
String code = baseMapper.queryCategoryCodeById(e.getWarehouseCategory());
if (MesXslWarehouseCategory.CUSTOMER_CATEGORY_CODE.equals(code)) {
if (MesXslWarehouseCategory.isCustomerWarehouseCategoryCode(code)) {
if (oConvertUtils.isEmpty(e.getCustomerId())) {
throw new JeecgBootException("仓库分类为客户库时,请选择客户");
}
} else if (MesXslWarehouseCategory.SUPPLIER_CATEGORY_CODE.equals(code)) {
} else if (MesXslWarehouseCategory.isSupplierWarehouseCategoryCode(code)) {
if (oConvertUtils.isEmpty(e.getSupplierId())) {
throw new JeecgBootException("仓库分类为供应商库时,请选择供应商");
}

View File

@@ -0,0 +1,91 @@
package org.jeecg.modules.xslmes.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 原材料库区看板:按库区编码前缀等规则形成的「分组带」汇总展示(不按业务楼层拆分)
*/
@Data
@Schema(description = "原材料库区看板")
public class MesXslRawMaterialWarehouseBoardVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "计量口径回显quantity=按剩余数量 weight=按剩余重量")
private String measureType;
@Schema(description = "分组带列表")
private List<BoardBandVO> bands = new ArrayList<>();
@Data
@Schema(description = "看板分组带(如 1F、2F")
public static class BoardBandVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "分组键(排序用)")
private String bandKey;
@Schema(description = "分组标题")
private String bandLabel;
@Schema(description = "排序号,越小越靠前")
private Integer bandSort;
@Schema(description = "该组下的库区卡片")
private List<BoardAreaCardVO> areas = new ArrayList<>();
}
@Data
@Schema(description = "单个库区卡片数据")
public static class BoardAreaCardVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "库区主键")
private String areaId;
@Schema(description = "库区编码")
private String areaCode;
@Schema(description = "库区名称")
private String areaName;
@Schema(description = "所属仓库ID")
private String warehouseId;
@Schema(description = "所属仓库名称")
private String warehouseName;
@Schema(description = "最大存放量(库区主数据)")
private Integer maxCapacity;
@Schema(description = "实际存放量(库区主数据快照)")
private Integer actualCapacity;
@Schema(description = "在库卡片张数(有剩余量)")
private Integer cardCount;
@Schema(description = "物料种类数(去重物料名称)")
private Integer materialKindCount;
@Schema(description = "剩余数量合计")
private Integer currentQuantity;
@Schema(description = "剩余重量合计")
private BigDecimal currentWeight;
@Schema(description = "物料名称摘要(前若干种)")
private List<String> topMaterialNames = new ArrayList<>();
@Schema(description = "占用率 0100无上限或上限为0时为 null")
private Double usagePercent;
@Schema(description = "告警级别empty/low/normal/high/full/unknown")
private String alertLevel;
}
}

View File

@@ -2,6 +2,8 @@ spring:
application:
name: jeecg-system
config:
import: optional:classpath:config/application-liteflow.yml
import:
- optional:classpath:config/application-liteflow.yml
- optional:classpath:config/application-xslmes-warehouse-area.yml
profiles:
active: '@profile.name@'

View File

@@ -0,0 +1,11 @@
# MES XSL — 库区「实际存放量」展示回填(不写库,可覆盖)
xslmes:
warehouse-area:
display-actual-capacity:
enabled: true
# 按「原材料库」分类编码解析 sys_category.id推荐业务树已无楼层语义编码中 F1/F2 后缀若变化请改此处)
raw-material-warehouse-category-codes:
- XSLMES_WH_F1_YCL
- XSLMES_WH_F2_YCL
# 可选:直接写 warehouse_category=id与上面的编码解析结果合并
raw-material-warehouse-category-ids: []

View File

@@ -0,0 +1,19 @@
-- 原材料库区看板菜单与查询权限父菜单 MES XSL 1900000000000000300
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`)
SELECT '1900000000000000580', '1900000000000000300', '原材料库区看板', '/xslmes/mesXslRawMaterialWarehouseBoard', 'xslmes/mesXslRawMaterialWarehouseBoard/MesXslRawMaterialWarehouseBoard', 1, NULL, NULL, 1, NULL, '0', 12.50, 0, 'ant-design:layout-outlined', 0, 1, 0, 0, '按库区聚合展示原材料卡片库存', 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000580');
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`)
SELECT '1900000000000000581', '1900000000000000580', '查询', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_raw_material_warehouse_board:list', '1', 1.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000581');
INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`)
SELECT REPLACE(UUID(), '-', ''), r.id, '1900000000000000580', NULL, NOW(), '127.0.0.1'
FROM `sys_role` r WHERE r.`role_code` = 'admin'
AND NOT EXISTS (SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.id AND rp.`permission_id` = '1900000000000000580');
INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`)
SELECT REPLACE(UUID(), '-', ''), r.id, '1900000000000000581', NULL, NOW(), '127.0.0.1'
FROM `sys_role` r WHERE r.`role_code` = 'admin'
AND NOT EXISTS (SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.id AND rp.`permission_id` = '1900000000000000581');

View File

@@ -0,0 +1,8 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
board = '/xslmes/mesXslRawMaterialWarehouseBoard/board',
}
export const fetchBoard = (params: { warehouseId?: string; keyword?: string; measureType?: string }) =>
defHttp.get({ url: Api.board, params });

View File

@@ -0,0 +1,725 @@
<template>
<div class="rmb-page">
<div class="rmb-toolbar card-surface">
<div class="rmb-toolbar-row">
<div class="rmb-title">
<span class="rmb-title-icon" />
<div>
<div class="rmb-title-text">原材料库区看板</div>
<div class="rmb-title-sub">按库区聚合条码卡片点击查看明细</div>
</div>
</div>
<div class="rmb-actions">
<a-button type="primary" ghost @click="loadBoard">
<Icon icon="ant-design:reload-outlined" />
刷新
</a-button>
</div>
</div>
<div class="rmb-filter" role="search">
<div class="rmb-filter-row">
<div class="rmb-inline-field">
<span class="rmb-inline-label">所属仓库</span>
<a-select
v-model:value="warehouseId"
allow-clear
placeholder="全部仓库"
class="rmb-filter-control"
:loading="warehouseLoading"
:options="warehouseOptions"
/>
</div>
<div class="rmb-inline-field">
<span class="rmb-inline-label">物料 / 条码 / 批次</span>
<a-input
v-model:value="keyword"
placeholder="关键字模糊筛选"
class="rmb-filter-control"
allow-clear
@press-enter="loadBoard"
/>
</div>
<div class="rmb-inline-field">
<span class="rmb-inline-label">占用率口径</span>
<a-radio-group v-model:value="measureType" button-style="solid" @change="loadBoard">
<a-radio-button value="quantity">剩余数量</a-radio-button>
<a-radio-button value="weight">剩余重量</a-radio-button>
</a-radio-group>
</div>
<div class="rmb-inline-field rmb-inline-actions">
<a-button type="primary" @click="loadBoard">查询</a-button>
<a-button @click="resetFilter">重置</a-button>
</div>
</div>
</div>
</div>
<a-spin :spinning="loading">
<div v-if="!bands.length && !loading" class="rmb-empty card-surface">
<a-empty description="暂无启用库区或无匹配数据" />
</div>
<div v-for="band in bands" :key="band.bandKey" class="rmb-band card-surface">
<div class="rmb-band-head">
<span class="rmb-band-label">{{ band.bandLabel }}</span>
<span class="rmb-band-count">{{ band.areas?.length || 0 }} 个库区</span>
</div>
<div class="rmb-card-row">
<div
v-for="area in band.areas"
:key="area.areaId"
class="rmb-card"
:class="'rmb-card--' + (area.alertLevel || 'unknown')"
@click="openDetail(area)"
>
<div class="rmb-card-head">
<span class="rmb-card-code">{{ area.areaCode }}</span>
<a-tag v-if="area.warehouseName" color="processing" class="rmb-card-wh">{{ area.warehouseName }}</a-tag>
</div>
<div class="rmb-card-name">{{ area.areaName || area.areaCode }}</div>
<div class="rmb-card-stats">
<div>
<div class="rmb-stat-label">当前数量</div>
<div class="rmb-stat-value">{{ area.currentQuantity ?? 0 }}</div>
</div>
<div>
<div class="rmb-stat-label">当前重量</div>
<div class="rmb-stat-value">{{ formatWeight(area.currentWeight) }}</div>
</div>
<div>
<div class="rmb-stat-label">上限</div>
<div class="rmb-stat-value">{{ area.maxCapacity ?? '—' }}</div>
</div>
</div>
<a-progress
:percent="progressPercent(area)"
:show-info="true"
:stroke-color="progressStroke(area)"
:trail-color="'rgba(0,0,0,0.06)'"
size="small"
/>
<div class="rmb-card-foot">
<span class="rmb-meta">卡片 {{ area.cardCount ?? 0 }} · 物料 {{ area.materialKindCount ?? 0 }} </span>
</div>
<div class="rmb-tags">
<template v-for="(m, idx) in (area.topMaterialNames || []).slice(0, 4)" :key="idx">
<a-tooltip :title="m">
<a-tag class="rmb-tag">{{ truncate(m, 10) }}</a-tag>
</a-tooltip>
</template>
<a-tag v-if="(area.topMaterialNames?.length || 0) > 4" class="rmb-tag rmb-tag-more">+{{ (area.topMaterialNames?.length || 0) - 4 }}</a-tag>
</div>
</div>
</div>
</div>
</a-spin>
<a-drawer
v-model:open="detailOpen"
:title="detailTitle"
placement="right"
width="min(96vw, 1080px)"
destroy-on-close
@close="onDetailClose"
@after-open-change="onDrawerAfterOpenChange"
>
<BasicTable @register="registerDetailTable" />
</a-drawer>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialWarehouseBoard" setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { BasicTable, useTable } from '/@/components/Table';
import type { BasicColumn } from '/@/components/Table';
import type { FormSchema } from '/@/components/Form';
import Icon from '/@/components/Icon';
import { fetchBoard } from './MesXslRawMaterialWarehouseBoard.api';
import { list as cardList } from '/@/views/xslmes/mesXslRawMaterialCard/MesXslRawMaterialCard.api';
import { list as warehouseList } from '/@/views/xslmes/mesXslWarehouse/MesXslWarehouse.api';
import { useMessage } from '/@/hooks/web/useMessage';
import { getTenantId } from '/@/utils/auth';
const { createMessage } = useMessage();
/** 上次选择的「所属仓库」持久化键(区分租户) */
const LS_RM_BOARD_WAREHOUSE = 'MES_XSL_RM_BOARD_WAREHOUSE_ID';
function warehouseBoardStorageKey() {
const tid = getTenantId();
if (tid === undefined || tid === null || tid === '') {
return LS_RM_BOARD_WAREHOUSE;
}
return `${LS_RM_BOARD_WAREHOUSE}_${tid}`;
}
/** 写入 localStorage空串/undefined 则清除 */
function persistWarehouseId(id: string | undefined | null) {
const k = warehouseBoardStorageKey();
if (id === undefined || id === null || String(id).trim() === '') {
localStorage.removeItem(k);
return;
}
localStorage.setItem(k, String(id).trim());
}
/** 读取上次选择的仓库 id若无或非法由调用方忽略 */
function readPersistedWarehouseId(): string | undefined {
const v = localStorage.getItem(warehouseBoardStorageKey());
const t = v?.trim();
return t || undefined;
}
interface BoardArea {
areaId: string;
areaCode: string;
areaName?: string;
warehouseId?: string;
warehouseName?: string;
maxCapacity?: number | null;
actualCapacity?: number | null;
cardCount?: number;
materialKindCount?: number;
currentQuantity?: number;
currentWeight?: number | string | null;
topMaterialNames?: string[];
usagePercent?: number | null;
alertLevel?: string;
}
interface BoardBand {
bandKey: string;
bandLabel: string;
bandSort?: number;
areas: BoardArea[];
}
const loading = ref(false);
const warehouseLoading = ref(false);
const measureType = ref<'quantity' | 'weight'>('quantity');
const warehouseId = ref<string | undefined>(undefined);
const keyword = ref('');
const bands = ref<BoardBand[]>([]);
const warehouseOptions = ref<{ label: string; value: string }[]>([]);
const detailOpen = ref(false);
const detailAreaCode = ref('');
const detailTitle = computed(() => (detailAreaCode.value ? `库区明细 · ${detailAreaCode.value}` : '库区明细'));
const detailQuery = reactive({ warehouseArea: '' });
/** 库区明细抽屉:条码/批次/物料 合并关键字 + 剩余数量筛选(单行紧凑布局) */
const detailFormColResponsive = {
xs: 24,
sm: 24,
md: 9,
lg: 9,
xl: 9,
xxl: 9,
span: 9,
} as const;
const detailQtyColResponsive = {
xs: 24,
sm: 24,
md: 7,
lg: 7,
xl: 7,
xxl: 7,
span: 7,
} as const;
const detailSearchFormSchema: FormSchema[] = [
{
label: '条码/批次/物料',
field: 'mixKeyword',
component: 'Input',
componentProps: {
placeholder: '条码/批次/物料模糊',
allowClear: true,
},
colProps: { ...detailFormColResponsive },
},
{
label: '剩余数量',
field: 'remainQtyFilter',
component: 'Select',
defaultValue: '',
componentProps: {
placeholder: '全部',
allowClear: true,
style: { width: '100%', maxWidth: 200 },
options: [
{ label: '全部', value: '' },
{ label: '有剩余', value: 'has' },
{ label: '无剩余', value: 'none' },
],
},
colProps: { ...detailQtyColResponsive },
},
];
/** 查询、重置与本行输入框并排:前两列占 16 栅格,操作列占 8合计 24 */
const detailFormActionCol = { xs: 24, sm: 24, md: 8, lg: 8, xl: 8, xxl: 8, span: 8 };
const detailColumns: BasicColumn[] = [
{ title: '条码', dataIndex: 'barcode', width: 190 },
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
{ title: '入场日期', dataIndex: 'entryDate', width: 110 },
{ title: '物料名称', dataIndex: 'materialName', width: 140 },
{ title: '剩余数量', dataIndex: 'remainingQuantity', width: 90 },
{ title: '剩余重量', dataIndex: 'remainingWeight', width: 90 },
{ title: '检测结果', dataIndex: 'testResult_dictText', width: 90 },
{ title: '库区', dataIndex: 'warehouseArea', width: 100 },
];
const [registerDetailTable, { reload }] = useTable({
title: '原材料卡片明细',
api: cardList,
columns: detailColumns,
useSearchForm: true,
formConfig: {
labelWidth: 108,
layout: 'horizontal',
compact: true,
schemas: detailSearchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
submitButtonOptions: { text: '查询' },
resetButtonOptions: { text: '重置' },
actionColOptions: {
...detailFormActionCol,
style: { textAlign: 'left', whiteSpace: 'nowrap', paddingLeft: '4px' },
},
},
showTableSetting: true,
canResize: true,
immediate: false,
pagination: { pageSize: 20 },
beforeFetch: (params) => {
const merged = Object.assign({}, params, {
warehouseArea: (detailQuery.warehouseArea || '').trim(),
});
const v = merged.remainQtyFilter;
// 后端只认 remainQtyFilter=has/none空串不传避免误筛
if (v === '' || v === undefined || v === null) {
delete merged.remainQtyFilter;
}
const kw = merged.mixKeyword;
if (kw === '' || kw === undefined || kw === null || String(kw).trim() === '') {
delete merged.mixKeyword;
} else if (typeof kw === 'string') {
merged.mixKeyword = kw.trim();
}
return merged;
},
});
async function loadWarehouses() {
warehouseLoading.value = true;
try {
const res = await warehouseList({ pageNo: 1, pageSize: 500 });
const records = res?.records ?? [];
warehouseOptions.value = records.map((r: Record<string, string>) => ({
label: r.warehouseName || r.warehouseCode || r.id,
value: r.id,
}));
// 恢复上次选择的仓库(仅当仍在列表中)
const savedId = readPersistedWarehouseId();
if (savedId) {
const ok = warehouseOptions.value.some((o) => o.value === savedId);
if (ok) {
warehouseId.value = savedId;
} else {
localStorage.removeItem(warehouseBoardStorageKey());
}
}
} catch {
createMessage.warning('加载仓库列表失败');
} finally {
warehouseLoading.value = false;
}
}
async function loadBoard() {
loading.value = true;
try {
const data = await fetchBoard({
warehouseId: warehouseId.value,
keyword: keyword.value?.trim(),
measureType: measureType.value,
});
bands.value = (data?.bands as BoardBand[]) || [];
if (!(data?.bands?.length ?? 0)) {
bands.value = [];
}
} catch (e: unknown) {
createMessage.error(e instanceof Error ? e.message : '加载看板失败');
bands.value = [];
} finally {
loading.value = false;
}
}
function resetFilter() {
warehouseId.value = undefined;
persistWarehouseId(undefined);
keyword.value = '';
measureType.value = 'quantity';
loadBoard();
}
function progressPercent(area: BoardArea): number {
const p = area.usagePercent;
if (p == null || Number.isNaN(p)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(p)));
}
function progressStroke(area: BoardArea) {
const level = area.alertLevel;
const map: Record<string, string> = {
empty: '#bfbfbf',
low: '#597ef7',
normal: 'var(--j-global-primary-color, #1677ff)',
high: '#fa8c16',
full: '#f5222d',
unknown: '#8c8c8c',
};
return map[level || 'unknown'] || map.unknown;
}
function formatWeight(w: BoardArea['currentWeight']) {
if (w == null || w === '') {
return '—';
}
const n = Number(w);
if (Number.isFinite(n)) {
return n.toFixed(3);
}
return String(w);
}
function truncate(s: string, n: number) {
return s.length > n ? `${s.slice(0, n)}` : s;
}
function openDetail(area: BoardArea) {
const code = String(area.areaCode ?? '').trim();
detailQuery.warehouseArea = code;
detailAreaCode.value = code;
detailOpen.value = true;
// 不在此处 reload抽屉 destroy-on-close 时子表格可能尚未挂载,会导致请求未带上条件或无实例
}
/** 抽屉打开动画完成后再拉取明细,确保 BasicTable 已 register */
async function onDrawerAfterOpenChange(open: boolean) {
if (!open) {
return;
}
const code = String(detailQuery.warehouseArea || '').trim();
if (!code) {
return;
}
await nextTick();
await reload();
}
function onDetailClose() {
detailQuery.warehouseArea = '';
}
// 用户切换「所属仓库」时持久化(无 immediate避免挂载前误清空缓存
watch(warehouseId, (v) => {
persistWarehouseId(v);
});
onMounted(async () => {
await loadWarehouses();
await loadBoard();
});
</script>
<style scoped lang="less">
.rmb-page {
padding: 0 8px 16px;
min-height: calc(100vh - 120px);
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, transparent 480px),
radial-gradient(1200px 400px at 10% -10%, rgba(82, 196, 26, 0.08), transparent);
}
.card-surface {
background: rgba(255, 255, 255, 0.92);
border-radius: 14px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
border: 1px solid rgba(15, 23, 42, 0.06);
backdrop-filter: blur(8px);
}
.rmb-toolbar {
padding: 16px 20px 12px;
margin-bottom: 16px;
}
.rmb-toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.rmb-title {
display: flex;
align-items: center;
gap: 12px;
}
.rmb-title-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, var(--j-global-primary-color, #1677ff), #722ed1);
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.35);
flex-shrink: 0;
}
.rmb-title-text {
font-size: 18px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
letter-spacing: 0.02em;
}
.rmb-title-sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
.rmb-filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
}
.rmb-inline-field {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 32px;
}
/* 标签右对齐 + 固定宽度,与控件纵向居中对齐 */
.rmb-inline-label {
flex: 0 0 148px;
width: 148px;
text-align: right;
font-size: 14px;
line-height: 32px;
color: rgba(0, 0, 0, 0.88);
}
.rmb-filter-control {
width: 220px !important;
}
/* 按钮组与其它字段同一中线,不靠虚构标签占位 */
.rmb-inline-actions {
gap: 8px;
padding-left: 4px;
margin-left: 4px;
border-left: 1px solid rgba(15, 23, 42, 0.08);
}
@media (max-width: 768px) {
.rmb-inline-label {
flex: 0 0 100%;
width: 100%;
text-align: left;
line-height: 1.4;
}
.rmb-inline-field {
flex-wrap: wrap;
width: 100%;
}
.rmb-filter-control {
width: 100% !important;
}
.rmb-inline-actions {
width: 100%;
margin-left: 0;
padding-left: 0;
border-left: none;
}
}
.rmb-empty {
padding: 48px;
margin-bottom: 16px;
}
.rmb-band {
padding: 16px 16px 12px;
margin-bottom: 16px;
}
.rmb-band-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
.rmb-band-label {
font-size: 22px;
font-weight: 800;
color: rgba(0, 0, 0, 0.85);
letter-spacing: 0.06em;
}
.rmb-band-count {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.rmb-card-row {
display: flex;
flex-wrap: nowrap;
gap: 14px;
overflow-x: auto;
padding-bottom: 6px;
scroll-snap-type: x proximity;
}
.rmb-card {
flex: 0 0 280px;
scroll-snap-align: start;
padding: 14px 14px 12px;
border-radius: 12px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease;
border: 1px solid rgba(22, 119, 255, 0.12);
background: linear-gradient(145deg, #ffffff 0%, #f6faff 100%);
}
.rmb-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--j-global-primary-color, #1677ff);
opacity: 0.85;
}
.rmb-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(22, 119, 255, 0.18);
}
.rmb-card--empty::before {
background: #bfbfbf;
}
.rmb-card--low::before {
background: #597ef7;
}
.rmb-card--normal::before {
background: var(--j-global-primary-color, #1677ff);
}
.rmb-card--high::before {
background: #fa8c16;
}
.rmb-card--full::before {
background: #f5222d;
}
.rmb-card--unknown::before {
background: #8c8c8c;
}
.rmb-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.rmb-card-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-weight: 700;
font-size: 15px;
color: rgba(0, 0, 0, 0.88);
}
.rmb-card-wh {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.rmb-card-name {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 10px;
min-height: 18px;
}
.rmb-card-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-bottom: 10px;
}
.rmb-stat-label {
font-size: 11px;
color: rgba(0, 0, 0, 0.45);
}
.rmb-stat-value {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.rmb-card-foot {
margin-top: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.rmb-meta {
display: inline-block;
}
.rmb-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rmb-tag {
margin: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.rmb-tag-more {
border-style: dashed;
}
</style>