xiaoyi
2 天以前 b5e1bceabb36fe1d1e5e88397280d12e7044c6dd
完善设备采集、安全管理及采购业务
已添加18个文件
已修改15个文件
660 ■■■■■ 文件已修改
sql/mysql/purchase_price_analysis_menu_20261106.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/safety_document_archive_20261106.sql 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/safety_hazard_notice_20261106.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/srm_warehouse_menu_20261106.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpPurchaseStatisticsController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchasePriceRespVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpPurchaseStatisticsMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/PurchasePriceMetrics.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/resources/mapper/statistics/ErpPurchaseStatisticsMapper.xml 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/MesCollectController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/vo/MesCollectOfflineIntervalRespVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/vo/MesCollectOfflineResultRespVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/SafetyDocumentController.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/document/SafetyDocumentApprovalReqVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/document/SafetyDocumentSaveReqVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyDocumentApprovalDO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyDocumentDO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyDocumentApprovalMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyDocumentMapper.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyHazardMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/SafetyErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/collect/CollectOfflinePolicy.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/collect/MesCollectQueryService.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyDocumentPolicy.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyDocumentService.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyHazardService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/tender/vo/SrmSupplierQuoteSaveReqVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/dal/mysql/SrmTenderConfigMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/enums/ErrorCodeConstants.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderBidServiceImpl.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderConfigServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/purchase_price_analysis_menu_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
-- å°†é‡‡è´­ä»·æ ¼åˆ†æžå½’入现有决策分析菜单,不修改一级菜单或自动授予角色权限。
-- ç›®æ ‡çŽ¯å¢ƒæ²¡æœ‰ /bi æ—¶ï¼Œè¯·æŒ‰çŽ°æœ‰èœå•ç»“æž„é€‰æ‹©çˆ¶èœå•åŽå†æ‰§è¡Œã€‚
SET @price_parent := (SELECT id FROM system_menu WHERE parent_id=0 AND path='/bi' AND type=1 AND deleted=b'0' LIMIT 1);
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, updater)
SELECT '采购价格分析', 'erp:statistics:query', 2, 50, @price_parent, 'purchase-price', 'lucide:chart-column', 'erp/purchase/statistics/price', 'ErpPurchaseStatisticsPrice', 0, b'1', b'1', b'1', '1', '1'
WHERE @price_parent IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component='erp/purchase/statistics/price' AND deleted=b'0');
sql/mysql/safety_document_archive_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
-- å®‰å…¨åˆ¶åº¦æŒ‰ç‰ˆæœ¬ä¿å­˜ï¼Œå¾…审批版本不会覆盖已批准版本。
CREATE TABLE IF NOT EXISTS safety_document (
  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  document_key VARCHAR(36) NOT NULL,
  revision INT NOT NULL,
  title VARCHAR(200) NOT NULL,
  category VARCHAR(50) NOT NULL,
  attachment_url VARCHAR(1000) NOT NULL,
  description VARCHAR(2000) NULL,
  effective_date DATE NULL,
  status VARCHAR(20) NOT NULL,
  owner_user_id BIGINT NOT NULL,
  submitter_user_id BIGINT NULL,
  submit_time DATETIME NULL,
  approver_user_id BIGINT NULL,
  approval_time DATETIME NULL,
  approval_comment VARCHAR(500) NULL,
  tenant_id BIGINT NOT NULL DEFAULT 0,
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  UNIQUE KEY uk_safety_document_revision (tenant_id,document_key,revision),
  KEY idx_safety_document_status (status,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='安全制度版本档案';
CREATE TABLE IF NOT EXISTS safety_document_approval (
  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  document_id BIGINT NOT NULL, approver_user_id BIGINT NOT NULL,
  decision VARCHAR(20) NOT NULL, comment VARCHAR(500) NULL, decision_time DATETIME NOT NULL,
  submitter_user_id BIGINT NULL, title_snapshot VARCHAR(200) NULL, category_snapshot VARCHAR(50) NULL,
  attachment_url_snapshot VARCHAR(1000) NULL, description_snapshot VARCHAR(2000) NULL, effective_date_snapshot DATE NULL,
  tenant_id BIGINT NOT NULL DEFAULT 0,
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0', KEY idx_safety_document_approval (document_id,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='安全制度审批记录';
-- ä»…在现有安全生产一级菜单下创建页面与按钮,不自动分配角色。
-- éœ€è¦ä¸Šä¼ é™„件的角色还应授予现有 safety:file:upload æƒé™ã€‚
SET @safety_document_parent := (SELECT id FROM system_menu WHERE parent_id=0 AND path='/safety' AND type=1 AND deleted=b'0' LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater)
SELECT '安全制度档案','',2,35,@safety_document_parent,'document','lucide:files','safety/document/index','SafetyDocument',0,b'1',b'1',b'1','1','1'
WHERE @safety_document_parent IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component='safety/document/index' AND deleted=b'0');
SET @safety_document_page := (SELECT id FROM system_menu WHERE component='safety/document/index' AND deleted=b'0' LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,status,visible,keep_alive,always_show,creator,updater)
SELECT p.name,p.permission,3,p.sort_no,@safety_document_page,'','',0,b'1',b'1',b'1','1','1'
FROM (SELECT '查询' name,'safety:document:query' permission,1 sort_no UNION ALL
      SELECT '新增','safety:document:create',2 UNION ALL SELECT '修改和提交','safety:document:update',3 UNION ALL SELECT '审批','safety:document:approve',4) p
WHERE @safety_document_page IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu m WHERE m.parent_id=@safety_document_page AND m.permission=p.permission AND m.deleted=b'0');
sql/mysql/safety_hazard_notice_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
-- é𐿂£åˆ†æ´¾ç«™å†…通知模板。请按目标系统权限配置消息接收人账号。
INSERT INTO system_notify_template (name,code,nickname,content,type,params,status,remark,creator,updater,deleted)
SELECT '隐患整改通知','safety_hazard_assigned','安全生产',
       '隐患 {code}:{title} å·²åˆ†æ´¾ç»™æ‚¨æ•´æ”¹ï¼Œæ•´æ”¹æœŸé™ï¼š{deadline}。请在隐患台账中处理。',
       1,'["code","title","deadline"]',0,'隐患分派自动通知','1','1',0
WHERE NOT EXISTS (SELECT 1 FROM system_notify_template WHERE code='safety_hazard_assigned' AND deleted=0);
sql/mysql/srm_warehouse_menu_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
-- ä»…当原采购一级菜单已停用时,把已有供应商管理目录归入仓储物流;不增改一级菜单。
SET @old_purchase := (SELECT id FROM system_menu WHERE parent_id=0 AND path='/purchase' AND type=1 AND status=1 AND deleted=b'0' LIMIT 1);
SET @warehouse_root := (SELECT id FROM system_menu WHERE parent_id=0 AND path='/wls' AND type=1 AND status=0 AND deleted=b'0' LIMIT 1);
SET @srm_menu := (SELECT id FROM system_menu WHERE parent_id=@old_purchase AND path='srm' AND type=1 AND deleted=b'0' LIMIT 1);
SET @srm_conflict := (SELECT COUNT(*) FROM system_menu WHERE parent_id=@warehouse_root AND path='srm' AND deleted=b'0' AND id<>IFNULL(@srm_menu,0));
UPDATE system_menu SET parent_id=@warehouse_root, updater='1', update_time=NOW()
WHERE id=@srm_menu AND @warehouse_root IS NOT NULL AND @srm_conflict=0;
-- ä¿ç•™åŽŸæœ‰æŽˆæƒè§’è‰²å¯¹æ–°å¢žç¥–å…ˆèœå•çš„å¯è§æ€§ï¼Œä¸èµ‹äºˆé¢å¤–ä¸šåŠ¡æŒ‰é’®æƒé™ã€‚
INSERT INTO system_role_menu (role_id,menu_id,creator,updater,tenant_id)
SELECT DISTINCT rm.role_id,@warehouse_root,'1','1',rm.tenant_id FROM system_role_menu rm
WHERE rm.menu_id=@srm_menu AND rm.deleted=b'0' AND @warehouse_root IS NOT NULL AND @srm_conflict=0
  AND NOT EXISTS (SELECT 1 FROM system_role_menu existing WHERE existing.role_id=rm.role_id AND existing.tenant_id=rm.tenant_id
      AND existing.menu_id=@warehouse_root AND existing.deleted=b'0');
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpPurchaseStatisticsController.java
@@ -16,6 +16,10 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.format.annotation.DateTimeFormat;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase.ErpPurchasePriceRespVO;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -33,6 +37,16 @@
    @Resource
    private ErpPurchaseStatisticsService purchaseStatisticsService;
    @GetMapping("/price")
    @Operation(summary = "采购价格分析")
    @PreAuthorize("@ss.hasPermission('erp:statistics:query')")
    public CommonResult<PageResult<ErpPurchasePriceRespVO>> getPriceAnalysis(@Validated PageParam page,
            @RequestParam(value = "productId", required = false) Long productId,
            @RequestParam(value = "orderTime", required = false)
            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime[] orderTime) {
        return success(purchaseStatisticsService.getPriceAnalysis(page, productId, orderTime));
    }
    @GetMapping("/summary")
    @Operation(summary = "获得采购统计")
    @PreAuthorize("@ss.hasPermission('erp:statistics:query')")
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchasePriceRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ErpPurchasePriceRespVO {
    private Long productId;
    private String productName;
    private String productBarCode;
    private BigDecimal totalQuantity;
    private BigDecimal avgPrice;
    private BigDecimal minPrice;
    private BigDecimal maxPrice;
    private BigDecimal latestPrice;
    private BigDecimal previousPrice;
    private BigDecimal priceChangeRate;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpPurchaseStatisticsMapper.java
@@ -2,6 +2,8 @@
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase.ErpPurchasePriceRespVO;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -17,4 +19,8 @@
    BigDecimal getPurchasePrice(@Param("beginTime") LocalDateTime beginTime,
                                @Param("endTime") LocalDateTime endTime);
    IPage<ErpPurchasePriceRespVO> selectPriceAnalysis(IPage<ErpPurchasePriceRespVO> page,
            @Param("productId") Long productId, @Param("beginTime") LocalDateTime beginTime,
            @Param("endTime") LocalDateTime endTime);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsService.java
@@ -2,6 +2,9 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase.ErpPurchasePriceRespVO;
/**
 * ERP é‡‡è´­ç»Ÿè®¡ Service æŽ¥å£
@@ -21,4 +24,6 @@
     */
    BigDecimal getPurchasePrice(LocalDateTime beginTime, LocalDateTime endTime);
    PageResult<ErpPurchasePriceRespVO> getPriceAnalysis(PageParam page, Long productId, LocalDateTime[] orderTime);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsServiceImpl.java
@@ -6,6 +6,10 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase.ErpPurchasePriceRespVO;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
/**
 * ERP é‡‡è´­ç»Ÿè®¡ Service å®žçŽ°ç±»
@@ -23,4 +27,13 @@
        return purchaseStatisticsMapper.getPurchasePrice(beginTime, endTime);
    }
    @Override
    public PageResult<ErpPurchasePriceRespVO> getPriceAnalysis(PageParam page, Long productId, LocalDateTime[] orderTime) {
        LocalDateTime begin = orderTime != null && orderTime.length > 0 ? orderTime[0] : null;
        LocalDateTime end = orderTime != null && orderTime.length > 1 ? orderTime[1] : null;
        var result = purchaseStatisticsMapper.selectPriceAnalysis(new Page<>(page.getPageNo(), page.getPageSize()), productId, begin, end);
        result.getRecords().forEach(row -> row.setPriceChangeRate(PurchasePriceMetrics.changeRate(row.getLatestPrice(), row.getPreviousPrice())));
        return new PageResult<>(result.getRecords(), result.getTotal());
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/PurchasePriceMetrics.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package cn.iocoder.yudao.module.erp.service.statistics;
import java.math.BigDecimal;
import java.math.RoundingMode;
final class PurchasePriceMetrics {
    private PurchasePriceMetrics() {}
    static BigDecimal changeRate(BigDecimal latest, BigDecimal previous) {
        if (latest == null || previous == null || previous.signum() == 0) return null;
        return latest.subtract(previous).multiply(BigDecimal.valueOf(100))
                .divide(previous, 2, RoundingMode.HALF_UP);
    }
}
yudao-module-erp/src/main/resources/mapper/statistics/ErpPurchaseStatisticsMapper.xml
@@ -26,4 +26,23 @@
                AND deleted = 0)
    </select>
</mapper>
    <select id="selectPriceAnalysis" resultType="cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase.ErpPurchasePriceRespVO">
        SELECT ranked.product_id, MAX(m.name) AS product_name, MAX(m.code) AS product_bar_code,
               SUM(ranked.quantity) AS total_quantity,
               ROUND(SUM(ranked.product_price * ranked.quantity) / NULLIF(SUM(ranked.quantity), 0), 4) AS avg_price,
               MIN(ranked.product_price) AS min_price, MAX(ranked.product_price) AS max_price,
               MAX(CASE WHEN ranked.row_num = 1 THEN ranked.product_price END) AS latest_price,
               MAX(CASE WHEN ranked.row_num = 2 THEN ranked.product_price END) AS previous_price
        FROM (
            SELECT i.product_id, i.product_price, i.count AS quantity,
                   ROW_NUMBER() OVER (PARTITION BY i.product_id ORDER BY o.order_time DESC, o.id DESC, i.id DESC) AS row_num
            FROM erp_purchase_order_items i JOIN erp_purchase_order o ON o.id = i.order_id
            WHERE i.deleted = 0 AND o.deleted = 0 AND o.status = 20
              AND i.product_id IS NOT NULL AND i.product_price IS NOT NULL AND i.count > 0
              <if test="productId != null">AND i.product_id = #{productId}</if>
              <if test="beginTime != null">AND o.order_time &gt;= #{beginTime}</if>
              <if test="endTime != null">AND o.order_time &lt;= #{endTime}</if>
        ) ranked LEFT JOIN mdm_item m ON m.id = ranked.product_id AND m.deleted = 0
        GROUP BY ranked.product_id ORDER BY ranked.product_id
    </select>
</mapper>
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/MesCollectController.java
@@ -18,6 +18,8 @@
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.time.LocalDateTime;
import org.springframework.format.annotation.DateTimeFormat;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -30,6 +32,16 @@
    @Resource
    private MesCollectQueryService queryService;
    @GetMapping("/offline/intervals")
    @Operation(summary = "查询数采离线停机区间")
    @PreAuthorize("@ss.hasPermission('mes:collect:query')")
    public CommonResult<MesCollectOfflineResultRespVO> getOfflineIntervals(
            @RequestParam("deviceCode") String deviceCode,
            @RequestParam("startTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
            @RequestParam("endTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
        return success(queryService.getOfflineIntervals(deviceCode, startTime, endTime));
    }
    @GetMapping("/device/list")
    @Operation(summary = "获取数采设备状态列表")
    @PreAuthorize("@ss.hasPermission('mes:collect:query')")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/vo/MesCollectOfflineIntervalRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.mes.controller.admin.collect.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
public class MesCollectOfflineIntervalRespVO {
    private LocalDateTime startTime;
    private LocalDateTime endTime;
    private long durationSeconds;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/vo/MesCollectOfflineResultRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.mes.controller.admin.collect.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@AllArgsConstructor
public class MesCollectOfflineResultRespVO {
    private LocalDateTime firstSampleTime;
    private List<MesCollectOfflineIntervalRespVO> intervals;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/SafetyDocumentController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.document.SafetyDocumentApprovalReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.document.SafetyDocumentSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyDocumentDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyDocumentApprovalDO;
import cn.iocoder.yudao.module.mes.service.safety.SafetyDocumentService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@RestController
@RequestMapping("/safety/document")
@Validated
public class SafetyDocumentController {
    @Resource private SafetyDocumentService service;
    @GetMapping("/page")
    @PreAuthorize("@ss.hasPermission('safety:document:query')")
    public CommonResult<PageResult<SafetyDocumentDO>> page(@Valid PageParam page,
            @RequestParam(value = "title", required = false) String title,
            @RequestParam(value = "status", required = false) String status) {
        return success(service.page(page, title, status));
    }
    @GetMapping("/approvals")
    @PreAuthorize("@ss.hasPermission('safety:document:query')")
    public CommonResult<java.util.List<SafetyDocumentApprovalDO>> approvals(@RequestParam("id") Long id) { return success(service.approvals(id)); }
    @PostMapping("/create")
    @PreAuthorize("@ss.hasPermission('safety:document:create')")
    public CommonResult<Long> create(@Valid @RequestBody SafetyDocumentSaveReqVO req) { return success(service.create(req)); }
    @PutMapping("/update")
    @PreAuthorize("@ss.hasPermission('safety:document:update')")
    public CommonResult<Boolean> update(@Valid @RequestBody SafetyDocumentSaveReqVO req) { service.update(req); return success(true); }
    @PutMapping("/submit")
    @PreAuthorize("@ss.hasPermission('safety:document:update')")
    public CommonResult<Boolean> submit(@RequestParam("id") Long id) { service.submit(id); return success(true); }
    @PutMapping("/approve")
    @PreAuthorize("@ss.hasPermission('safety:document:approve')")
    public CommonResult<Boolean> approve(@Valid @RequestBody SafetyDocumentApprovalReqVO req) { service.approve(req); return success(true); }
    @PostMapping("/revision")
    @PreAuthorize("@ss.hasPermission('safety:document:create')")
    public CommonResult<Long> revision(@RequestParam("id") Long id) { return success(service.createRevision(id)); }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/document/SafetyDocumentApprovalReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety.vo.document;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class SafetyDocumentApprovalReqVO {
    @NotNull private Long id;
    @NotNull private Boolean approved;
    @Size(max = 500) private String comment;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/document/SafetyDocumentSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety.vo.document;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
@Data
public class SafetyDocumentSaveReqVO {
    private Long id;
    @NotBlank @Size(max = 200) private String title;
    @NotBlank @Size(max = 50) private String category;
    @NotBlank @Size(max = 1000) private String attachmentUrl;
    @Size(max = 2000) private String description;
    private LocalDate effectiveDate;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyDocumentApprovalDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.safety;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.time.LocalDate;
@TableName("safety_document_approval")
@Data
@EqualsAndHashCode(callSuper = true)
public class SafetyDocumentApprovalDO extends BaseDO {
    @TableId private Long id;
    private Long documentId;
    private Long approverUserId;
    private String decision;
    private String comment;
    private LocalDateTime decisionTime;
    private Long submitterUserId;
    private String titleSnapshot;
    private String categorySnapshot;
    private String attachmentUrlSnapshot;
    private String descriptionSnapshot;
    private LocalDate effectiveDateSnapshot;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyDocumentDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.safety;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
import java.time.LocalDateTime;
@TableName("safety_document")
@Data
@EqualsAndHashCode(callSuper = true)
public class SafetyDocumentDO extends BaseDO {
    @TableId private Long id;
    private String documentKey;
    private Integer revision;
    private String title;
    private String category;
    private String attachmentUrl;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private String description;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate effectiveDate;
    private String status;
    private Long ownerUserId;
    private Long submitterUserId;
    private LocalDateTime submitTime;
    private Long approverUserId;
    private LocalDateTime approvalTime;
    private String approvalComment;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyDocumentApprovalMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.mes.dal.mysql.safety;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyDocumentApprovalDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SafetyDocumentApprovalMapper extends BaseMapperX<SafetyDocumentApprovalDO> {}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyDocumentMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.mes.dal.mysql.safety;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyDocumentDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SafetyDocumentMapper extends BaseMapperX<SafetyDocumentDO> {
    default SafetyDocumentDO lockById(Long id) {
        return selectOne(new LambdaQueryWrapperX<SafetyDocumentDO>().eq(SafetyDocumentDO::getId, id).last("FOR UPDATE"));
    }
    default SafetyDocumentDO latest(String documentKey) {
        return selectOne(new LambdaQueryWrapperX<SafetyDocumentDO>().eq(SafetyDocumentDO::getDocumentKey, documentKey)
                .orderByDesc(SafetyDocumentDO::getRevision).last("LIMIT 1"));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/safety/SafetyHazardMapper.java
@@ -19,6 +19,10 @@
@Mapper
public interface SafetyHazardMapper extends BaseMapperX<SafetyHazardDO> {
    default SafetyHazardDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<SafetyHazardDO>().eq(SafetyHazardDO::getId, id).last("FOR UPDATE"));
    }
    default PageResult<SafetyHazardDO> selectPage(SafetyHazardPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<SafetyHazardDO>()
                .likeIfPresent(SafetyHazardDO::getCode, reqVO.getCode())
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/SafetyErrorCodeConstants.java
@@ -35,6 +35,8 @@
    ErrorCode SAFETY_HAZARD_STATUS_ERROR = new ErrorCode(1_040_964_001, "当前隐患状态不允许该操作");
    ErrorCode SAFETY_HAZARD_ASSIGNEE_REQUIRED = new ErrorCode(1_040_964_002, "分派必须指定整改责任人");
    ErrorCode SAFETY_HAZARD_SAME_OPERATOR = new ErrorCode(1_040_964_003, "整改人与复查人不能是同一人");
    ErrorCode SAFETY_HAZARD_ASSIGNEE_NO_ACCOUNT = new ErrorCode(1_040_964_004, "整改责任人未关联系统账号,无法发送整改通知");
    ErrorCode SAFETY_HAZARD_NOTICE_UNAVAILABLE = new ErrorCode(1_040_964_005, "整改通知模板未启用,请联系管理员处理");
    // ========== é«˜å±ä½œä¸šç¥¨ 1_040_965_xxx ==========
    ErrorCode SAFETY_PERMIT_NOT_EXISTS = new ErrorCode(1_040_965_000, "作业票不存在");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/collect/CollectOfflinePolicy.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.mes.service.collect;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/** æ ¹æ®é‡‡æ ·é—´éš”æŽ¨ç®—离线区间,不推断首次采样之前的状态。 */
final class CollectOfflinePolicy {
    record Interval(LocalDateTime start, LocalDateTime end) {}
    private CollectOfflinePolicy() {}
    static List<Interval> calculate(List<LocalDateTime> samples, LocalDateTime start,
                                    LocalDateTime end, Duration timeout) {
        if (start == null || end == null || !start.isBefore(end) || timeout == null || timeout.isNegative() || timeout.isZero()) {
            throw new IllegalArgumentException("时间范围或离线阈值无效");
        }
        List<LocalDateTime> ordered = samples.stream().filter(t -> t != null && !t.isAfter(end)).distinct().sorted().toList();
        List<Interval> result = new ArrayList<>();
        for (int i = 0; i < ordered.size(); i++) {
            LocalDateTime offline = ordered.get(i).plus(timeout);
            LocalDateTime recovery = i + 1 < ordered.size() ? ordered.get(i + 1) : end;
            LocalDateTime clippedStart = offline.isBefore(start) ? start : offline;
            LocalDateTime clippedEnd = recovery.isAfter(end) ? end : recovery;
            if (clippedStart.isBefore(clippedEnd)) result.add(new Interval(clippedStart, clippedEnd));
        }
        return result;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/collect/MesCollectQueryService.java
@@ -17,6 +17,11 @@
import cn.iocoder.yudao.module.mes.service.md.workstation.MesMdWorkstationService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.framework.collect.config.MesCollectProperties;
import java.time.Duration;
import java.util.ArrayList;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import java.time.LocalDateTime;
import java.util.List;
@@ -60,6 +65,38 @@
    private MesCollectTaskResolver taskResolver;
    @Resource
    private MesMdWorkstationService workstationService;
    @Resource
    private MesCollectProperties collectProperties;
    /** åŸºäºŽä¿ç•™çš„æ•°é‡‡åŽ†å²è®¡ç®—ç¦»çº¿åŒºé—´ï¼Œé¦–æ¬¡é‡‡æ ·ä¹‹å‰ä¸ä½œæŽ¨æ–­ã€‚ */
    public MesCollectOfflineResultRespVO getOfflineIntervals(String deviceCode, LocalDateTime start, LocalDateTime end) {
        LocalDateTime now = LocalDateTime.now();
        if (deviceCode == null || deviceCode.isBlank() || start == null || end == null || !start.isBefore(end)
                || end.isAfter(now) || Duration.between(start, end).compareTo(Duration.ofDays(7)) > 0) {
            throw invalidParamException("请选择有效的设备和时间范围,单次最多查询七天,结束时间不能晚于当前时间");
        }
        if (start.isBefore(now.minusDays(collectProperties.getRetention().getRealtimeDays()))) {
            throw invalidParamException("查询时间超出数采历史保留期,无法可靠计算停机记录");
        }
        var prior = realtimeHistoryMapper.selectOne(new LambdaQueryWrapperX<MesCollectRealtimeHistoryDO>()
                .eq(MesCollectRealtimeHistoryDO::getDeviceCode, deviceCode)
                .ge(MesCollectRealtimeHistoryDO::getCollectTime, now.minusDays(collectProperties.getRetention().getRealtimeDays()))
                .le(MesCollectRealtimeHistoryDO::getCollectTime, start)
                .orderByDesc(MesCollectRealtimeHistoryDO::getCollectTime).last("LIMIT 1"));
        var history = realtimeHistoryMapper.selectList(new LambdaQueryWrapperX<MesCollectRealtimeHistoryDO>()
                .eq(MesCollectRealtimeHistoryDO::getDeviceCode, deviceCode)
                .gt(MesCollectRealtimeHistoryDO::getCollectTime, start)
                .le(MesCollectRealtimeHistoryDO::getCollectTime, end)
                .orderByAsc(MesCollectRealtimeHistoryDO::getCollectTime).last("LIMIT 100001"));
        if (history.size() > 100000) throw invalidParamException("采样数据过多,请缩短查询时间范围");
        List<LocalDateTime> samples = new ArrayList<>();
        if (prior != null) samples.add(prior.getCollectTime());
        history.forEach(item -> samples.add(item.getCollectTime()));
        var intervals = CollectOfflinePolicy.calculate(samples, start, end, Duration.ofMinutes(ONLINE_TIMEOUT_MINUTES)).stream()
                .map(item -> new MesCollectOfflineIntervalRespVO(item.start(), item.end(), Duration.between(item.start(), item.end()).getSeconds()))
                .toList();
        return new MesCollectOfflineResultRespVO(samples.isEmpty() ? null : samples.getFirst(), intervals);
    }
    /**
     * èŽ·å–å·²é…ç½®æ•°é‡‡ç¼–ç çš„è®¾å¤‡å®žæ—¶çŠ¶æ€åˆ—è¡¨
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyDocumentPolicy.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package cn.iocoder.yudao.module.mes.service.safety;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
final class SafetyDocumentPolicy {
    private SafetyDocumentPolicy() {}
    static void checkApproval(String status, Long submitterId, Long approverId) {
        if (!"PENDING".equals(status)) throw invalidParamException("仅待审批的制度可以审批");
        if (submitterId == null || approverId == null || Objects.equals(submitterId, approverId)) {
            throw invalidParamException("审批人不能是制度提交人");
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyDocumentService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,139 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.document.SafetyDocumentApprovalReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.document.SafetyDocumentSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyDocumentDO;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.SafetyDocumentMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.SafetyDocumentApprovalMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyDocumentApprovalDO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.Objects;
import java.util.UUID;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Service
public class SafetyDocumentService {
    @Resource private SafetyDocumentMapper mapper;
    @Resource private SafetyDocumentApprovalMapper approvalMapper;
    @Transactional(rollbackFor = Exception.class)
    public Long create(SafetyDocumentSaveReqVO req) {
        var document = new SafetyDocumentDO();
        document.setDocumentKey(UUID.randomUUID().toString());
        document.setRevision(1);
        document.setStatus("DRAFT");
        document.setOwnerUserId(currentUser());
        apply(document, req);
        mapper.insert(document);
        return document.getId();
    }
    @Transactional(rollbackFor = Exception.class)
    public void update(SafetyDocumentSaveReqVO req) {
        if (req.getId() == null) throw invalidParamException("请选择制度档案");
        var document = requireLocked(req.getId());
        if (!"DRAFT".equals(document.getStatus()) && !"REJECTED".equals(document.getStatus())) {
            throw invalidParamException("只有草稿或驳回的制度可以修改");
        }
        if (!Objects.equals(document.getOwnerUserId(), currentUser())) throw invalidParamException("只有创建人可以修改制度草稿");
        apply(document, req);
        document.setStatus("DRAFT");
        mapper.updateById(document);
    }
    @Transactional(rollbackFor = Exception.class)
    public void submit(Long id) {
        var document = requireLocked(id);
        if (!"DRAFT".equals(document.getStatus())) throw invalidParamException("只有草稿可以提交审批");
        if (!Objects.equals(document.getOwnerUserId(), currentUser())) throw invalidParamException("只有创建人可以提交制度");
        document.setStatus("PENDING");
        document.setSubmitterUserId(currentUser());
        document.setSubmitTime(LocalDateTime.now());
        mapper.updateById(document);
    }
    @Transactional(rollbackFor = Exception.class)
    public void approve(SafetyDocumentApprovalReqVO req) {
        var document = requireLocked(req.getId());
        SafetyDocumentPolicy.checkApproval(document.getStatus(), document.getSubmitterUserId(), currentUser());
        if (!req.getApproved() && (req.getComment() == null || req.getComment().isBlank())) throw invalidParamException("驳回时请填写审批意见");
        if (req.getApproved()) {
            if (document.getEffectiveDate() != null && document.getEffectiveDate().isAfter(LocalDate.now())) {
                throw invalidParamException("暂不支持未来日期自动生效,请在生效日期当天审批或修改生效日期");
            }
            mapper.update(null, new LambdaUpdateWrapper<SafetyDocumentDO>()
                    .eq(SafetyDocumentDO::getDocumentKey, document.getDocumentKey())
                    .eq(SafetyDocumentDO::getStatus, "APPROVED")
                    .ne(SafetyDocumentDO::getId, document.getId())
                    .set(SafetyDocumentDO::getStatus, "SUPERSEDED"));
        }
        document.setStatus(req.getApproved() ? "APPROVED" : "REJECTED");
        document.setApproverUserId(currentUser());
        document.setApprovalTime(LocalDateTime.now());
        document.setApprovalComment(req.getComment());
        mapper.updateById(document);
        var log = new SafetyDocumentApprovalDO();
        log.setDocumentId(document.getId()); log.setApproverUserId(currentUser());
        log.setDecision(document.getStatus()); log.setComment(req.getComment()); log.setDecisionTime(document.getApprovalTime());
        log.setSubmitterUserId(document.getSubmitterUserId()); log.setTitleSnapshot(document.getTitle());
        log.setCategorySnapshot(document.getCategory()); log.setAttachmentUrlSnapshot(document.getAttachmentUrl());
        log.setDescriptionSnapshot(document.getDescription());
        log.setEffectiveDateSnapshot(document.getEffectiveDate());
        approvalMapper.insert(log);
    }
    @Transactional(rollbackFor = Exception.class)
    public Long createRevision(Long id) {
        var previous = requireLocked(id);
        if (!"APPROVED".equals(previous.getStatus())) throw invalidParamException("只有已批准的制度可以创建新版本");
        var latest = mapper.latest(previous.getDocumentKey());
        if (latest == null || !Objects.equals(latest.getId(), previous.getId())) throw invalidParamException("请基于最新版本创建修订,已有后续版本");
        var next = new SafetyDocumentDO();
        next.setDocumentKey(previous.getDocumentKey());
        next.setRevision(previous.getRevision() + 1);
        next.setTitle(previous.getTitle()); next.setCategory(previous.getCategory());
        next.setAttachmentUrl(previous.getAttachmentUrl()); next.setDescription(previous.getDescription());
        next.setEffectiveDate(previous.getEffectiveDate());
        next.setStatus("DRAFT"); next.setOwnerUserId(currentUser());
        mapper.insert(next);
        return next.getId();
    }
    public PageResult<SafetyDocumentDO> page(PageParam page, String title, String status) {
        return mapper.selectPage(page, new LambdaQueryWrapperX<SafetyDocumentDO>()
                .likeIfPresent(SafetyDocumentDO::getTitle, title).eqIfPresent(SafetyDocumentDO::getStatus, status)
                .orderByDesc(SafetyDocumentDO::getId));
    }
    public java.util.List<SafetyDocumentApprovalDO> approvals(Long id) {
        if (id == null || mapper.selectById(id) == null) throw invalidParamException("制度档案不存在");
        return approvalMapper.selectList(new LambdaQueryWrapperX<SafetyDocumentApprovalDO>()
                .eq(SafetyDocumentApprovalDO::getDocumentId, id).orderByDesc(SafetyDocumentApprovalDO::getId));
    }
    private SafetyDocumentDO requireLocked(Long id) {
        if (id == null) throw invalidParamException("请选择制度档案");
        var result = mapper.lockById(id);
        if (result == null) throw invalidParamException("制度档案不存在");
        return result;
    }
    private Long currentUser() {
        Long userId = getLoginUserId();
        if (userId == null) throw invalidParamException("登录状态已失效");
        return userId;
    }
    private void apply(SafetyDocumentDO doc, SafetyDocumentSaveReqVO req) {
        doc.setTitle(req.getTitle().trim()); doc.setCategory(req.getCategory().trim());
        doc.setAttachmentUrl(req.getAttachmentUrl().trim()); doc.setDescription(req.getDescription());
        doc.setEffectiveDate(req.getEffectiveDate());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyHazardService.java
@@ -19,6 +19,10 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.SafetyErrorCodeConstants.*;
@@ -41,6 +45,10 @@
    private SafetyHazardMapper hazardMapper;
    @Resource
    private SafetyRefResolver refResolver;
    @Resource
    private NotifyMessageSendApi notifyMessageSendApi;
    @Resource
    private AdminUserService adminUserService;
    /**
     * é𐿂£ä¸ŠæŠ¥ï¼ˆå«ç§»åŠ¨ç«¯æ‹ç…§ä¸Šä¼ çš„ photoUrls)
@@ -83,7 +91,8 @@
     */
    @Transactional(rollbackFor = Exception.class)
    public void assignHazard(SafetyHazardAssignReqVO reqVO) {
        SafetyHazardDO hazard = validateHazardExists(reqVO.getId());
        SafetyHazardDO hazard = hazardMapper.selectByIdForUpdate(reqVO.getId());
        if (hazard == null) throw exception(SAFETY_HAZARD_NOT_EXISTS);
        if (!SafetyHazardStatusEnum.REPORTED.getStatus().equals(hazard.getStatus())
                && !SafetyHazardStatusEnum.REJECTED.getStatus().equals(hazard.getStatus())) {
            throw exception(SAFETY_HAZARD_STATUS_ERROR);
@@ -92,6 +101,8 @@
            throw exception(SAFETY_HAZARD_ASSIGNEE_REQUIRED);
        }
        var assignee = refResolver.requireEmployee(reqVO.getAssigneeId(), SAFETY_HAZARD_ASSIGNEE_REQUIRED);
        if (assignee.getUserId() == null) throw exception(SAFETY_HAZARD_ASSIGNEE_NO_ACCOUNT);
        adminUserService.validateUserList(List.of(assignee.getUserId()));
        SafetyHazardDO update = new SafetyHazardDO();
        update.setId(hazard.getId());
        update.setAssigneeId(assignee.getId());
@@ -103,6 +114,14 @@
            update.setRemark(reqVO.getRemark().trim());
        }
        hazardMapper.updateById(update);
        NotifySendSingleToUserReqDTO notice = new NotifySendSingleToUserReqDTO();
        notice.setUserId(assignee.getUserId());
        notice.setTemplateCode("safety_hazard_assigned");
        notice.setTemplateParams(Map.of("code", hazard.getCode(), "title", hazard.getTitle(),
                "deadline", reqVO.getRectifyDeadline() == null ? "未设置" : reqVO.getRectifyDeadline().toString()));
        if (notifyMessageSendApi.sendSingleMessageToAdmin(notice) == null) {
            throw exception(SAFETY_HAZARD_NOTICE_UNAVAILABLE);
        }
    }
    /**
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/tender/vo/SrmSupplierQuoteSaveReqVO.java
@@ -2,6 +2,9 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.Min;
import lombok.Data;
import java.math.BigDecimal;
@@ -20,6 +23,7 @@
    private Long supplierId;
    @Schema(description = "投标ID", example = "1")
    @NotNull(message = "投标ID不能为空")
    private Long bidId;
    @Schema(description = "招标物料ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@@ -28,19 +32,22 @@
    @Schema(description = "报价价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "5000")
    @NotNull(message = "报价价格不能为空")
    @DecimalMin(value = "0", message = "报价不能为负数")
    private BigDecimal quotePrice;
    @Schema(description = "税率", example = "13")
    @DecimalMin(value = "0", message = "税率不能为负数")
    @DecimalMax(value = "100", message = "税率不能超过100%")
    private BigDecimal taxRate;
    @Schema(description = "交货周期(天)", example = "30")
    private Integer deliveryCycle;
    @Min(value = 0, message = "交货周期不能为负数") private Integer deliveryCycle;
    @Schema(description = "付款条件", example = "货到30天付款")
    private String paymentTerms;
    @Schema(description = "质保期(月)", example = "12")
    private Integer warrantyPeriod;
    @Min(value = 0, message = "质保期不能为负数") private Integer warrantyPeriod;
    @Schema(description = "备注", example = "含运费")
    private String remark;
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/dal/mysql/SrmTenderConfigMapper.java
@@ -3,7 +3,13 @@
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmTenderConfigDO;
import org.apache.ibatis.annotations.Mapper;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@Mapper
public interface SrmTenderConfigMapper extends BaseMapperX<SrmTenderConfigDO> {
    default SrmTenderConfigDO selectLatest() {
        return selectOne(new LambdaQueryWrapperX<SrmTenderConfigDO>()
                .orderByDesc(SrmTenderConfigDO::getUpdateTime)
                .orderByDesc(SrmTenderConfigDO::getId).last("LIMIT 1"));
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/enums/ErrorCodeConstants.java
@@ -75,5 +75,8 @@
    // ========== é€šç”¨ ==========
    ErrorCode QUOTE_MATERIAL_DUPLICATE = new ErrorCode(1_040_900_001, "该投标已存在相同物料的报价");
    ErrorCode QUOTE_MATERIAL_NOT_IN_PROJECT = new ErrorCode(1_040_900_002, "报价物料不属于当前招标项目");
    ErrorCode QUOTE_NOT_EXISTS = new ErrorCode(1_040_900_003, "报价记录不存在");
    ErrorCode QUOTE_BID_MISMATCH = new ErrorCode(1_040_900_004, "报价记录不属于当前投标");
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderBidServiceImpl.java
@@ -12,6 +12,7 @@
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierQuoteMapper;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmTenderBidMapper;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmTenderProjectMapper;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmTenderMaterialMapper;
import cn.iocoder.yudao.module.srm.enums.BidStatusEnum;
import cn.iocoder.yudao.module.srm.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.srm.enums.TenderStatusEnum;
@@ -26,6 +27,7 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import static cn.iocoder.yudao.module.srm.enums.LogRecordConstants.*;
@@ -47,6 +49,8 @@
    @Resource
    private SrmSupplierMapper supplierMapper;
    @Resource
    private SrmTenderMaterialMapper tenderMaterialMapper;
    // ========== æŠ•标管理 ==========
@@ -129,6 +133,7 @@
        if (project == null || !TenderStatusEnum.canCreateOrUpdateQuote(project.getTenderStatus())) {
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.TENDER_QUOTE_FORBIDDEN);
        }
        validateQuoteMaterial(createReqVO.getTenderMaterialId(), bid.getTenderProjectId());
        // æ ¡éªŒåŒä¸€æŠ•标下物料不可重复报价
        if (supplierQuoteMapper.existsByBidAndMaterial(createReqVO.getBidId(), createReqVO.getTenderMaterialId())) {
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.QUOTE_MATERIAL_DUPLICATE);
@@ -152,16 +157,28 @@
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.TENDER_QUOTE_FORBIDDEN);
        }
        // æ ¡éªŒåŒä¸€æŠ•标下物料不可重复报价(排除自身)
        SrmSupplierQuoteDO existing = supplierQuoteMapper.selectById(updateReqVO.getId());
        if (existing != null && !existing.getTenderMaterialId().equals(updateReqVO.getTenderMaterialId())) {
        SrmSupplierQuoteDO existing = updateReqVO.getId() == null ? null : supplierQuoteMapper.selectById(updateReqVO.getId());
        if (existing == null) throw ServiceExceptionUtil.exception(ErrorCodeConstants.QUOTE_NOT_EXISTS);
        if (!Objects.equals(existing.getBidId(), bid.getId())) throw ServiceExceptionUtil.exception(ErrorCodeConstants.QUOTE_BID_MISMATCH);
        validateQuoteMaterial(updateReqVO.getTenderMaterialId(), bid.getTenderProjectId());
        if (!Objects.equals(existing.getTenderMaterialId(), updateReqVO.getTenderMaterialId())) {
            if (supplierQuoteMapper.existsByBidAndMaterial(updateReqVO.getBidId(), updateReqVO.getTenderMaterialId())) {
                throw ServiceExceptionUtil.exception(ErrorCodeConstants.QUOTE_MATERIAL_DUPLICATE);
            }
        }
        SrmSupplierQuoteDO updateObj = BeanUtils.toBean(updateReqVO, SrmSupplierQuoteDO.class);
        updateObj.setTenderProjectId(bid.getTenderProjectId());
        updateObj.setSupplierId(bid.getSupplierId());
        supplierQuoteMapper.updateById(updateObj);
    }
    private void validateQuoteMaterial(Long materialId, Long projectId) {
        var material = materialId == null ? null : tenderMaterialMapper.selectById(materialId);
        if (material == null || !Objects.equals(material.getTenderProjectId(), projectId)) {
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.QUOTE_MATERIAL_NOT_IN_PROJECT);
        }
    }
    @Override
    public List<SrmSupplierQuoteDO> getQuoteList(Long bidId) {
        return supplierQuoteMapper.selectListByBidId(bidId);
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/tender/SrmTenderConfigServiceImpl.java
@@ -16,7 +16,7 @@
    @Override
    public SrmTenderConfigDO getConfig() {
        SrmTenderConfigDO config = srmTenderConfigMapper.selectOne(null);
        SrmTenderConfigDO config = srmTenderConfigMapper.selectLatest();
        if (config == null) {
            config = new SrmTenderConfigDO();
            config.setDefaultBiddingMode(BiddingModeEnum.FULL.getCode());
@@ -27,7 +27,7 @@
    @Override
    public void saveConfig(String defaultBiddingMode) {
        SrmTenderConfigDO config = srmTenderConfigMapper.selectOne(null);
        SrmTenderConfigDO config = srmTenderConfigMapper.selectLatest();
        if (config != null) {
            config.setDefaultBiddingMode(defaultBiddingMode);
            srmTenderConfigMapper.updateById(config);