Sfoglia il codice sorgente

feat(risk-factoryVisit): 品控访厂(3.1.2)后端 + DB完整实现

- 新增 2 张表 risk_factory_visit(主表)/risk_factory_visit_report(报告子表)
- 7 模块评分(基础信息/ESG/GMP/ISO14001/ISO45001/ISO9001/致敏原)用 JSON 字段存储
- 状态流转:待访厂 → 待审核 → 已审核,总分(0-100)=ESG(30)+GMP(20)+ISO14001(15)+ISO45001(15)+ISO9001(20)
- 8 个接口:list/getInfo/add/edit/remove/export/submitReport/audit
- 7 类按钮权限标识 risk:factoryVisit:list/add/edit/remove/export/report/audit
- 独立 SQL 脚本:risk_quality_factory_visit.sql(表+字典)+ risk_quality_factory_visit_menu.sql(菜单)
yangjingjing 2 giorni fa
parent
commit
b56e8b37db

+ 151 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/controller/RiskFactoryVisitController.java

@@ -0,0 +1,151 @@
+package org.dromara.risk.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.excel.utils.ExcelUtil;
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.risk.domain.bo.RiskFactoryVisitBo;
+import org.dromara.risk.domain.bo.RiskFactoryVisitReportBo;
+import org.dromara.risk.domain.vo.RiskFactoryVisitVo;
+import org.dromara.risk.service.IRiskFactoryVisitService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 访厂任务
+ *
+ * @author ruoyi
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/risk/factoryVisit")
+public class RiskFactoryVisitController extends BaseController {
+
+    private final IRiskFactoryVisitService factoryVisitService;
+
+    /**
+     * 查询访厂任务列表
+     */
+    @SaCheckPermission("risk:factoryVisit:list")
+    @GetMapping("/list")
+    public TableDataInfo<RiskFactoryVisitVo> list(RiskFactoryVisitBo bo, PageQuery pageQuery) {
+        return factoryVisitService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 获取访厂任务详细信息(含报告子表 7 个模块)
+     */
+    @SaCheckPermission("risk:factoryVisit:list")
+    @GetMapping("/{id}")
+    public R<RiskFactoryVisitVo> getInfo(@NotNull(message = "访厂任务ID不能为空") @PathVariable("id") Long id) {
+        return R.ok(factoryVisitService.queryById(id));
+    }
+
+    /**
+     * 新增访厂任务
+     */
+    @SaCheckPermission("risk:factoryVisit:add")
+    @Log(title = "访厂任务", businessType = BusinessType.INSERT)
+    @RepeatSubmit()
+    @PostMapping
+    public R<Void> add(@Validated(AddGroup.class) @RequestBody RiskFactoryVisitBo bo) {
+        return toAjax(factoryVisitService.insertByBo(bo));
+    }
+
+    /**
+     * 修改访厂任务(基础信息)
+     * <p>
+     * 仅允许修改"待访厂"状态的任务;已提交报告或已审核的任务不可改基础信息。
+     */
+    @SaCheckPermission("risk:factoryVisit:edit")
+    @Log(title = "访厂任务", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping
+    public R<Void> edit(@Validated(EditGroup.class) @RequestBody RiskFactoryVisitBo bo) {
+        return toAjax(factoryVisitService.updateByBo(bo));
+    }
+
+    /**
+     * 删除访厂任务
+     */
+    @SaCheckPermission("risk:factoryVisit:remove")
+    @Log(title = "访厂任务", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public R<Void> remove(@NotEmpty(message = "访厂任务ID不能为空") @PathVariable Long[] ids) {
+        return toAjax(factoryVisitService.deleteWithValidByIds(Arrays.asList(ids)));
+    }
+
+    /**
+     * 导出访厂任务列表
+     */
+    @SaCheckPermission("risk:factoryVisit:export")
+    @Log(title = "访厂任务", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(RiskFactoryVisitBo bo, HttpServletResponse response) {
+        List<RiskFactoryVisitVo> list = factoryVisitService.queryList(bo);
+        ExcelUtil.exportExcel(list, "访厂任务数据", RiskFactoryVisitVo.class, response);
+    }
+
+    /**
+     * 填写访厂报告(提交 7 个模块评分)
+     * <p>
+     * 待访厂 → 待审核:状态自动流转。
+     * 仅"待访厂"状态可调用此接口。
+     *
+     * @param bo 报告数据(含 7 个模块 JSON)
+     * @return reportId
+     */
+    @SaCheckPermission("risk:factoryVisit:report")
+    @Log(title = "访厂报告", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PostMapping("/submitReport")
+    public R<Map<String, Long>> submitReport(@Validated @RequestBody RiskFactoryVisitReportBo bo) {
+        Long reportId = factoryVisitService.submitReport(bo);
+        Map<String, Long> data = new HashMap<>(2);
+        data.put("reportId", reportId);
+        return R.ok(data);
+    }
+
+    /**
+     * 审核访厂任务
+     * <p>
+     * 待审核 → 已审核:状态自动流转。
+     * 必传参数:id/auditResult/auditRemark。
+     *
+     * @param bo 审核参数 (id/auditResult/auditRemark/auditorId/auditorName)
+     */
+    @SaCheckPermission("risk:factoryVisit:audit")
+    @Log(title = "访厂审核", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping("/audit")
+    public R<Void> audit(@Validated @RequestBody RiskFactoryVisitBo bo) {
+        if (bo.getId() == null) {
+            return R.fail("访厂任务ID不能为空");
+        }
+        boolean ok = factoryVisitService.auditVisit(
+            bo.getId(),
+            bo.getAuditResult(),
+            bo.getAuditRemark(),
+            bo.getAuditorId(),
+            bo.getAuditorName()
+        );
+        return ok ? R.ok() : R.fail("审核失败,记录可能不存在或状态不允许");
+    }
+}

+ 130 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/RiskFactoryVisit.java

@@ -0,0 +1,130 @@
+package org.dromara.risk.domain;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 访厂主表对象 risk_factory_visit
+ *
+ * @author ruoyi
+ */
+@Data
+@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+@TableName("risk_factory_visit")
+public class RiskFactoryVisit extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    /**
+     * 访厂ID(FC+年月日+4位序号)
+     */
+    private String visitCode;
+
+    /**
+     * 被访厂商名称
+     */
+    private String companyName;
+
+    /**
+     * 厂商地址
+     */
+    private String companyAddress;
+
+    /**
+     * 访厂日期
+     */
+    private Date visitDate;
+
+    /**
+     * 访查人ID
+     */
+    private Long visitorId;
+
+    /**
+     * 访查人姓名
+     */
+    private String visitorName;
+
+    /**
+     * 审核部门
+     */
+    private String auditDept;
+
+    /**
+     * 审核产品范围
+     */
+    private String productScope;
+
+    /**
+     * 评审人ID
+     */
+    private Long reviewerId;
+
+    /**
+     * 评审人姓名
+     */
+    private String reviewerName;
+
+    /**
+     * 访查实际得分(各模块加权求和)
+     */
+    private BigDecimal totalScore;
+
+    /**
+     * 审核结论(通过/不通过/待定)
+     */
+    private String auditResult;
+
+    /**
+     * 审核意见
+     */
+    private String auditRemark;
+
+    /**
+     * 审核人ID
+     */
+    private Long auditorId;
+
+    /**
+     * 审核人姓名
+     */
+    private String auditorName;
+
+    /**
+     * 审核时间
+     */
+    private Date auditTime;
+
+    /**
+     * 状态(待访厂/待审核/已审核)
+     */
+    private String status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 删除标志
+     */
+    @TableLogic
+    private String delFlag;
+
+}

+ 98 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/RiskFactoryVisitReport.java

@@ -0,0 +1,98 @@
+package org.dromara.risk.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 访厂报告子表对象 risk_factory_visit_report
+ * <p>
+ * 7 个审查模块以 JSON 字段存储,简化表结构。模块结构:
+ * <ul>
+ *     <li>basic_evaluate - 模块1:基础信息核查(9项是/否)</li>
+ *     <li>esg_score - 模块2:ESG 评分(10项数字,总分30)</li>
+ *     <li>gmp_score - 模块3:GMP 评分(5项数字,总分20)</li>
+ *     <li>iso14001_score - 模块4:ISO14001 评分(4项数字,总分15)</li>
+ *     <li>iso45001_score - 模块5:ISO45001 评分(4项数字,总分15)</li>
+ *     <li>iso9001_score - 模块6:ISO9001 评分(5项数字,总分20)</li>
+ *     <li>allergen_evaluate - 模块7:致敏原管控(19项合格/不合格/不适用+备注)</li>
+ * </ul>
+ *
+ * @author ruoyi
+ */
+@Data
+@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+@TableName(value = "risk_factory_visit_report", autoResultMap = true)
+public class RiskFactoryVisitReport extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 访厂主表ID
+     */
+    private Long visitId;
+
+    /**
+     * 基础信息核查(9项是/否)
+     */
+    @TableField(value = "basic_evaluate", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
+    private Object basicEvaluate;
+
+    /**
+     * ESG评分(10项数字)
+     */
+    @TableField(value = "esg_score", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
+    private Object esgScore;
+
+    /**
+     * GMP评分(5项数字)
+     */
+    @TableField(value = "gmp_score", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
+    private Object gmpScore;
+
+    /**
+     * ISO14001评分(4项数字)
+     */
+    @TableField(value = "iso14001_score", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
+    private Object iso14001Score;
+
+    /**
+     * ISO45001评分(4项数字)
+     */
+    @TableField(value = "iso45001_score", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
+    private Object iso45001Score;
+
+    /**
+     * ISO9001评分(5项数字)
+     */
+    @TableField(value = "iso9001_score", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
+    private Object iso9001Score;
+
+    /**
+     * 致敏原管控(19项合格/不合格/不适用+备注)
+     */
+    @TableField(value = "allergen_evaluate", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
+    private Object allergenEvaluate;
+
+    /**
+     * 7.6 问题汇总与整改要求
+     */
+    private String summary;
+
+}

+ 116 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/bo/RiskFactoryVisitBo.java

@@ -0,0 +1,116 @@
+package org.dromara.risk.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import org.dromara.risk.domain.RiskFactoryVisit;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 访厂任务 业务对象 risk_factory_visit
+ *
+ * @author ruoyi
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = RiskFactoryVisit.class, reverseConvertGenerate = false)
+public class RiskFactoryVisitBo extends BaseEntity {
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    /**
+     * 访厂ID(FC+年月日+4位序号)
+     */
+    private String visitCode;
+
+    /**
+     * 被访厂商名称
+     */
+    private String companyName;
+
+    /**
+     * 厂商地址
+     */
+    private String companyAddress;
+
+    /**
+     * 访厂日期
+     */
+    private Date visitDate;
+
+    /**
+     * 访查人ID
+     */
+    private Long visitorId;
+
+    /**
+     * 访查人姓名
+     */
+    private String visitorName;
+
+    /**
+     * 审核部门
+     */
+    private String auditDept;
+
+    /**
+     * 审核产品范围
+     */
+    private String productScope;
+
+    /**
+     * 评审人ID
+     */
+    private Long reviewerId;
+
+    /**
+     * 评审人姓名
+     */
+    private String reviewerName;
+
+    /**
+     * 访查实际得分(各模块加权求和)
+     */
+    private BigDecimal totalScore;
+
+    /**
+     * 状态(待访厂/待审核/已审核)
+     */
+    private String status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 关键词(用于列表搜索:公司名/访厂ID/访查人)
+     */
+    private String keyword;
+
+    /**
+     * 审核结论(通过/不通过/待定) - 仅审核接口使用
+     */
+    private String auditResult;
+
+    /**
+     * 审核意见
+     */
+    private String auditRemark;
+
+    /**
+     * 审核人ID
+     */
+    private Long auditorId;
+
+    /**
+     * 审核人姓名
+     */
+    private String auditorName;
+}

+ 85 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/bo/RiskFactoryVisitReportBo.java

@@ -0,0 +1,85 @@
+package org.dromara.risk.domain.bo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import org.dromara.risk.domain.RiskFactoryVisitReport;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 访厂报告 业务对象 risk_factory_visit_report
+ * <p>
+ * 用于"填写访厂报告"接口(7 个审查模块一次性提交)。
+ *
+ * @author ruoyi
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = RiskFactoryVisitReport.class, reverseConvertGenerate = false)
+public class RiskFactoryVisitReportBo extends BaseEntity {
+
+    /**
+     * 主键ID(更新时必填)
+     */
+    private Long id;
+
+    /**
+     * 访厂主表ID
+     */
+    private Long visitId;
+
+    /**
+     * 基础信息核查(9项是/否) JSON
+     */
+    private Object basicEvaluate;
+
+    /**
+     * ESG评分(10项数字) JSON
+     */
+    private Object esgScore;
+
+    /**
+     * GMP评分(5项数字) JSON
+     */
+    private Object gmpScore;
+
+    /**
+     * ISO14001评分(4项数字) JSON
+     */
+    private Object iso14001Score;
+
+    /**
+     * ISO45001评分(4项数字) JSON
+     */
+    private Object iso45001Score;
+
+    /**
+     * ISO9001评分(5项数字) JSON
+     */
+    private Object iso9001Score;
+
+    /**
+     * 致敏原管控(19项合格/不合格/不适用+备注) JSON
+     */
+    private Object allergenEvaluate;
+
+    /**
+     * 7.6 问题汇总与整改要求
+     */
+    private String summary;
+
+    /**
+     * 访查实际得分(由后端根据各模块评分自动计算,可由前端传入以节省一次请求)
+     */
+    private BigDecimal totalScore;
+
+    /**
+     * 访厂日期(写入主表)
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date visitDate;
+}

+ 74 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/RiskFactoryVisitReportVo.java

@@ -0,0 +1,74 @@
+package org.dromara.risk.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.risk.domain.RiskFactoryVisitReport;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 访厂报告视图对象 risk_factory_visit_report
+ * <p>
+ * 7 个审查模块以 JSON 字段返回,由前端按模块结构渲染报告弹框。
+ *
+ * @author ruoyi
+ */
+@Data
+@AutoMapper(target = RiskFactoryVisitReport.class)
+public class RiskFactoryVisitReportVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    private Long id;
+
+    /**
+     * 访厂主表ID
+     */
+    private Long visitId;
+
+    /**
+     * 基础信息核查(9项是/否)
+     */
+    private Object basicEvaluate;
+
+    /**
+     * ESG评分(10项数字)
+     */
+    private Object esgScore;
+
+    /**
+     * GMP评分(5项数字)
+     */
+    private Object gmpScore;
+
+    /**
+     * ISO14001评分(4项数字)
+     */
+    private Object iso14001Score;
+
+    /**
+     * ISO45001评分(4项数字)
+     */
+    private Object iso45001Score;
+
+    /**
+     * ISO9001评分(5项数字)
+     */
+    private Object iso9001Score;
+
+    /**
+     * 致敏原管控(19项合格/不合格/不适用+备注)
+     */
+    private Object allergenEvaluate;
+
+    /**
+     * 7.6 问题汇总与整改要求
+     */
+    private String summary;
+
+}

+ 164 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/vo/RiskFactoryVisitVo.java

@@ -0,0 +1,164 @@
+package org.dromara.risk.domain.vo;
+
+import cn.idev.excel.annotation.ExcelProperty;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.risk.domain.RiskFactoryVisit;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 访厂任务视图对象 risk_factory_visit
+ *
+ * @author ruoyi
+ */
+@Data
+@AutoMapper(target = RiskFactoryVisit.class)
+public class RiskFactoryVisitVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @ExcelProperty(value = "主键ID")
+    private Long id;
+
+    /**
+     * 访厂ID(FC+年月日+4位序号)
+     */
+    @ExcelProperty(value = "访厂ID")
+    private String visitCode;
+
+    /**
+     * 被访厂商名称
+     */
+    @ExcelProperty(value = "公司名称")
+    private String companyName;
+
+    /**
+     * 厂商地址
+     */
+    @ExcelProperty(value = "地址")
+    private String companyAddress;
+
+    /**
+     * 访厂日期
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @ExcelProperty(value = "访厂日期")
+    private Date visitDate;
+
+    /**
+     * 访查人ID
+     */
+    private Long visitorId;
+
+    /**
+     * 访查人姓名
+     */
+    @ExcelProperty(value = "访查人")
+    private String visitorName;
+
+    /**
+     * 审核部门
+     */
+    @ExcelProperty(value = "审核部门")
+    private String auditDept;
+
+    /**
+     * 审核产品范围
+     */
+    @ExcelProperty(value = "审核产品范围")
+    private String productScope;
+
+    /**
+     * 评审人ID
+     */
+    private Long reviewerId;
+
+    /**
+     * 评审人姓名
+     */
+    @ExcelProperty(value = "评审人")
+    private String reviewerName;
+
+    /**
+     * 访查实际得分(各模块加权求和)
+     */
+    @ExcelProperty(value = "访查实际得分")
+    private BigDecimal totalScore;
+
+    /**
+     * 审核结论(通过/不通过/待定)
+     */
+    @ExcelProperty(value = "审核结论")
+    private String auditResult;
+
+    /**
+     * 审核意见
+     */
+    @ExcelProperty(value = "审核意见")
+    private String auditRemark;
+
+    /**
+     * 审核人ID
+     */
+    private Long auditorId;
+
+    /**
+     * 审核人姓名
+     */
+    @ExcelProperty(value = "审核人")
+    private String auditorName;
+
+    /**
+     * 审核时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "审核时间")
+    private Date auditTime;
+
+    /**
+     * 状态(待访厂/待审核/已审核)
+     */
+    @ExcelProperty(value = "状态")
+    private String status;
+
+    /**
+     * 备注
+     */
+    @ExcelProperty(value = "备注")
+    private String remark;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "创建时间")
+    private Date createTime;
+
+    /**
+     * 更新者
+     */
+    @ExcelProperty(value = "更新者")
+    private Long updateBy;
+
+    /**
+     * 更新时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @ExcelProperty(value = "更新时间")
+    private Date updateTime;
+
+    /**
+     * 报告子表(可选,详情接口填充)
+     */
+    private RiskFactoryVisitReportVo report;
+
+}

+ 75 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/mapper/RiskFactoryVisitMapper.java

@@ -0,0 +1,75 @@
+package org.dromara.risk.mapper;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Constants;
+import org.apache.ibatis.annotations.Param;
+import org.dromara.common.mybatis.annotation.DataColumn;
+import org.dromara.common.mybatis.annotation.DataPermission;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.risk.domain.RiskFactoryVisit;
+import org.dromara.risk.domain.vo.RiskFactoryVisitVo;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 访厂任务 数据层
+ *
+ * @author ruoyi
+ */
+public interface RiskFactoryVisitMapper extends BaseMapperPlus<RiskFactoryVisit, RiskFactoryVisitVo> {
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default <P extends IPage<RiskFactoryVisitVo>> P selectVoPage(IPage<RiskFactoryVisit> page, Wrapper<RiskFactoryVisit> wrapper) {
+        return selectVoPage(page, wrapper, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default List<RiskFactoryVisitVo> selectVoList(Wrapper<RiskFactoryVisit> wrapper) {
+        return selectVoList(wrapper, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default RiskFactoryVisitVo selectVoById(Serializable id) {
+        return selectVoById(id, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default List<RiskFactoryVisitVo> selectVoByIds(Collection<? extends Serializable> idList) {
+        return selectVoByIds(idList, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    default RiskFactoryVisitVo selectVoOne(Wrapper<RiskFactoryVisit> wrapper) {
+        return selectVoOne(wrapper, this.currentVoClass());
+    }
+
+    @Override
+    @DataPermission({
+        @DataColumn(key = "deptName", value = "create_dept"),
+        @DataColumn(key = "userName", value = "create_by")
+    })
+    int updateById(@Param(Constants.ENTITY) RiskFactoryVisit entity);
+}

+ 52 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/mapper/RiskFactoryVisitReportMapper.java

@@ -0,0 +1,52 @@
+package org.dromara.risk.mapper;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Constants;
+import org.apache.ibatis.annotations.Param;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.risk.domain.RiskFactoryVisitReport;
+import org.dromara.risk.domain.vo.RiskFactoryVisitReportVo;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 访厂报告子表 数据层
+ * <p>
+ * 报告子表一般与主表联动查询(单条报告对应一个访厂任务),
+ * 不需要数据权限。父表(主表)的权限会通过 visitId 关联自然继承。
+ *
+ * @author ruoyi
+ */
+public interface RiskFactoryVisitReportMapper extends BaseMapperPlus<RiskFactoryVisitReport, RiskFactoryVisitReportVo> {
+
+    @Override
+    default <P extends IPage<RiskFactoryVisitReportVo>> P selectVoPage(IPage<RiskFactoryVisitReport> page, Wrapper<RiskFactoryVisitReport> wrapper) {
+        return selectVoPage(page, wrapper, this.currentVoClass());
+    }
+
+    @Override
+    default List<RiskFactoryVisitReportVo> selectVoList(Wrapper<RiskFactoryVisitReport> wrapper) {
+        return selectVoList(wrapper, this.currentVoClass());
+    }
+
+    @Override
+    default RiskFactoryVisitReportVo selectVoById(Serializable id) {
+        return selectVoById(id, this.currentVoClass());
+    }
+
+    @Override
+    default List<RiskFactoryVisitReportVo> selectVoByIds(Collection<? extends Serializable> idList) {
+        return selectVoByIds(idList, this.currentVoClass());
+    }
+
+    @Override
+    default RiskFactoryVisitReportVo selectVoOne(Wrapper<RiskFactoryVisitReport> wrapper) {
+        return selectVoOne(wrapper, this.currentVoClass());
+    }
+
+    @Override
+    int updateById(@Param(Constants.ENTITY) RiskFactoryVisitReport entity);
+}

+ 78 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/IRiskFactoryVisitService.java

@@ -0,0 +1,78 @@
+package org.dromara.risk.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.risk.domain.bo.RiskFactoryVisitBo;
+import org.dromara.risk.domain.bo.RiskFactoryVisitReportBo;
+import org.dromara.risk.domain.vo.RiskFactoryVisitVo;
+
+import java.util.List;
+
+/**
+ * 访厂任务 业务层
+ *
+ * @author ruoyi
+ */
+public interface IRiskFactoryVisitService {
+
+    /**
+     * 查询访厂任务
+     *
+     * @param id 任务主键
+     * @return 访厂任务(含报告子表)
+     */
+    RiskFactoryVisitVo queryById(Long id);
+
+    /**
+     * 查询访厂任务列表(分页)
+     *
+     * @param bo        查询条件
+     * @param pageQuery 分页参数
+     */
+    TableDataInfo<RiskFactoryVisitVo> queryPageList(RiskFactoryVisitBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询访厂任务列表
+     */
+    List<RiskFactoryVisitVo> queryList(RiskFactoryVisitBo bo);
+
+    /**
+     * 新增访厂任务
+     */
+    int insertByBo(RiskFactoryVisitBo bo);
+
+    /**
+     * 修改访厂任务(基础信息)
+     */
+    int updateByBo(RiskFactoryVisitBo bo);
+
+    /**
+     * 校验并批量删除访厂任务
+     */
+    int deleteWithValidByIds(List<Long> ids);
+
+    /**
+     * 提交访厂报告(填写完整 7 个模块)
+     * <p>
+     * 写入/更新 risk_factory_visit_report 子表,同时回写主表的 total_score,
+     * 状态由"待访厂"流转为"待审核"。
+     *
+     * @param bo 报告数据(含 7 个模块 JSON)
+     * @return 报告ID
+     */
+    Long submitReport(RiskFactoryVisitReportBo bo);
+
+    /**
+     * 审核访厂任务
+     * <p>
+     * 状态由"待审核"流转为"已审核",同步写入审核结论(通过/不通过/待定)+审核意见+审核人/审核时间。
+     *
+     * @param id           访厂任务ID
+     * @param auditResult  审核结论(通过/不通过/待定)
+     * @param auditRemark  审核意见
+     * @param auditorId    审核人ID(可空,自动取当前登录用户)
+     * @param auditorName  审核人姓名
+     * @return 是否成功
+     */
+    boolean auditVisit(Long id, String auditResult, String auditRemark, Long auditorId, String auditorName);
+}

+ 337 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/impl/RiskFactoryVisitServiceImpl.java

@@ -0,0 +1,337 @@
+package org.dromara.risk.service.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.risk.domain.RiskFactoryVisit;
+import org.dromara.risk.domain.RiskFactoryVisitReport;
+import org.dromara.risk.domain.bo.RiskFactoryVisitBo;
+import org.dromara.risk.domain.bo.RiskFactoryVisitReportBo;
+import org.dromara.risk.domain.vo.RiskFactoryVisitReportVo;
+import org.dromara.risk.domain.vo.RiskFactoryVisitVo;
+import org.dromara.risk.mapper.RiskFactoryVisitMapper;
+import org.dromara.risk.mapper.RiskFactoryVisitReportMapper;
+import org.dromara.risk.service.IRiskFactoryVisitService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.Assert;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 访厂任务 业务层实现
+ *
+ * @author ruoyi
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class RiskFactoryVisitServiceImpl implements IRiskFactoryVisitService {
+
+    /** 状态:待访厂 */
+    private static final String STATUS_PENDING = "待访厂";
+    /** 状态:待审核 */
+    private static final String STATUS_AUDITING = "待审核";
+    /** 状态:已审核 */
+    private static final String STATUS_AUDITED = "已审核";
+
+    /** 审核结论:通过 */
+    private static final String AUDIT_PASS = "通过";
+    /** 审核结论:不通过 */
+    private static final String AUDIT_REJECT = "不通过";
+    /** 审核结论:待定 */
+    private static final String AUDIT_HOLD = "待定";
+
+    private static final List<String> AUDIT_RESULTS = Arrays.asList(AUDIT_PASS, AUDIT_REJECT, AUDIT_HOLD);
+
+    private final RiskFactoryVisitMapper baseMapper;
+    private final RiskFactoryVisitReportMapper reportMapper;
+
+    // =============== CRUD ===============
+
+    @Override
+    public RiskFactoryVisitVo queryById(Long id) {
+        RiskFactoryVisitVo vo = baseMapper.selectVoById(id);
+        if (vo == null) {
+            return null;
+        }
+        // 联表查询报告子表
+        RiskFactoryVisitReportVo report = reportMapper.selectVoOne(
+            Wrappers.<RiskFactoryVisitReport>lambdaQuery()
+                .eq(RiskFactoryVisitReport::getVisitId, id)
+        );
+        vo.setReport(report);
+        return vo;
+    }
+
+    @Override
+    public TableDataInfo<RiskFactoryVisitVo> queryPageList(RiskFactoryVisitBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<RiskFactoryVisit> lqw = buildQueryWrapper(bo);
+        Page<RiskFactoryVisitVo> page = baseMapper.selectVoPage(pageQuery.build(), lqw);
+        return TableDataInfo.build(page);
+    }
+
+    @Override
+    public List<RiskFactoryVisitVo> queryList(RiskFactoryVisitBo bo) {
+        LambdaQueryWrapper<RiskFactoryVisit> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    private LambdaQueryWrapper<RiskFactoryVisit> buildQueryWrapper(RiskFactoryVisitBo bo) {
+        Map<String, Object> params = bo.getParams();
+        LambdaQueryWrapper<RiskFactoryVisit> lqw = Wrappers.lambdaQuery();
+        lqw.eq(StringUtils.isNotBlank(bo.getVisitCode()), RiskFactoryVisit::getVisitCode, bo.getVisitCode());
+        lqw.eq(StringUtils.isNotBlank(bo.getStatus()), RiskFactoryVisit::getStatus, bo.getStatus());
+        lqw.eq(StringUtils.isNotBlank(bo.getAuditResult()), RiskFactoryVisit::getAuditResult, bo.getAuditResult());
+        lqw.like(StringUtils.isNotBlank(bo.getCompanyName()), RiskFactoryVisit::getCompanyName, bo.getCompanyName());
+        lqw.like(StringUtils.isNotBlank(bo.getVisitorName()), RiskFactoryVisit::getVisitorName, bo.getVisitorName());
+        lqw.like(StringUtils.isNotBlank(bo.getProductScope()), RiskFactoryVisit::getProductScope, bo.getProductScope());
+        lqw.like(StringUtils.isNotBlank(bo.getAuditDept()), RiskFactoryVisit::getAuditDept, bo.getAuditDept());
+        lqw.like(StringUtils.isNotBlank(bo.getReviewerName()), RiskFactoryVisit::getReviewerName, bo.getReviewerName());
+        // 关键词搜索:公司名/访厂ID/访查人
+        if (StringUtils.isNotBlank(bo.getKeyword())) {
+            lqw.and(w -> w.like(RiskFactoryVisit::getCompanyName, bo.getKeyword())
+                .or().like(RiskFactoryVisit::getVisitCode, bo.getKeyword())
+                .or().like(RiskFactoryVisit::getVisitorName, bo.getKeyword()));
+        }
+        // 访厂日期范围
+        lqw.between(params.get("beginVisitDate") != null && params.get("endVisitDate") != null,
+            RiskFactoryVisit::getVisitDate, params.get("beginVisitDate"), params.get("endVisitDate"));
+        // 默认按创建时间倒序
+        lqw.orderByDesc(RiskFactoryVisit::getCreateTime);
+        return lqw;
+    }
+
+    @Override
+    public int insertByBo(RiskFactoryVisitBo bo) {
+        RiskFactoryVisit add = MapstructUtils.convert(bo, RiskFactoryVisit.class);
+        // 生成访厂ID:FC+年月日+4位序号
+        if (StrUtil.isBlank(add.getVisitCode())) {
+            add.setVisitCode(generateVisitCode());
+        }
+        // 默认状态:待访厂
+        if (StrUtil.isBlank(add.getStatus())) {
+            add.setStatus(STATUS_PENDING);
+        }
+        // 兜底:访查人为空时取当前登录用户
+        if (add.getVisitorId() == null) {
+            try {
+                add.setVisitorId(LoginHelper.getUserId());
+                add.setVisitorName(LoginHelper.getUsername());
+            } catch (Exception ignore) {
+                // 允许匿名创建
+            }
+        }
+        int row = baseMapper.insert(add);
+        bo.setId(add.getId());
+        return row;
+    }
+
+    @Override
+    public int updateByBo(RiskFactoryVisitBo bo) {
+        RiskFactoryVisit update = MapstructUtils.convert(bo, RiskFactoryVisit.class);
+        return baseMapper.updateById(update);
+    }
+
+    @Override
+    public int deleteWithValidByIds(List<Long> ids) {
+        if (ObjectUtil.isEmpty(ids)) {
+            return 0;
+        }
+        return baseMapper.deleteBatchIds(ids);
+    }
+
+    // =============== 报告提交与状态流转 ===============
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long submitReport(RiskFactoryVisitReportBo bo) {
+        // 1. 校验主表
+        Assert.notNull(bo.getVisitId(), "访厂任务ID不能为空");
+        RiskFactoryVisit visit = baseMapper.selectById(bo.getVisitId());
+        Assert.notNull(visit, "访厂任务不存在: " + bo.getVisitId());
+        // 仅"待访厂"状态可填写报告
+        if (!STATUS_PENDING.equals(visit.getStatus())) {
+            throw new ServiceException("当前状态(" + visit.getStatus() + ")不允许填写报告");
+        }
+
+        // 2. 计算总分:7 个模块中,模块 2-6 是数字评分项(总分 30+20+15+15+20=100)
+        //    优先使用前端传入的 total_score,否则按各模块分数求和
+        BigDecimal totalScore = bo.getTotalScore();
+        if (totalScore == null) {
+            totalScore = computeTotalScore(bo);
+        }
+
+        // 3. 写入/更新报告子表
+        RiskFactoryVisitReport exist = reportMapper.selectOne(
+            Wrappers.<RiskFactoryVisitReport>lambdaQuery()
+                .eq(RiskFactoryVisitReport::getVisitId, bo.getVisitId())
+        );
+        RiskFactoryVisitReport entity = new RiskFactoryVisitReport();
+        entity.setVisitId(bo.getVisitId());
+        entity.setBasicEvaluate(bo.getBasicEvaluate());
+        entity.setEsgScore(bo.getEsgScore());
+        entity.setGmpScore(bo.getGmpScore());
+        entity.setIso14001Score(bo.getIso14001Score());
+        entity.setIso45001Score(bo.getIso45001Score());
+        entity.setIso9001Score(bo.getIso9001Score());
+        entity.setAllergenEvaluate(bo.getAllergenEvaluate());
+        entity.setSummary(bo.getSummary());
+
+        Long reportId;
+        if (exist == null) {
+            entity.setId(null);
+            reportMapper.insert(entity);
+            reportId = entity.getId();
+        } else {
+            entity.setId(exist.getId());
+            reportMapper.updateById(entity);
+            reportId = exist.getId();
+        }
+
+        // 4. 回写主表:totalScore + status 由 待访厂 → 待审核
+        RiskFactoryVisit update = new RiskFactoryVisit();
+        update.setId(visit.getId());
+        update.setTotalScore(totalScore);
+        if (bo.getVisitDate() != null) {
+            update.setVisitDate(bo.getVisitDate());
+        }
+        update.setStatus(STATUS_AUDITING);
+        baseMapper.updateById(update);
+
+        log.info("访厂报告提交成功 visitId={} reportId={} totalScore={}", visit.getId(), reportId, totalScore);
+        return reportId;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean auditVisit(Long id, String auditResult, String auditRemark, Long auditorId, String auditorName) {
+        // 1. 校验
+        Assert.notNull(id, "访厂任务ID不能为空");
+        if (StrUtil.isBlank(auditResult)) {
+            throw new ServiceException("审核结论不能为空");
+        }
+        if (!AUDIT_RESULTS.contains(auditResult)) {
+            throw new ServiceException("审核结论不合法,可选值: " + AUDIT_RESULTS);
+        }
+
+        RiskFactoryVisit visit = baseMapper.selectById(id);
+        Assert.notNull(visit, "访厂任务不存在: " + id);
+
+        // 仅"待审核"状态可审核
+        if (!STATUS_AUDITING.equals(visit.getStatus())) {
+            throw new ServiceException("当前状态(" + visit.getStatus() + ")不允许审核");
+        }
+
+        // 2. 审核人信息:前端未传则自动取当前登录用户
+        Long finalAuditorId = auditorId;
+        String finalAuditorName = auditorName;
+        try {
+            if (finalAuditorId == null) {
+                finalAuditorId = LoginHelper.getUserId();
+            }
+            if (StrUtil.isBlank(finalAuditorName)) {
+                finalAuditorName = LoginHelper.getUsername();
+            }
+        } catch (Exception ignore) {
+            // 允许匿名审核(后台任务)
+        }
+
+        // 3. 状态流转:待审核 → 已审核 + 写入审核信息
+        RiskFactoryVisit update = new RiskFactoryVisit();
+        update.setId(id);
+        update.setStatus(STATUS_AUDITED);
+        update.setAuditResult(auditResult);
+        update.setAuditRemark(auditRemark);
+        update.setAuditorId(finalAuditorId);
+        update.setAuditorName(finalAuditorName);
+        update.setAuditTime(new Date());
+
+        int row = baseMapper.updateById(update);
+        log.info("访厂审核完成 visitId={} result={} auditor={} remark={}",
+            id, auditResult, finalAuditorName, auditRemark);
+        return row > 0;
+    }
+
+    // =============== 工具方法 ===============
+
+    /**
+     * 生成访厂ID:FC+yyyyMMdd+4位随机序号
+     * <p>
+     * 备注:并发场景下可能有极小概率重复(同一天超过 10000 条时);
+     * 数据库有 uk_visit_code 唯一约束做兜底,重复时会抛 DuplicateKeyException。
+     */
+    private String generateVisitCode() {
+        String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+        int seq = ThreadLocalRandom.current().nextInt(0, 10000);
+        return String.format("FC%s%04d", date, seq);
+    }
+
+    /**
+     * 计算访查实际得分:对 ESG/GMP/ISO14001/ISO45001/ISO9001 五个数字模块的
+     * 所有数字字段求和。理论上总和=30+20+15+15+20=100。
+     * <p>
+     * JSON 结构示例:
+     * <pre>
+     * { "carbon": 3, "waste": 2, ... }
+     * </pre>
+     * 嵌套结构(对象/数组)则递归求和。
+     */
+    @SuppressWarnings("unchecked")
+    private BigDecimal computeTotalScore(RiskFactoryVisitReportBo bo) {
+        double sum = 0.0;
+        sum += sumJsonNumbers(bo.getEsgScore());
+        sum += sumJsonNumbers(bo.getGmpScore());
+        sum += sumJsonNumbers(bo.getIso14001Score());
+        sum += sumJsonNumbers(bo.getIso45001Score());
+        sum += sumJsonNumbers(bo.getIso9001Score());
+        return BigDecimal.valueOf(sum).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 递归累加 JSON 中所有数字字段
+     */
+    @SuppressWarnings("unchecked")
+    private double sumJsonNumbers(Object obj) {
+        if (obj == null) {
+            return 0.0;
+        }
+        if (obj instanceof Number) {
+            return ((Number) obj).doubleValue();
+        }
+        if (obj instanceof Map) {
+            double s = 0.0;
+            for (Object v : ((Map<String, Object>) obj).values()) {
+                s += sumJsonNumbers(v);
+            }
+            return s;
+        }
+        if (obj instanceof List) {
+            double s = 0.0;
+            for (Object v : (List<?>) obj) {
+                s += sumJsonNumbers(v);
+            }
+            return s;
+        }
+        // 其他类型(字符串等)忽略
+        return 0.0;
+    }
+}

+ 143 - 0
sql/risk_quality_factory_visit.sql

@@ -0,0 +1,143 @@
+-- =============================================================
+-- 风控品控平台 · 品控访厂增量脚本 (2026-07-07)
+-- 文件: risk_quality_factory_visit.sql
+-- 适用: 已有环境升级 / 新环境增量应用
+-- 幂等: 所有 DDL/DML 均使用条件判断,可重复执行
+-- =============================================================
+
+-- 1. 访厂主表
+CREATE TABLE IF NOT EXISTS `risk_factory_visit` (
+  `id`              BIGINT       NOT NULL AUTO_INCREMENT         COMMENT '主键ID',
+  `visit_code`      VARCHAR(50)  NOT NULL                          COMMENT '访厂ID(FC+年月日+4位序号)',
+  `company_name`    VARCHAR(200) NOT NULL                          COMMENT '被访厂商名称',
+  `company_address` VARCHAR(500) DEFAULT NULL                      COMMENT '厂商地址',
+  `visit_date`      DATE         DEFAULT NULL                      COMMENT '访厂日期',
+  `visitor_id`      BIGINT       DEFAULT NULL                      COMMENT '访查人ID',
+  `visitor_name`    VARCHAR(50)  DEFAULT NULL                      COMMENT '访查人姓名',
+  `audit_dept`      VARCHAR(100) DEFAULT NULL                      COMMENT '审核部门',
+  `product_scope`   VARCHAR(200) DEFAULT NULL                      COMMENT '审核产品范围',
+  `reviewer_id`     BIGINT       DEFAULT NULL                      COMMENT '评审人ID',
+  `reviewer_name`   VARCHAR(50)  DEFAULT NULL                      COMMENT '评审人姓名',
+  `total_score`     DECIMAL(6,2) DEFAULT 0.00                      COMMENT '访查实际得分(各模块加权求和)',
+  `audit_result`    VARCHAR(20)  DEFAULT NULL                      COMMENT '审核结论(通过/不通过/待定)',
+  `audit_remark`    VARCHAR(1000) DEFAULT NULL                     COMMENT '审核意见',
+  `auditor_id`      BIGINT       DEFAULT NULL                      COMMENT '审核人ID',
+  `auditor_name`    VARCHAR(50)  DEFAULT NULL                      COMMENT '审核人姓名',
+  `audit_time`      DATETIME     DEFAULT NULL                      COMMENT '审核时间',
+  `status`          VARCHAR(20)  NOT NULL DEFAULT '待访厂'         COMMENT '状态(待访厂/待审核/已审核)',
+  `remark`          VARCHAR(1000) DEFAULT NULL                     COMMENT '备注',
+  `tenant_id`       VARCHAR(20)  NOT NULL DEFAULT '000000'         COMMENT '租户ID',
+  `create_dept`     BIGINT       DEFAULT NULL                      COMMENT '创建部门',
+  `create_by`       BIGINT       DEFAULT NULL                      COMMENT '创建人',
+  `create_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP         COMMENT '创建时间',
+  `update_by`       BIGINT       DEFAULT NULL                      COMMENT '更新人',
+  `update_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_visit_code` (`visit_code`),
+  KEY `idx_status` (`status`),
+  KEY `idx_visit_date` (`visit_date`),
+  KEY `idx_tenant` (`tenant_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='品控·访厂主表';
+
+-- 2. 访厂报告子表(JSON 存储 7 个模块评分,简化表结构)
+CREATE TABLE IF NOT EXISTS `risk_factory_visit_report` (
+  `id`                BIGINT       NOT NULL AUTO_INCREMENT       COMMENT '主键ID',
+  `visit_id`          BIGINT       NOT NULL                       COMMENT '访厂主表ID',
+  `basic_evaluate`    JSON         DEFAULT NULL                   COMMENT '基础信息核查(9项是/否)',
+  `esg_score`         JSON         DEFAULT NULL                   COMMENT 'ESG评分(10项数字)',
+  `gmp_score`         JSON         DEFAULT NULL                   COMMENT 'GMP评分(5项数字)',
+  `iso14001_score`    JSON         DEFAULT NULL                   COMMENT 'ISO14001评分(4项数字)',
+  `iso45001_score`    JSON         DEFAULT NULL                   COMMENT 'ISO45001评分(4项数字)',
+  `iso9001_score`     JSON         DEFAULT NULL                   COMMENT 'ISO9001评分(5项数字)',
+  `allergen_evaluate` JSON         DEFAULT NULL                   COMMENT '致敏原管控(19项合格/不合格/不适用+备注)',
+  `summary`           TEXT         DEFAULT NULL                   COMMENT '7.6 问题汇总与整改要求',
+  `tenant_id`         VARCHAR(20)  NOT NULL DEFAULT '000000'      COMMENT '租户ID',
+  `create_dept`       BIGINT       DEFAULT NULL                   COMMENT '创建部门',
+  `create_by`         BIGINT       DEFAULT NULL                   COMMENT '创建人',
+  `create_time`       DATETIME     DEFAULT CURRENT_TIMESTAMP      COMMENT '创建时间',
+  `update_by`         BIGINT       DEFAULT NULL                   COMMENT '更新人',
+  `update_time`       DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_visit_id` (`visit_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='品控·访厂报告(7模块评分JSON)';
+
+-- 3. 字典类型:访厂状态
+INSERT INTO `sys_dict_type` (`dict_id`, `tenant_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES (12200, '000000', '访厂状态', 'visit_status', 103, 1, NOW(), 1, NOW(), '访厂任务状态(待访厂/待审核/已审核)')
+ON DUPLICATE KEY UPDATE `update_time` = NOW();
+
+-- 4. 字典类型:审核结论
+INSERT INTO `sys_dict_type` (`dict_id`, `tenant_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES (12201, '000000', '访厂审核结论', 'visit_audit_result', 103, 1, NOW(), 1, NOW(), '访厂审核结论(通过/不通过/待定)')
+ON DUPLICATE KEY UPDATE `update_time` = NOW();
+
+-- 5. 字典类型:基础信息评价
+INSERT INTO `sys_dict_type` (`dict_id`, `tenant_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES (12202, '000000', '基础信息评价', 'visit_basic_evaluate', 103, 1, NOW(), 1, NOW(), '基础信息核查模块评价(是/否)')
+ON DUPLICATE KEY UPDATE `update_time` = NOW();
+
+-- 6. 字典类型:致敏原评价
+INSERT INTO `sys_dict_type` (`dict_id`, `tenant_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES (12203, '000000', '致敏原评价', 'visit_allergen_evaluate', 103, 1, NOW(), 1, NOW(), '致敏原管控模块评价(合格/不合格/不适用)')
+ON DUPLICATE KEY UPDATE `update_time` = NOW();
+
+-- 7. 字典数据:访厂状态(3 项)
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 1, '000000', '待访厂', '待访厂', 'visit_status', '', 'warning', 'N', 103, 1, NOW(), '等待填写访厂报告'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_status' AND `dict_value` = '待访厂');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 2, '000000', '待审核', '待审核', 'visit_status', '', 'primary', 'N', 103, 1, NOW(), '已提交,等待审核'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_status' AND `dict_value` = '待审核');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 3, '000000', '已审核', '已审核', 'visit_status', '', 'success', 'N', 103, 1, NOW(), '审核完成'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_status' AND `dict_value` = '已审核');
+
+-- 8. 字典数据:访厂审核结论(3 项)
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 1, '000000', '通过', '通过', 'visit_audit_result', '', 'success', 'N', 103, 1, NOW(), '审核通过'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_audit_result' AND `dict_value` = '通过');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 2, '000000', '不通过', '不通过', 'visit_audit_result', '', 'danger', 'N', 103, 1, NOW(), '审核不通过'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_audit_result' AND `dict_value` = '不通过');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 3, '000000', '待定', '待定', 'visit_audit_result', '', 'info', 'N', 103, 1, NOW(), '待定,需补充资料'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_audit_result' AND `dict_value` = '待定');
+
+-- 9. 字典数据:基础信息评价(2 项)
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 1, '000000', '是', '是', 'visit_basic_evaluate', '', 'success', 'N', 103, 1, NOW(), '评价为是'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_basic_evaluate' AND `dict_value` = '是');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 2, '000000', '否', '否', 'visit_basic_evaluate', '', 'danger', 'N', 103, 1, NOW(), '评价为否'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_basic_evaluate' AND `dict_value` = '否');
+
+-- 10. 字典数据:致敏原评价(3 项)
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 1, '000000', '合格', '合格', 'visit_allergen_evaluate', '', 'success', 'N', 103, 1, NOW(), '评价为合格'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_allergen_evaluate' AND `dict_value` = '合格');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 2, '000000', '不合格', '不合格', 'visit_allergen_evaluate', '', 'danger', 'N', 103, 1, NOW(), '评价为不合格'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_allergen_evaluate' AND `dict_value` = '不合格');
+
+INSERT INTO `sys_dict_data` (`dict_sort`, `tenant_id`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT 3, '000000', '不适用', '不适用', 'visit_allergen_evaluate', '', 'info', 'N', 103, 1, NOW(), '不适用本项'
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'visit_allergen_evaluate' AND `dict_value` = '不适用');
+
+-- 11. 验证
+SELECT 'risk_factory_visit' AS tbl, COUNT(*) AS cnt FROM `risk_factory_visit`
+UNION ALL
+SELECT 'risk_factory_visit_report', COUNT(*) FROM `risk_factory_visit_report`
+UNION ALL
+SELECT 'visit_status', COUNT(*) FROM `sys_dict_data` WHERE `dict_type` = 'visit_status'
+UNION ALL
+SELECT 'visit_audit_result', COUNT(*) FROM `sys_dict_data` WHERE `dict_type` = 'visit_audit_result'
+UNION ALL
+SELECT 'visit_basic_evaluate', COUNT(*) FROM `sys_dict_data` WHERE `dict_type` = 'visit_basic_evaluate'
+UNION ALL
+SELECT 'visit_allergen_evaluate', COUNT(*) FROM `sys_dict_data` WHERE `dict_type` = 'visit_allergen_evaluate';

+ 43 - 0
sql/risk_quality_factory_visit_menu.sql

@@ -0,0 +1,43 @@
+-- ============================================
+-- 品控访厂菜单权限 SQL 脚本
+-- 创建时间: 2026-06-21
+-- 依赖: 风控平台菜单 risk_menu.sql 已执行,@risk_parent_id 为风控平台目录 ID
+-- 关联脚本: risk_quality_menu.sql(品控巡检菜单)、risk_quality_factory_visit.sql(表结构)
+-- ============================================
+
+-- 设置变量(指向已有风控平台目录,品控访厂作为同级子菜单挂在风控平台目录下)
+SET @risk_parent_id = (SELECT menu_id FROM sys_menu WHERE menu_name = '风控平台' AND menu_type = 'M' LIMIT 1);
+
+-- 1. 品控访厂主菜单
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark)
+VALUES ('品控访厂', @risk_parent_id, 14, 'factoryVisit', 'risk/factoryVisit/index', 1, 0, 'C', '0', '0', 'risk:factoryVisit:list', '#', 103, 1, NOW(), '品控访厂管理菜单');
+
+SET @factory_visit_id = LAST_INSERT_ID();
+
+-- 2. 品控访厂按钮权限(列表/新增/修改/删除/导出/填写报告/审核)
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) VALUES
+('品控访厂查询', @factory_visit_id, 1, '#', '', 1, 0, 'F', '0', '0', 'risk:factoryVisit:list', '#', 103, 1, NOW(), ''),
+('品控访厂新增', @factory_visit_id, 2, '#', '', 1, 0, 'F', '0', '0', 'risk:factoryVisit:add', '#', 103, 1, NOW(), ''),
+('品控访厂修改', @factory_visit_id, 3, '#', '', 1, 0, 'F', '0', '0', 'risk:factoryVisit:edit', '#', 103, 1, NOW(), ''),
+('品控访厂删除', @factory_visit_id, 4, '#', '', 1, 0, 'F', '0', '0', 'risk:factoryVisit:remove', '#', 103, 1, NOW(), ''),
+('品控访厂导出', @factory_visit_id, 5, '#', '', 1, 0, 'F', '0', '0', 'risk:factoryVisit:export', '#', 103, 1, NOW(), ''),
+('品控访厂填写报告', @factory_visit_id, 6, '#', '', 1, 0, 'F', '0', '0', 'risk:factoryVisit:report', '#', 103, 1, NOW(), ''),
+('品控访厂审核', @factory_visit_id, 7, '#', '', 1, 0, 'F', '0', '0', 'risk:factoryVisit:audit', '#', 103, 1, NOW(), '');
+
+-- 3. 给超级管理员角色授权所有品控访厂权限
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, menu_id FROM sys_menu
+WHERE perms LIKE 'risk:factoryVisit:%'
+ON DUPLICATE KEY UPDATE role_id = role_id;
+
+-- 4. 给租户管理员(角色ID 2,如果存在)也授权
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 2, menu_id FROM sys_menu
+WHERE perms LIKE 'risk:factoryVisit:%'
+ON DUPLICATE KEY UPDATE role_id = role_id;
+
+-- 完成提示
+SELECT '品控访厂菜单权限初始化完成!' AS message,
+       COUNT(*) AS total_menus
+FROM sys_menu
+WHERE perms LIKE 'risk:factoryVisit:%';