diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index bd87293..7a76333 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -328,3 +328,19 @@ fad: webKey: 34bf20d1db5b183558b9bb85d6eed783 securityKey: 6f9171724396deb5f8c42ef256b3cbc5 +--- # 小米 MiMo 大模型(AI 合同/简历审核) +mimo: + # OpenAI 兼容接口地址(不含 /chat/completions) + base-url: https://api.xiaomimimo.com/v1 + # API Key + api-key: sk-cgdkhgch2w1cg37dl12scuckyzbnrkj37ih3b6f0k13dcgwp + # 多模态模型 + model: mimo-v2.5 + # 最大生成 token(mimo-v2.5 为推理模型,会先消耗 token 思考,需留足额度) + max-tokens: 8192 + temperature: 0.3 + # 单次请求读超时(秒)。推理+长文档审核较慢,给足时间 + timeout: 180 + # PDF 无法提取文字时(扫描件)转图片走多模态,最多渲染页数 + max-image-pages: 8 + diff --git a/ruoyi-oa/pom.xml b/ruoyi-oa/pom.xml index 6b7611c..6985e8a 100644 --- a/ruoyi-oa/pom.xml +++ b/ruoyi-oa/pom.xml @@ -83,6 +83,17 @@ 2.0.29 + + + org.apache.poi + poi-ooxml + + + org.apache.poi + poi-scratchpad + ${poi.version} + + diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java new file mode 100644 index 0000000..f38bddf --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java @@ -0,0 +1,92 @@ +package com.ruoyi.oa.aireview; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.poi.hwpf.extractor.WordExtractor; +import org.apache.poi.xwpf.extractor.XWPFWordExtractor; +import org.apache.poi.xwpf.usermodel.XWPFDocument; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * 文档解析:从 PDF / Word 提取文字;扫描版 PDF 渲染为图片走多模态。 + * + * @author wangyu + */ +@Slf4j +public final class DocumentParseUtil { + + private DocumentParseUtil() {} + + /** 提取文字内容(PDF / docx / doc)。提取不到返回空串,不抛异常。 */ + public static String extractText(String fileName, byte[] bytes) { + String lower = fileName == null ? "" : fileName.toLowerCase(); + try { + if (lower.endsWith(".pdf")) { + return extractPdfText(bytes); + } else if (lower.endsWith(".docx")) { + try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes)); + XWPFWordExtractor ex = new XWPFWordExtractor(doc)) { + return StringUtils.trimToEmpty(ex.getText()); + } + } else if (lower.endsWith(".doc")) { + try (WordExtractor ex = new WordExtractor(new ByteArrayInputStream(bytes))) { + return StringUtils.trimToEmpty(ex.getText()); + } + } + throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件"); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("解析文档失败:{}", fileName, e); + return ""; + } + } + + private static String extractPdfText(byte[] bytes) throws Exception { + try (PDDocument doc = PDDocument.load(bytes)) { + PDFTextStripper stripper = new PDFTextStripper(); + return StringUtils.trimToEmpty(stripper.getText(doc)); + } + } + + /** 是否为 PDF */ + public static boolean isPdf(String fileName) { + return fileName != null && fileName.toLowerCase().endsWith(".pdf"); + } + + /** + * 将 PDF 渲染为 PNG 图片的 data URI 列表(用于扫描件走多模态)。 + * + * @param maxPages 最多渲染页数 + */ + public static List renderPdfImages(byte[] bytes, int maxPages) { + List images = new ArrayList<>(); + try (PDDocument doc = PDDocument.load(bytes)) { + PDFRenderer renderer = new PDFRenderer(doc); + int pages = Math.min(doc.getNumberOfPages(), Math.max(1, maxPages)); + for (int i = 0; i < pages; i++) { + BufferedImage img = renderer.renderImageWithDPI(i, 120, ImageType.RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + String b64 = Base64.getEncoder().encodeToString(baos.toByteArray()); + images.add("data:image/png;base64," + b64); + } + } catch (Exception e) { + log.error("PDF 转图片失败", e); + throw new ServiceException("PDF 转图片失败:" + e.getMessage()); + } + return images; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java new file mode 100644 index 0000000..616dcf1 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java @@ -0,0 +1,238 @@ +package com.ruoyi.oa.aireview; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import javax.annotation.PostConstruct; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.function.BiConsumer; + +/** + * 小米 MiMo 大模型调用客户端(OpenAI 兼容 /chat/completions)。 + * + * 注意:mimo-v2.5 是推理模型,响应里 message.content 是最终答案, + * message.reasoning_content 是思考过程;max_completion_tokens 要给足, + * 否则 token 被思考耗尽会出现 content 为空、finish_reason=length。 + * + * @author wangyu + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MiMoClient { + + private final MiMoProperties props; + private final ObjectMapper json = new ObjectMapper(); + + private RestTemplate restTemplate; + + @PostConstruct + public void init() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(10_000); + factory.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000); + this.restTemplate = new RestTemplate(factory); + } + + /** + * 纯文本对话 + */ + public String chatText(String systemPrompt, String userText) { + ObjectNode body = baseBody(); + ArrayNode messages = body.putArray("messages"); + if (StringUtils.isNotBlank(systemPrompt)) { + messages.addObject().put("role", "system").put("content", systemPrompt); + } + messages.addObject().put("role", "user").put("content", userText); + return send(body); + } + + /** + * 多模态对话:一段文字 + 若干图片(data URI,形如 data:image/png;base64,xxx) + */ + public String chatMultimodal(String systemPrompt, String userText, List imageDataUris) { + ObjectNode body = baseBody(); + ArrayNode messages = body.putArray("messages"); + if (StringUtils.isNotBlank(systemPrompt)) { + messages.addObject().put("role", "system").put("content", systemPrompt); + } + ObjectNode userMsg = messages.addObject(); + userMsg.put("role", "user"); + ArrayNode content = userMsg.putArray("content"); + content.addObject().put("type", "text").put("text", userText); + if (imageDataUris != null) { + for (String uri : imageDataUris) { + if (StringUtils.isBlank(uri)) continue; + ObjectNode img = content.addObject(); + img.put("type", "image_url"); + img.putObject("image_url").put("url", uri); + } + } + return send(body); + } + + /** + * 流式对话(SSE)。一边接收一边回调,返回拼接好的最终答案 content。 + * + * @param imageDataUris 多模态图片(可空) + * @param onDelta 回调:kind = "reasoning"(思考) / "content"(正文),text = 增量片段 + */ + public String chatStream(String systemPrompt, String userText, List imageDataUris, + BiConsumer onDelta) { + if (StringUtils.isBlank(props.getApiKey())) { + throw new ServiceException("未配置 MiMo API Key(application.yml: mimo.api-key)"); + } + ObjectNode body = baseBody(); + body.put("stream", true); + ArrayNode messages = body.putArray("messages"); + if (StringUtils.isNotBlank(systemPrompt)) { + messages.addObject().put("role", "system").put("content", systemPrompt); + } + boolean multimodal = imageDataUris != null && !imageDataUris.isEmpty(); + if (multimodal) { + ObjectNode userMsg = messages.addObject(); + userMsg.put("role", "user"); + ArrayNode content = userMsg.putArray("content"); + content.addObject().put("type", "text").put("text", userText); + for (String uri : imageDataUris) { + if (StringUtils.isBlank(uri)) continue; + ObjectNode img = content.addObject(); + img.put("type", "image_url"); + img.putObject("image_url").put("url", uri); + } + } else { + messages.addObject().put("role", "user").put("content", userText); + } + + HttpURLConnection conn = null; + try { + URL url = new URL(props.getBaseUrl() + "/chat/completions"); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "text/event-stream"); + conn.setRequestProperty("api-key", props.getApiKey()); + conn.setRequestProperty("Authorization", "Bearer " + props.getApiKey()); + try (OutputStream os = conn.getOutputStream()) { + os.write(json.writeValueAsString(body).getBytes(StandardCharsets.UTF_8)); + } + int code = conn.getResponseCode(); + if (code >= 400) { + String err = readAll(conn.getErrorStream()); + throw new ServiceException("AI 服务返回 " + code + ":" + StringUtils.substring(err, 0, 300)); + } + StringBuilder full = new StringBuilder(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + if (line.isEmpty() || !line.startsWith("data:")) continue; + String payload = line.substring(5).trim(); + if ("[DONE]".equals(payload)) break; + JsonNode delta = json.readTree(payload).path("choices").path(0).path("delta"); + JsonNode rc = delta.get("reasoning_content"); + if (rc != null && !rc.isNull() && !rc.asText().isEmpty()) { + onDelta.accept("reasoning", rc.asText()); + } + JsonNode c = delta.get("content"); + if (c != null && !c.isNull() && !c.asText().isEmpty()) { + full.append(c.asText()); + onDelta.accept("content", c.asText()); + } + } + } + if (full.length() == 0) { + throw new ServiceException("AI 未返回有效内容(文档过长或额度不足)"); + } + return full.toString(); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("调用 MiMo 流式接口失败", e); + throw new ServiceException("AI 服务调用失败:" + e.getMessage()); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private String readAll(InputStream in) { + if (in == null) return ""; + try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String l; + while ((l = r.readLine()) != null) sb.append(l); + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + private ObjectNode baseBody() { + ObjectNode body = json.createObjectNode(); + body.put("model", props.getModel()); + // OpenAI 兼容新参数名;MiMo 同时也接受 max_tokens,这里两者都给以防万一 + body.put("max_completion_tokens", props.getMaxTokens()); + body.put("max_tokens", props.getMaxTokens()); + body.put("temperature", props.getTemperature()); + body.put("stream", false); + return body; + } + + private String send(ObjectNode body) { + if (StringUtils.isBlank(props.getApiKey())) { + throw new ServiceException("未配置 MiMo API Key(application.yml: mimo.api-key)"); + } + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + // 文档要求 api-key 头;同时带 Bearer 以兼容 OpenAI 风格 + headers.set("api-key", props.getApiKey()); + headers.set("Authorization", "Bearer " + props.getApiKey()); + + String url = props.getBaseUrl() + "/chat/completions"; + try { + HttpEntity entity = new HttpEntity<>(json.writeValueAsString(body), headers); + ResponseEntity resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + if (resp.getStatusCode() != HttpStatus.OK || resp.getBody() == null) { + throw new ServiceException("AI 服务返回异常:" + resp.getStatusCode()); + } + JsonNode root = json.readTree(resp.getBody()); + JsonNode message = root.path("choices").path(0).path("message"); + String content = message.path("content").asText(""); + String finish = root.path("choices").path(0).path("finish_reason").asText(""); + if (StringUtils.isBlank(content)) { + if ("length".equals(finish)) { + throw new ServiceException("文档过长,AI 输出被截断,请精简文档或提高 mimo.max-tokens 后重试"); + } + throw new ServiceException("AI 未返回有效内容"); + } + int total = root.path("usage").path("total_tokens").asInt(0); + log.info("MiMo 审核完成,model={}, 消耗 token={}", props.getModel(), total); + return content; + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("调用 MiMo 失败", e); + throw new ServiceException("AI 服务调用失败:" + e.getMessage()); + } + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java new file mode 100644 index 0000000..92de805 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java @@ -0,0 +1,37 @@ +package com.ruoyi.oa.aireview; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 小米 MiMo 大模型配置(AI 合同/简历审核) + * + * @author wangyu + */ +@Data +@Component +@ConfigurationProperties(prefix = "mimo") +public class MiMoProperties { + + /** OpenAI 兼容接口地址(不含 /chat/completions) */ + private String baseUrl = "https://api.xiaomimimo.com/v1"; + + /** API Key */ + private String apiKey; + + /** 多模态模型名 */ + private String model = "mimo-v2.5"; + + /** 最大生成 token(mimo-v2.5 为推理模型,需留足额度给思考+输出) */ + private Integer maxTokens = 8192; + + /** 采样温度 */ + private Double temperature = 0.3; + + /** 单次请求读超时(秒) */ + private Integer timeout = 180; + + /** PDF 转图片走多模态时最多渲染页数 */ + private Integer maxImagePages = 8; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java new file mode 100644 index 0000000..b424c0e --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java @@ -0,0 +1,70 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.oa.domain.bo.OaAiReviewBo; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; +import com.ruoyi.oa.service.IOaAiReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Arrays; + +/** + * AI 审核(合同 / 简历) + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/oa/aiReview") +public class OaAiReviewController extends BaseController { + + private final IOaAiReviewService service; + + /** + * 上传并审核 + */ + @Log(title = "AI审核", businessType = BusinessType.OTHER) + @PostMapping(value = "/analyze", consumes = "multipart/form-data") + public R analyze(@RequestParam("file") MultipartFile file, + @RequestParam("reviewType") String reviewType, + @RequestParam(value = "position", required = false) String position) { + return R.ok("审核完成", service.analyze(file, reviewType, position)); + } + + /** + * 上传并流式审核(SSE:边生成边推送,结束后落库)。 + * 不加 @Log:操作日志切面会尝试序列化返回值,SseEmitter 不适合被序列化。 + */ + @PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8") + public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file, + @RequestParam("reviewType") String reviewType, + @RequestParam(value = "position", required = false) String position) { + return service.analyzeStream(file, reviewType, position); + } + + @GetMapping("/list") + public TableDataInfo list(OaAiReviewBo bo, PageQuery pageQuery) { + return service.queryPageList(bo, pageQuery); + } + + @GetMapping("/{id}") + public R getInfo(@NotNull @PathVariable Long id) { + return R.ok(service.queryById(id)); + } + + @Log(title = "AI审核", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty @PathVariable Long[] ids) { + return toAjax(service.deleteByIds(Arrays.asList(ids))); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaArrivalDetailController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaArrivalDetailController.java new file mode 100644 index 0000000..cecd139 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaArrivalDetailController.java @@ -0,0 +1,101 @@ +package com.ruoyi.oa.controller; + +import java.util.List; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import lombok.RequiredArgsConstructor; +import javax.servlet.http.HttpServletResponse; +import javax.validation.constraints.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import com.ruoyi.common.core.validate.QueryGroup; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.oa.domain.bo.OaArrivalDetailBo; +import com.ruoyi.oa.service.IOaArrivalDetailService; +import com.ruoyi.common.core.page.TableDataInfo; + +/** + * 到货明细 + * + * @author ruoyi + * @date 2026-06-12 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/oa/arrivalDetail") +public class OaArrivalDetailController extends BaseController { + + private final IOaArrivalDetailService iOaArrivalDetailService; + + /** + * 查询到货明细列表 + */ + @GetMapping("/list") + public TableDataInfo list(OaArrivalDetailBo bo, PageQuery pageQuery) { + return iOaArrivalDetailService.queryPageList(bo, pageQuery); + } + + /** + * 导出到货明细列表 + */ + @Log(title = "到货明细", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(OaArrivalDetailBo bo, HttpServletResponse response) { + List list = iOaArrivalDetailService.queryList(bo); + ExcelUtil.exportExcel(list, "到货明细", OaArrivalDetailVo.class, response); + } + + /** + * 获取到货明细详细信息 + * + * @param detailId 主键 + */ + @GetMapping("/{detailId}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Long detailId) { + return R.ok(iOaArrivalDetailService.queryById(detailId)); + } + + /** + * 新增到货明细 + */ + @Log(title = "到货明细", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody OaArrivalDetailBo bo) { + return toAjax(iOaArrivalDetailService.insertByBo(bo)); + } + + /** + * 修改到货明细 + */ + @Log(title = "到货明细", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody OaArrivalDetailBo bo) { + return toAjax(iOaArrivalDetailService.updateByBo(bo)); + } + + /** + * 删除到货明细 + * + * @param detailIds 主键串 + */ + @Log(title = "到货明细", businessType = BusinessType.DELETE) + @DeleteMapping("/{detailIds}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] detailIds) { + return toAjax(iOaArrivalDetailService.deleteWithValidByIds(Arrays.asList(detailIds), true)); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java new file mode 100644 index 0000000..2500975 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaMeetingMinutesController.java @@ -0,0 +1,67 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; +import com.ruoyi.oa.service.IOaMeetingMinutesService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Arrays; + +/** + * 会议纪要 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/oa/meetingMinutes") +public class OaMeetingMinutesController extends BaseController { + + private final IOaMeetingMinutesService service; + + @GetMapping("/list") + public TableDataInfo list(OaMeetingMinutesBo bo, PageQuery pageQuery) { + return service.queryPageList(bo, pageQuery); + } + + @GetMapping("/{id}") + public R getInfo(@NotNull @PathVariable Long id) { + return R.ok(service.queryById(id)); + } + + /** + * 新增,返回新纪要ID(前端据此切换为编辑态,避免重复保存生成多条) + */ + @Log(title = "会议纪要", businessType = BusinessType.INSERT) + @RepeatSubmit + @PostMapping + public R add(@RequestBody OaMeetingMinutesBo bo) { + if (!service.insertByBo(bo)) { + return R.fail("保存失败"); + } + return R.ok(bo.getId()); + } + + @Log(title = "会议纪要", businessType = BusinessType.UPDATE) + @RepeatSubmit + @PutMapping + public R edit(@RequestBody OaMeetingMinutesBo bo) { + return toAjax(service.updateByBo(bo)); + } + + @Log(title = "会议纪要", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty @PathVariable Long[] ids) { + return toAjax(service.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java new file mode 100644 index 0000000..a11215b --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java @@ -0,0 +1,58 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI 审核记录(合同 / 简历) + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("oa_ai_review") +public class OaAiReview extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + /** 审核类型:contract 合同 / resume 简历 */ + private String reviewType; + + /** 原始文件名 */ + private String fileName; + + /** OSS 文件ID(原件留存,可空) */ + private Long ossId; + + /** OSS 文件地址 */ + private String fileUrl; + + /** 简历审核的目标岗位 */ + private String position; + + /** 简历匹配度评分 0-100(合同为空) */ + private Integer matchScore; + + /** 合同总体风险评级:高/中/低(简历为空) */ + private String riskLevel; + + /** AI 审核结论摘要(列表展示,纯文本) */ + private String summary; + + /** AI 审核结果(Markdown) */ + private String resultMd; + + /** 使用的模型 */ + private String model; + + /** 消耗 token */ + private Integer tokens; + + @TableLogic + private String delFlag; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaArrivalDetail.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaArrivalDetail.java new file mode 100644 index 0000000..0e29f52 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaArrivalDetail.java @@ -0,0 +1,95 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.io.Serializable; +import java.util.Date; +import java.math.BigDecimal; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 到货明细对象 oa_arrival_detail + * + * @author ruoyi + * @date 2026-06-12 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("oa_arrival_detail") +public class OaArrivalDetail extends BaseEntity { + + private static final long serialVersionUID=1L; + + /** + * 明细主键 + */ + @TableId(value = "detail_id") + private Long detailId; + /** + * 关联采购需求ID + */ + private Long requirementId; + /** + * 关联项目ID + */ + private Long projectId; + /** + * 项目类型:0=内贸 1=外贸 + */ + private Integer tradeType; + /** + * 合同编号 + */ + private String contractNo; + /** + * 物料名称 + */ + private String goodsName; + /** + * 数量 + */ + private BigDecimal quantity; + /** + * 单价 + */ + private BigDecimal unitPrice; + /** + * 到货类型 (0 收,1 发) + */ + private Integer arrivalType; + /** + * 到货截止日期 + */ + private Date deadline; + /** + * 发货地点 + */ + private String sourceAddress; + /** + * 规划目的地 + */ + private String targetAddress; + /** + * 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消) + */ + private Integer detailStatus; + /** + * 描述 + */ + private String description; + /** + * 手动备注 + */ + private String remark; + /** + * 删除标志:0正常 1删除 + */ + @TableLogic + private Integer delFlag; + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaMeetingMinutes.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaMeetingMinutes.java new file mode 100644 index 0000000..0faa7c1 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaMeetingMinutes.java @@ -0,0 +1,58 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * 会议纪要 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("oa_meeting_minutes") +public class OaMeetingMinutes extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private String meetingCode; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date meetingDate; + + /** sys_oa_project.project_id */ + private Long projectId; + + private String meetingType; + private String subject; + private String location; + + /** sys_user.user_id */ + private Long hostUserId; + + /** 参会人员 user_id CSV */ + private String attendeeUserIds; + + private String topic; + private String discussion; + private String decision; + + /** 待办 JSON:[{assigneeUserId, content, deadline, status, taskId}] */ + private String tasksJson; + + /** 1=保存时自动同步生成 sys_oa_task */ + private Integer syncTask; + + @TableLogic + private String delFlag; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java new file mode 100644 index 0000000..f28da36 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java @@ -0,0 +1,21 @@ +package com.ruoyi.oa.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI 审核记录 查询 BO + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OaAiReviewBo extends BaseEntity { + + private Long id; + + /** 审核类型:contract / resume */ + private String reviewType; + + /** 关键字(文件名 / 岗位) */ + private String keyword; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaArrivalDetailBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaArrivalDetailBo.java new file mode 100644 index 0000000..3b26b1e --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaArrivalDetailBo.java @@ -0,0 +1,103 @@ +package com.ruoyi.oa.domain.bo; + +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.validation.constraints.*; + +import java.util.Date; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 到货明细业务对象 oa_arrival_detail + * + * @author ruoyi + * @date 2026-06-12 + */ + +@Data +@EqualsAndHashCode(callSuper = true) +public class OaArrivalDetailBo extends BaseEntity { + + /** + * 明细主键 + */ + private Long detailId; + + /** + * 关联采购需求ID + */ + private Long requirementId; + + /** + * 关联项目ID + */ + private Long projectId; + + /** + * 项目类型:0=内贸 1=外贸 + */ + private Integer tradeType; + + /** + * 合同编号 + */ + private String contractNo; + + /** + * 物料名称 + */ + private String goodsName; + + /** + * 数量 + */ + private BigDecimal quantity; + + /** + * 单价 + */ + private BigDecimal unitPrice; + + /** + * 到货类型 (0 收,1 发) + */ + private Integer arrivalType; + + /** + * 到货截止日期 + */ + private Date deadline; + + /** + * 发货地点 + */ + private String sourceAddress; + + /** + * 规划目的地 + */ + private String targetAddress; + + /** + * 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消) + */ + private Integer detailStatus; + + /** + * 描述 + */ + private String description; + + /** + * 手动备注 + */ + private String remark; + + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaMeetingMinutesBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaMeetingMinutesBo.java new file mode 100644 index 0000000..4b158e4 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaMeetingMinutesBo.java @@ -0,0 +1,47 @@ +package com.ruoyi.oa.domain.bo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * 会议纪要 BO + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OaMeetingMinutesBo extends BaseEntity { + + private Long id; + private String meetingCode; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date meetingDate; + + private Long projectId; + private String meetingType; + private String subject; + private String location; + private Long hostUserId; + private String attendeeUserIds; + private String topic; + private String discussion; + private String decision; + private String tasksJson; + private Integer syncTask; + + /** 关键字模糊(主题/地点/项目名) */ + private String keyword; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date dateFrom; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date dateTo; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java new file mode 100644 index 0000000..6c81d32 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java @@ -0,0 +1,33 @@ +package com.ruoyi.oa.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * AI 审核记录 VO + */ +@Data +public class OaAiReviewVo implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private String reviewType; + private String fileName; + private Long ossId; + private String fileUrl; + private String position; + private Integer matchScore; + private String riskLevel; + private String summary; + private String resultMd; + private String model; + private Integer tokens; + + private String createBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java new file mode 100644 index 0000000..892f4ab --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaArrivalDetailVo.java @@ -0,0 +1,131 @@ +package com.ruoyi.oa.domain.vo; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import com.ruoyi.common.annotation.ExcelDictFormat; +import com.ruoyi.common.convert.ExcelDictConvert; +import com.ruoyi.common.core.domain.BaseEntity; +import com.alibaba.excel.annotation.ExcelIgnore; +import lombok.Data; +import java.util.Date; + + + +/** + * 到货明细视图对象 oa_arrival_detail + * + * @author ruoyi + * @date 2026-06-12 + */ +@Data +@ExcelIgnoreUnannotated +public class OaArrivalDetailVo extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** + * 明细主键 + */ + @ExcelProperty(value = "明细主键") + private Long detailId; + + /** + * 关联采购需求ID + */ + @ExcelProperty(value = "关联采购需求ID") + private Long requirementId; + + /** + * 关联项目ID + */ + @ExcelProperty(value = "关联项目ID") + private Long projectId; + + /** + * 项目类型:0=内贸 1=外贸 + */ + @ExcelProperty(value = "项目类型:0=内贸 1=外贸") + private Integer tradeType; + + /** + * 合同编号 + */ + @ExcelProperty(value = "合同编号") + private String contractNo; + + /** + * 物料名称 + */ + @ExcelProperty(value = "物料名称") + private String goodsName; + + /** + * 数量 + */ + @ExcelProperty(value = "数量") + private BigDecimal quantity; + + /** + * 单价 + */ + @ExcelProperty(value = "单价") + private BigDecimal unitPrice; + + /** + * 到货类型 (0 收,1 发) + */ + @ExcelProperty(value = "到货类型 (0 收,1 发)") + private Integer arrivalType; + + /** + * 到货截止日期 + */ + @ExcelProperty(value = "到货截止日期") + private Date deadline; + + /** + * 发货地点 + */ + @ExcelProperty(value = "发货地点") + private String sourceAddress; + + /** + * 规划目的地 + */ + @ExcelProperty(value = "规划目的地") + private String targetAddress; + + /** + * 状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消) + */ + @ExcelProperty(value = "状态(0 = 待发货,1 = 运输中,2 = 已到货,3 = 异常 / 拒收,4 = 取消)") + private Integer detailStatus; + + /** + * 描述 + */ + @ExcelProperty(value = "描述") + private String description; + + /** + * 手动备注 + */ + @ExcelProperty(value = "手动备注") + private String remark; + + /** + * 关联需求完整信息(列表展示用,非数据库字段) + */ + @ExcelIgnore + private OaRequirementsVo requirement; + + /** + * 关联项目完整信息(列表展示用,非数据库字段) + */ + @ExcelIgnore + private SysOaProjectVo project; + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaMeetingMinutesVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaMeetingMinutesVo.java new file mode 100644 index 0000000..779e99f --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaMeetingMinutesVo.java @@ -0,0 +1,51 @@ +package com.ruoyi.oa.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 会议纪要 VO + */ +@Data +public class OaMeetingMinutesVo implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private String meetingCode; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date meetingDate; + + private Long projectId; + /** 冗余:项目编号 / 名称(列表联表带出) */ + private String projectNum; + private String projectName; + + private String meetingType; + private String subject; + private String location; + private Long hostUserId; + /** 冗余:主持人昵称 */ + private String hostUserName; + + private String attendeeUserIds; + /** 冗余:参会人员昵称(逗号分隔) */ + private String attendeeUserNames; + + private String topic; + private String discussion; + private String decision; + private String tasksJson; + private Integer syncTask; + + private String createBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + private String updateBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updateTime; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java new file mode 100644 index 0000000..03dd99a --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java @@ -0,0 +1,8 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.domain.OaAiReview; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; + +public interface OaAiReviewMapper extends BaseMapperPlus { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaArrivalDetailMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaArrivalDetailMapper.java new file mode 100644 index 0000000..04c7b1a --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaArrivalDetailMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.oa.domain.OaArrivalDetail; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.common.core.mapper.BaseMapperPlus; + +/** + * 到货明细Mapper接口 + * + * @author ruoyi + * @date 2026-06-12 + */ +public interface OaArrivalDetailMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaMeetingMinutesMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaMeetingMinutesMapper.java new file mode 100644 index 0000000..234d98a --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaMeetingMinutesMapper.java @@ -0,0 +1,8 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.domain.OaMeetingMinutes; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; + +public interface OaMeetingMinutesMapper extends BaseMapperPlus { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java new file mode 100644 index 0000000..dbd02bc --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java @@ -0,0 +1,33 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.oa.domain.bo.OaAiReviewBo; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Collection; + +public interface IOaAiReviewService { + + /** + * 上传合同/简历并进行 AI 审核,落库并返回结果 + * + * @param file PDF / Word 文件 + * @param reviewType contract / resume + * @param position 简历审核的目标岗位(合同可空) + */ + OaAiReviewVo analyze(MultipartFile file, String reviewType, String position); + + /** + * 流式审核:边生成边推送(SSE),结束后落库 + */ + SseEmitter analyzeStream(MultipartFile file, String reviewType, String position); + + TableDataInfo queryPageList(OaAiReviewBo bo, PageQuery pageQuery); + + OaAiReviewVo queryById(Long id); + + Boolean deleteByIds(Collection ids); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaArrivalDetailService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaArrivalDetailService.java new file mode 100644 index 0000000..a67b28d --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaArrivalDetailService.java @@ -0,0 +1,49 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.oa.domain.OaArrivalDetail; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.oa.domain.bo.OaArrivalDetailBo; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.core.domain.PageQuery; + +import java.util.Collection; +import java.util.List; + +/** + * 到货明细Service接口 + * + * @author ruoyi + * @date 2026-06-12 + */ +public interface IOaArrivalDetailService { + + /** + * 查询到货明细 + */ + OaArrivalDetailVo queryById(Long detailId); + + /** + * 查询到货明细列表 + */ + TableDataInfo queryPageList(OaArrivalDetailBo bo, PageQuery pageQuery); + + /** + * 查询到货明细列表 + */ + List queryList(OaArrivalDetailBo bo); + + /** + * 新增到货明细 + */ + Boolean insertByBo(OaArrivalDetailBo bo); + + /** + * 修改到货明细 + */ + Boolean updateByBo(OaArrivalDetailBo bo); + + /** + * 校验并批量删除到货明细信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaMeetingMinutesService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaMeetingMinutesService.java new file mode 100644 index 0000000..9cb6685 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaMeetingMinutesService.java @@ -0,0 +1,24 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; + +import java.util.Collection; +import java.util.List; + +public interface IOaMeetingMinutesService { + + OaMeetingMinutesVo queryById(Long id); + + TableDataInfo queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery); + + List queryList(OaMeetingMinutesBo bo); + + Boolean insertByBo(OaMeetingMinutesBo bo); + + Boolean updateByBo(OaMeetingMinutesBo bo); + + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java new file mode 100644 index 0000000..559cec3 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java @@ -0,0 +1,342 @@ +package com.ruoyi.oa.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.oa.aireview.DocumentParseUtil; +import com.ruoyi.oa.aireview.MiMoClient; +import com.ruoyi.oa.aireview.MiMoProperties; +import com.ruoyi.oa.domain.OaAiReview; +import com.ruoyi.oa.domain.bo.OaAiReviewBo; +import com.ruoyi.oa.domain.vo.OaAiReviewVo; +import com.ruoyi.oa.mapper.OaAiReviewMapper; +import com.ruoyi.oa.service.IOaAiReviewService; +import com.ruoyi.system.domain.vo.SysOssVo; +import com.ruoyi.system.service.ISysOssService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.File; +import java.nio.file.Files; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * AI 审核(合同 / 简历) + * + * @author wangyu + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OaAiReviewServiceImpl implements IOaAiReviewService { + + private final OaAiReviewMapper baseMapper; + private final MiMoClient miMoClient; + private final MiMoProperties miMoProps; + private final ISysOssService ossService; + + /** 文字内容少于该长度,认为是扫描件/图片版,PDF 走多模态 */ + private static final int MIN_TEXT_LEN = 30; + /** 文档过长时截断,避免 prompt 过大 */ + private static final int MAX_TEXT_LEN = 24000; + + private static final Pattern SCORE_PATTERN = Pattern.compile("匹配度评分[::\\s]*([0-9]{1,3})"); + private static final Pattern RISK_PATTERN = Pattern.compile("风险评级[::\\s]*([高中低])"); + + @Override + public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) { + Prepared p = prepareSync(file, reviewType, position); + buildPrompt(p); + String result = p.images != null + ? miMoClient.chatMultimodal(p.system, p.userText, p.images) + : miMoClient.chatText(p.system, p.userText); + // 留存原件 + SysOssVo oss = uploadQuietly(file); + OaAiReview entity = persist(p, result, oss, currentUsername()); + return baseMapper.selectVoById(entity.getId()); + } + + @Override + public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) { + // 只在同步阶段做轻量校验 + 读取字节(multipart 必须在请求线程内消费); + // 文档解析、渲染、大模型调用全部放到异步线程,任何异常都以 SSE error 事件返回, + // 避免在返回 SseEmitter 之前抛异常被全局处理器包成 JSON(前端按流读取会“静默失败”)。 + Prepared p = prepareSync(file, reviewType, position); + String username = currentUsername(); + long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L; + SseEmitter emitter = new SseEmitter(timeoutMs); + + Thread worker = new Thread(() -> { + try { + // 立即推送一个 start 事件,确认通道已打开(前端据此显示“已连接”) + emitter.send(event("start", null)); + // 解析文档 + 构建提示词(可能抛异常,此处会转成 SSE error) + buildPrompt(p); + + String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> { + try { + Map ev = new HashMap<>(); + ev.put("type", kind); // reasoning / content + ev.put("c", chunk); + emitter.send(ev); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + SysOssVo oss = uploadQuietly(p.fileName, p.bytes); + OaAiReview entity = persist(p, result, oss, username); + + Map done = new HashMap<>(); + done.put("type", "done"); + done.put("id", entity.getId()); + done.put("matchScore", entity.getMatchScore()); + done.put("riskLevel", entity.getRiskLevel()); + emitter.send(done); + emitter.complete(); + } catch (Exception e) { + log.error("流式审核失败", e); + try { + Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e; + emitter.send(event("error", cause.getMessage())); + } catch (Exception ignored) {} + emitter.complete(); + } + }); + worker.setDaemon(true); + worker.setName("ai-review-stream"); + worker.start(); + return emitter; + } + + private Map event(String type, String msg) { + Map ev = new HashMap<>(); + ev.put("type", type); + if (msg != null) ev.put("msg", msg); + return ev; + } + + /** 同步阶段:校验 + 读取字节(不解析,快) */ + private Prepared prepareSync(MultipartFile file, String reviewType, String position) { + if (file == null || file.isEmpty()) { + throw new ServiceException("请上传文件"); + } + if (!"contract".equals(reviewType) && !"resume".equals(reviewType)) { + throw new ServiceException("审核类型不正确"); + } + String fileName = file.getOriginalFilename(); + if (StringUtils.isBlank(fileName)) { + throw new ServiceException("文件名为空"); + } + String lower = fileName.toLowerCase(); + if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) { + throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件"); + } + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (Exception e) { + throw new ServiceException("读取文件失败"); + } + Prepared p = new Prepared(); + p.reviewType = reviewType; + p.position = position; + p.fileName = fileName; + p.bytes = bytes; + return p; + } + + /** 解析文档 + 构建提示词(耗时/可能抛异常的部分) */ + private void buildPrompt(Prepared p) { + p.system = "contract".equals(p.reviewType) ? contractSystemPrompt() : resumeSystemPrompt(p.position); + String text = truncate(DocumentParseUtil.extractText(p.fileName, p.bytes)); + if (StringUtils.length(text) >= MIN_TEXT_LEN) { + p.userText = "contract".equals(p.reviewType) + ? "以下是待审核的合同全文:\n\n" + text + : "以下是待评估的简历内容:" + + (StringUtils.isNotBlank(p.position) ? "(目标岗位:" + p.position + ")" : "") + "\n\n" + text; + p.images = null; + } else if (DocumentParseUtil.isPdf(p.fileName)) { + p.images = DocumentParseUtil.renderPdfImages(p.bytes, miMoProps.getMaxImagePages()); + p.userText = "contract".equals(p.reviewType) + ? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。" + : "请评估以下图片中的简历(扫描件)。" + + (StringUtils.isNotBlank(p.position) ? "目标岗位:" + p.position + "。" : ""); + } else { + throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF"); + } + } + + /** 落库(结论解析、摘要、创建人) */ + private OaAiReview persist(Prepared p, String result, SysOssVo oss, String username) { + OaAiReview entity = new OaAiReview(); + entity.setReviewType(p.reviewType); + entity.setFileName(p.fileName); + if (oss != null) { + entity.setOssId(oss.getOssId()); + entity.setFileUrl(oss.getUrl()); + } + entity.setPosition(p.position); + entity.setResultMd(result); + entity.setSummary(buildSummary(result)); + entity.setModel(miMoProps.getModel()); + if ("resume".equals(p.reviewType)) { + entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100)); + } else { + entity.setRiskLevel(parseStr(RISK_PATTERN, result)); + } + if (StringUtils.isNotBlank(username)) { + entity.setCreateBy(username); + } + baseMapper.insert(entity); + return entity; + } + + private String currentUsername() { + try { + return LoginHelper.getLoginUser() != null ? LoginHelper.getLoginUser().getUsername() : null; + } catch (Exception e) { + return null; + } + } + + private SysOssVo uploadQuietly(MultipartFile file) { + try { + return ossService.upload(file, 0L); + } catch (Exception e) { + log.warn("AI审核原件留存失败:{}", file.getOriginalFilename(), e); + return null; + } + } + + private SysOssVo uploadQuietly(String fileName, byte[] bytes) { + File tmp = null; + try { + String suffix = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : ""; + tmp = File.createTempFile("aireview", suffix); + Files.write(tmp.toPath(), bytes); + return ossService.upload(tmp, fileName, 0L); + } catch (Exception e) { + log.warn("AI审核原件留存失败:{}", fileName, e); + return null; + } finally { + if (tmp != null && tmp.exists()) { + try { tmp.delete(); } catch (Exception ignored) {} + } + } + } + + /** 解析后的待审核数据 */ + private static class Prepared { + String reviewType; + String position; + String fileName; + byte[] bytes; + String system; + String userText; + List images; + } + + private String truncate(String text) { + if (text == null) return ""; + return text.length() > MAX_TEXT_LEN ? text.substring(0, MAX_TEXT_LEN) : text; + } + + /** 从 Markdown 结果里提炼一段纯文本摘要,供列表展示 */ + private String buildSummary(String md) { + if (StringUtils.isBlank(md)) return null; + String text = md.replaceAll("(?m)^#+\\s*", "") // 标题符号 + .replaceAll("[*`>#\\-]", " ") // markdown 符号 + .replaceAll("\\s+", " ") // 折叠空白 + .trim(); + return text.length() > 160 ? text.substring(0, 160) : text; + } + + private Integer parseInt(Pattern p, String text, int max) { + Matcher m = p.matcher(text == null ? "" : text); + if (m.find()) { + try { + int v = Integer.parseInt(m.group(1)); + if (v >= 0 && v <= max) return v; + } catch (NumberFormatException ignored) {} + } + return null; + } + + private String parseStr(Pattern p, String text) { + Matcher m = p.matcher(text == null ? "" : text); + return m.find() ? m.group(1) : null; + } + + private String contractSystemPrompt() { + return "你是德睿福成套设备有限公司聘请的资深合同法务与商务谈判顾问。" + + "你的立场是【最大化保护并争取“我方”(德睿福)的利益】——审查时始终站在我方角度," + + "识别对我方不利的安排,并提出利好我方的修改与补充建议。\n" + + "请用简体中文、Markdown 格式输出审核报告,包含以下小节(用二级标题):\n" + + "## 一、合同概要\n(合同类型、双方主体、标的、金额、期限等关键信息)\n" + + "## 二、总体风险评级\n(必须单独一行明确写出:`风险评级:高` 或 `风险评级:中` 或 `风险评级:低`,再附简要理由)\n" + + "## 三、对我方不利/存在风险的条款\n(逐条列出:① 原文摘录 ② 风险说明 ③ 利好我方的修改建议)\n" + + "## 四、建议补充的利好我方条款\n(如违约金、付款节点与比例、质保与验收、知识产权归属、保密、不可抗力、争议解决地与管辖等)\n" + + "## 五、关键条款审查\n(付款、交付、验收、违约责任、责任限制/赔偿上限、解除权等)\n" + + "## 六、谈判要点与优先级\n(按重要性排序,给出可直接用于谈判的要点)\n" + + "注意:若合同中我方实为乙方/供方,请据实判断我方身份后仍以争取我方利益为目标。"; + } + + private String resumeSystemPrompt(String position) { + String posLine = StringUtils.isNotBlank(position) + ? "目标岗位为【" + position + "】,请重点评估候选人与该岗位的匹配度。\n" + : "未指定目标岗位,请综合判断其最适合的岗位方向并据此评估。\n"; + return "你是德睿福成套设备有限公司的资深招聘官与技术面试官。" + posLine + + "请用简体中文、Markdown 格式输出评估报告,包含以下小节(用二级标题):\n" + + "## 一、候选人概要\n(姓名、工作年限、学历、当前/最近岗位、期望等,能提取则填)\n" + + "## 二、岗位匹配度\n(必须单独一行明确写出:`匹配度评分:NN`,NN 为 0-100 的整数;随后给出评分理由," + + "结合岗位所需的技能、经验、行业背景逐点对照)\n" + + "## 三、核心优势\n" + + "## 四、短板与风险点\n(经历空窗、跳槽频繁、技能缺口等)\n" + + "## 五、建议重点考察的面试问题\n(针对其经历与岗位要求,给出 5 个左右有针对性的问题)\n" + + "## 六、录用建议\n(明确给出:建议录用 / 谨慎考虑 / 不建议,并说明理由)"; + } + + @Override + public TableDataInfo queryPageList(OaAiReviewBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + if (bo != null) { + lqw.eq(StringUtils.isNotBlank(bo.getReviewType()), OaAiReview::getReviewType, bo.getReviewType()); + if (StringUtils.isNotBlank(bo.getKeyword())) { + String kw = bo.getKeyword().trim(); + lqw.and(w -> w.like(OaAiReview::getFileName, kw).or().like(OaAiReview::getPosition, kw)); + } + } + lqw.orderByDesc(OaAiReview::getCreateTime); + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + // 列表只需 summary,清空大字段 resultMd 减小响应体 + if (page.getRecords() != null) { + page.getRecords().forEach(v -> v.setResultMd(null)); + } + return TableDataInfo.build(page); + } + + @Override + public OaAiReviewVo queryById(Long id) { + OaAiReviewVo vo = baseMapper.selectVoById(id); + if (vo == null) throw new ServiceException("记录不存在或已删除"); + return vo; + } + + @Override + public Boolean deleteByIds(Collection ids) { + return baseMapper.deleteBatchIds(ids) > 0; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java new file mode 100644 index 0000000..541b342 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaArrivalDetailServiceImpl.java @@ -0,0 +1,239 @@ +package com.ruoyi.oa.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.core.domain.PageQuery; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import com.ruoyi.oa.domain.bo.OaArrivalDetailBo; +import com.ruoyi.oa.domain.vo.OaArrivalDetailVo; +import com.ruoyi.oa.domain.vo.OaRequirementsVo; +import com.ruoyi.oa.domain.vo.SysOaProjectVo; +import com.ruoyi.oa.domain.OaArrivalDetail; +import com.ruoyi.oa.domain.OaRequirements; +import com.ruoyi.oa.domain.SysOaProject; +import com.ruoyi.oa.mapper.OaArrivalDetailMapper; +import com.ruoyi.oa.mapper.OaRequirementsMapper; +import com.ruoyi.oa.mapper.SysOaProjectMapper; +import com.ruoyi.oa.service.IOaArrivalDetailService; + +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.Set; +import java.util.Collections; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 到货明细Service业务层处理 + * + * @author ruoyi + * @date 2026-06-12 + */ +@RequiredArgsConstructor +@Service +public class OaArrivalDetailServiceImpl implements IOaArrivalDetailService { + + private final OaArrivalDetailMapper baseMapper; + + private final OaRequirementsMapper requirementsMapper; + + private final SysOaProjectMapper projectMapper; + + /** + * 查询到货明细 + */ + @Override + public OaArrivalDetailVo queryById(Long detailId){ + OaArrivalDetailVo vo = baseMapper.selectVoById(detailId); + if (vo != null) { + fillRelatedVo(vo); + } + return vo; + } + + /** + * 查询到货明细列表(分页) + */ + @Override + public TableDataInfo queryPageList(OaArrivalDetailBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + // 批量填充关联名称,避免 N+1 + batchFillRelatedVos(result.getRecords()); + return TableDataInfo.build(result); + } + + /** + * 查询到货明细列表(不分页) + */ + @Override + public List queryList(OaArrivalDetailBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + List list = baseMapper.selectVoList(lqw); + // 批量填充关联名称 + batchFillRelatedVos(list); + return list; + } + + private LambdaQueryWrapper buildQueryWrapper(OaArrivalDetailBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getRequirementId() != null, OaArrivalDetail::getRequirementId, bo.getRequirementId()); + lqw.eq(bo.getProjectId() != null, OaArrivalDetail::getProjectId, bo.getProjectId()); + lqw.eq(bo.getTradeType() != null, OaArrivalDetail::getTradeType, bo.getTradeType()); + lqw.eq(StringUtils.isNotBlank(bo.getContractNo()), OaArrivalDetail::getContractNo, bo.getContractNo()); + lqw.like(StringUtils.isNotBlank(bo.getGoodsName()), OaArrivalDetail::getGoodsName, bo.getGoodsName()); + lqw.eq(bo.getQuantity() != null, OaArrivalDetail::getQuantity, bo.getQuantity()); + lqw.eq(bo.getUnitPrice() != null, OaArrivalDetail::getUnitPrice, bo.getUnitPrice()); + lqw.eq(bo.getArrivalType() != null, OaArrivalDetail::getArrivalType, bo.getArrivalType()); + lqw.eq(bo.getDeadline() != null, OaArrivalDetail::getDeadline, bo.getDeadline()); + lqw.eq(StringUtils.isNotBlank(bo.getSourceAddress()), OaArrivalDetail::getSourceAddress, bo.getSourceAddress()); + lqw.eq(StringUtils.isNotBlank(bo.getTargetAddress()), OaArrivalDetail::getTargetAddress, bo.getTargetAddress()); + lqw.eq(bo.getDetailStatus() != null, OaArrivalDetail::getDetailStatus, bo.getDetailStatus()); + lqw.eq(StringUtils.isNotBlank(bo.getDescription()), OaArrivalDetail::getDescription, bo.getDescription()); + lqw.orderByDesc(OaArrivalDetail::getCreateTime); + return lqw; + } + + /** + * 批量填充关联的需求VO和项目VO + */ + private void batchFillRelatedVos(List list) { + if (list == null || list.isEmpty()) { + return; + } + + // 收集所有需求ID + Set requirementIds = list.stream() + .map(OaArrivalDetailVo::getRequirementId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 收集所有项目ID + Set projectIds = list.stream() + .map(OaArrivalDetailVo::getProjectId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 批量查询需求完整VO + Map requirementMap = Collections.emptyMap(); + if (!requirementIds.isEmpty()) { + List reqVoList = requirementsMapper.selectVoBatchIds(requirementIds); + if (reqVoList != null) { + requirementMap = reqVoList.stream() + .collect(Collectors.toMap(OaRequirementsVo::getRequirementId, + Function.identity(), (a, b) -> a)); + } + } + + // 批量查询项目完整VO + Map projectMap = Collections.emptyMap(); + if (!projectIds.isEmpty()) { + List projVoList = projectMapper.selectVoBatchIds(projectIds); + if (projVoList != null) { + projectMap = projVoList.stream() + .collect(Collectors.toMap(SysOaProjectVo::getProjectId, + Function.identity(), (a, b) -> a)); + } + } + + // 回填完整 VO 对象 + for (OaArrivalDetailVo vo : list) { + if (vo.getRequirementId() != null) { + vo.setRequirement(requirementMap.get(vo.getRequirementId())); + } + if (vo.getProjectId() != null) { + vo.setProject(projectMap.get(vo.getProjectId())); + } + } + } + + /** + * 单条填充关联VO + */ + private void fillRelatedVo(OaArrivalDetailVo vo) { + if (vo.getRequirementId() != null) { + vo.setRequirement(requirementsMapper.selectVoById(vo.getRequirementId())); + } + if (vo.getProjectId() != null) { + vo.setProject(projectMapper.selectVoById(vo.getProjectId())); + } + } + + /** + * 新增到货明细 + */ + @Override + public Boolean insertByBo(OaArrivalDetailBo bo) { + // 如果前端传了需求ID,自动查询需求表获取项目ID,再查项目表获取贸易类型 + autoFillByRequirement(bo); + + OaArrivalDetail add = BeanUtil.toBean(bo, OaArrivalDetail.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setDetailId(add.getDetailId()); + } + return flag; + } + + /** + * 根据需求ID自动填充项目ID和贸易类型 + */ + private void autoFillByRequirement(OaArrivalDetailBo bo) { + if (bo.getRequirementId() == null) { + return; + } + // 查询需求表获取项目ID + OaRequirements requirement = requirementsMapper.selectById(bo.getRequirementId()); + if (requirement == null) { + return; + } + Long projectId = requirement.getProjectId(); + if (projectId != null && bo.getProjectId() == null) { + bo.setProjectId(projectId); + } + // 查询项目表获取贸易类型(仅当未设置时) + if (bo.getTradeType() == null && projectId != null) { + SysOaProject project = projectMapper.selectById(projectId); + if (project != null && project.getTradeType() != null) { + bo.setTradeType(project.getTradeType().intValue()); + } + } + } + + /** + * 修改到货明细 + */ + @Override + public Boolean updateByBo(OaArrivalDetailBo bo) { + OaArrivalDetail update = BeanUtil.toBean(bo, OaArrivalDetail.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(OaArrivalDetail entity){ + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除到货明细 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if(isValid){ + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java new file mode 100644 index 0000000..a3be2b2 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaMeetingMinutesServiceImpl.java @@ -0,0 +1,327 @@ +package com.ruoyi.oa.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.RandomUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.oa.domain.OaMeetingMinutes; +import com.ruoyi.oa.domain.SysOaProject; +import com.ruoyi.oa.domain.SysOaTask; +import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo; +import com.ruoyi.oa.domain.bo.SysOaTaskBo; +import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo; +import com.ruoyi.oa.mapper.OaMeetingMinutesMapper; +import com.ruoyi.oa.mapper.SysOaProjectMapper; +import com.ruoyi.oa.mapper.SysOaTaskMapper; +import com.ruoyi.oa.service.IOaMeetingMinutesService; +import com.ruoyi.oa.service.ISysOaTaskService; +import com.ruoyi.system.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 会议纪要 + * + * 待办同步说明:tasks_json 中"有负责人且有内容"的条目会通过 ISysOaTaskService + * 生成/更新 sys_oa_task(复用任务模块的操作日志、IM 通知逻辑),任务ID回写进 JSON。 + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService { + + private final OaMeetingMinutesMapper baseMapper; + private final SysOaProjectMapper projectMapper; + private final SysOaTaskMapper taskMapper; + private final ISysOaTaskService taskService; + private final SysUserMapper userMapper; + private final ObjectMapper json = new ObjectMapper(); + + /** sys_oa_task.state:0执行中 2执行完成(1等待验收/15延期申请由任务模块流转,会议页不使用) */ + private static final Long TASK_STATE_DOING = 0L; + private static final Long TASK_STATE_DONE = 2L; + + @Override + public OaMeetingMinutesVo queryById(Long id) { + OaMeetingMinutesVo vo = baseMapper.selectVoById(id); + if (vo == null) { + throw new ServiceException("会议纪要不存在或已删除"); + } + enrich(Collections.singletonList(vo)); + return vo; + } + + @Override + public TableDataInfo queryPageList(OaMeetingMinutesBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + enrich(result.getRecords()); + return TableDataInfo.build(result); + } + + @Override + public List queryList(OaMeetingMinutesBo bo) { + List list = baseMapper.selectVoList(buildQueryWrapper(bo)); + enrich(list); + return list; + } + + private LambdaQueryWrapper buildQueryWrapper(OaMeetingMinutesBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + if (bo != null) { + lqw.eq(bo.getProjectId() != null, OaMeetingMinutes::getProjectId, bo.getProjectId()); + lqw.eq(StringUtils.isNotBlank(bo.getMeetingType()), OaMeetingMinutes::getMeetingType, bo.getMeetingType()); + if (StringUtils.isNotBlank(bo.getKeyword())) { + String kw = bo.getKeyword().trim(); + lqw.and(w -> w.like(OaMeetingMinutes::getSubject, kw) + .or().like(OaMeetingMinutes::getLocation, kw) + .or().like(OaMeetingMinutes::getMeetingCode, kw)); + } + lqw.ge(bo.getDateFrom() != null, OaMeetingMinutes::getMeetingDate, bo.getDateFrom()); + lqw.le(bo.getDateTo() != null, OaMeetingMinutes::getMeetingDate, bo.getDateTo()); + } + lqw.orderByDesc(OaMeetingMinutes::getMeetingDate) + .orderByDesc(OaMeetingMinutes::getCreateTime); + return lqw; + } + + /** 填充 项目名/编号、主持人/参会人/待办负责人 昵称 */ + private void enrich(List list) { + if (list == null || list.isEmpty()) return; + Set projectIds = new HashSet<>(); + Set userIds = new HashSet<>(); + Map taskNodes = new HashMap<>(); + for (OaMeetingMinutesVo v : list) { + if (v == null) continue; + if (v.getProjectId() != null) projectIds.add(v.getProjectId()); + if (v.getHostUserId() != null) userIds.add(v.getHostUserId()); + userIds.addAll(parseLongCsv(v.getAttendeeUserIds())); + ArrayNode arr = parseTaskArray(v.getTasksJson()); + if (arr != null) { + taskNodes.put(v.getId(), arr); + for (JsonNode n : arr) { + Long uid = longOf(n, "assigneeUserId"); + if (uid != null) userIds.add(uid); + } + } + } + Map pMap = projectIds.isEmpty() ? Collections.emptyMap() + : projectMapper.selectList(new QueryWrapper().in("project_id", projectIds)) + .stream().collect(Collectors.toMap(SysOaProject::getProjectId, p -> p, (a, b) -> a)); + Map uMap = userIds.isEmpty() ? Collections.emptyMap() + : userMapper.selectList(new QueryWrapper().in("user_id", userIds)) + .stream().collect(Collectors.toMap(SysUser::getUserId, u -> u, (a, b) -> a)); + + for (OaMeetingMinutesVo v : list) { + if (v == null) continue; + if (v.getProjectId() != null) { + SysOaProject p = pMap.get(v.getProjectId()); + if (p != null) { + v.setProjectName(p.getProjectName()); + v.setProjectNum(p.getProjectNum()); + } + } + if (v.getHostUserId() != null) { + SysUser u = uMap.get(v.getHostUserId()); + if (u != null) v.setHostUserName(u.getNickName()); + } + List names = new ArrayList<>(); + for (Long uid : parseLongCsv(v.getAttendeeUserIds())) { + SysUser u = uMap.get(uid); + if (u != null) names.add(u.getNickName()); + } + if (!names.isEmpty()) v.setAttendeeUserNames(String.join(",", names)); + // 待办条目写入 assigneeName,前端展示/导出用 + ArrayNode arr = taskNodes.get(v.getId()); + if (arr != null) { + for (JsonNode n : arr) { + if (!n.isObject()) continue; + Long uid = longOf(n, "assigneeUserId"); + SysUser u = uid == null ? null : uMap.get(uid); + ((ObjectNode) n).put("assigneeName", u == null ? null : u.getNickName()); + } + try { + v.setTasksJson(json.writeValueAsString(arr)); + } catch (Exception e) { + log.warn("会议纪要[{}] tasksJson 序列化失败", v.getId(), e); + } + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(OaMeetingMinutesBo bo) { + OaMeetingMinutes add = BeanUtil.toBean(bo, OaMeetingMinutes.class); + add.setId(null); + // 时间戳+3位随机数,避免同一秒并发保存撞唯一键 + add.setMeetingCode("MT-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + + RandomUtil.randomNumbers(3)); + if (StringUtils.isBlank(add.getMeetingType())) add.setMeetingType("other"); + if (add.getSyncTask() == null) add.setSyncTask(1); + validBeforeSave(add); + boolean ok = baseMapper.insert(add) > 0; + if (ok) { + bo.setId(add.getId()); + if (Integer.valueOf(1).equals(add.getSyncTask())) { + String updated = syncTasks(add); + if (updated != null && !updated.equals(add.getTasksJson())) { + patchTasksJson(add.getId(), updated); + } + } + } + return ok; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(OaMeetingMinutesBo bo) { + if (bo.getId() == null) throw new ServiceException("缺少纪要ID"); + OaMeetingMinutes old = baseMapper.selectById(bo.getId()); + if (old == null) throw new ServiceException("会议纪要不存在或已删除"); + OaMeetingMinutes upd = BeanUtil.toBean(bo, OaMeetingMinutes.class); + if (StringUtils.isBlank(upd.getMeetingType())) upd.setMeetingType("other"); + validBeforeSave(upd); + if (Integer.valueOf(1).equals(upd.getSyncTask())) { + upd.setTasksJson(syncTasks(upd)); + } + // 可清空字段(解绑项目、清空人员/内容等)显式 set,避免 MyBatis-Plus 忽略 null 导致清空不生效 + LambdaUpdateWrapper luw = Wrappers.lambdaUpdate() + .set(OaMeetingMinutes::getProjectId, upd.getProjectId()) + .set(OaMeetingMinutes::getHostUserId, upd.getHostUserId()) + .set(OaMeetingMinutes::getAttendeeUserIds, upd.getAttendeeUserIds()) + .set(OaMeetingMinutes::getLocation, upd.getLocation()) + .set(OaMeetingMinutes::getTopic, upd.getTopic()) + .set(OaMeetingMinutes::getDiscussion, upd.getDiscussion()) + .set(OaMeetingMinutes::getDecision, upd.getDecision()) + .set(OaMeetingMinutes::getTasksJson, upd.getTasksJson()) + .eq(OaMeetingMinutes::getId, upd.getId()); + OaMeetingMinutes entity = new OaMeetingMinutes(); + entity.setMeetingDate(upd.getMeetingDate()); + entity.setMeetingType(upd.getMeetingType()); + entity.setSubject(upd.getSubject()); + entity.setSyncTask(upd.getSyncTask()); + return baseMapper.update(entity, luw) > 0; + } + + private void patchTasksJson(Long id, String tasksJson) { + baseMapper.update(null, Wrappers.lambdaUpdate() + .set(OaMeetingMinutes::getTasksJson, tasksJson) + .eq(OaMeetingMinutes::getId, id)); + } + + private void validBeforeSave(OaMeetingMinutes e) { + if (e.getMeetingDate() == null) throw new ServiceException("请选择会议日期"); + if (StringUtils.isBlank(e.getSubject())) throw new ServiceException("请输入会议主题"); + } + + /** + * 将待办同步到 sys_oa_task(通过任务服务,带操作日志和 IM 通知): + * - 无内容或无负责人的条目仅作纪要记录,不生成任务 + * - 已有 taskId 且任务仍存在 → 更新;否则新建并把 taskId 写回 JSON + * - 同步失败不阻塞纪要保存,仅记录日志 + */ + private String syncTasks(OaMeetingMinutes meeting) { + if (StringUtils.isBlank(meeting.getTasksJson())) return meeting.getTasksJson(); + try { + JsonNode root = json.readTree(meeting.getTasksJson()); + if (!root.isArray()) return meeting.getTasksJson(); + ArrayNode arr = (ArrayNode) root; + for (JsonNode n : arr) { + if (!n.isObject()) continue; + ObjectNode o = (ObjectNode) n; + String content = textOf(o, "content"); + Long assignee = longOf(o, "assigneeUserId"); + if (StringUtils.isBlank(content) || assignee == null) continue; + + String status = textOf(o, "status"); + boolean done = "done".equals(status); + Long existTaskId = longOf(o, "taskId"); + SysOaTask exist = existTaskId == null ? null : taskMapper.selectById(existTaskId); + + SysOaTaskBo t = new SysOaTaskBo(); + t.setProjectId(meeting.getProjectId()); + t.setTaskTitle(StringUtils.substring(content, 0, 200)); + t.setContent("来自会议纪要「" + meeting.getSubject() + "」"); + t.setFinishTime(parseDay(textOf(o, "deadline"))); + t.setState(done ? TASK_STATE_DONE : TASK_STATE_DOING); + if (done) t.setCompletedTime(new Date()); + if (exist == null) { + t.setBeginTime(meeting.getMeetingDate()); + t.setWorkerIds(String.valueOf(assignee)); + t.setStatus(0L); + taskService.insertByBo(t); + if (t.getTaskId() != null) o.put("taskId", t.getTaskId()); + } else { + t.setTaskId(existTaskId); + t.setWorkerId(assignee); + taskService.updateByBo(t); + } + } + return json.writeValueAsString(arr); + } catch (Exception e) { + log.warn("会议纪要[{}]待办同步OA任务失败", meeting.getMeetingCode(), e); + return meeting.getTasksJson(); + } + } + + private Date parseDay(String s) { + if (StringUtils.isBlank(s)) return null; + try { return new SimpleDateFormat("yyyy-MM-dd").parse(s); } + catch (Exception e) { return null; } + } + + private ArrayNode parseTaskArray(String s) { + if (StringUtils.isBlank(s)) return null; + try { + JsonNode root = json.readTree(s); + return root.isArray() ? (ArrayNode) root : null; + } catch (Exception e) { return null; } + } + + private String textOf(JsonNode n, String k) { + JsonNode v = n.get(k); + return v == null || v.isNull() ? null : v.asText(); + } + + private Long longOf(JsonNode n, String k) { + JsonNode v = n.get(k); + if (v == null || v.isNull()) return null; + try { return v.isNumber() ? v.asLong() : Long.parseLong(v.asText()); } + catch (NumberFormatException e) { return null; } + } + + private List parseLongCsv(String csv) { + if (StringUtils.isBlank(csv)) return Collections.emptyList(); + List r = new ArrayList<>(); + for (String s : csv.split(",")) { + String t = s.trim(); + if (t.isEmpty()) continue; + try { r.add(Long.parseLong(t)); } catch (NumberFormatException ignored) {} + } + return r; + } + + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + return baseMapper.deleteBatchIds(ids) > 0; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java index 6fed139..f4197a1 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/SysOaTaskServiceImpl.java @@ -217,6 +217,8 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService { add.setOriginFinishTime(add.getFinishTime()); add.setWorkerId(workerId); flag = baseMapper.insert(add) > 0; + // 回填任务ID,供调用方(如会议纪要待办同步)建立关联 + bo.setTaskId(add.getTaskId()); if (flag) { operationLogService.recordLog(add.getProjectId(), 3, add.getTaskId(), add.getTaskTitle(), 1, "新增任务: " + add.getTaskTitle(), null, null); diff --git a/ruoyi-oa/src/main/resources/mapper/oa/OaArrivalDetailMapper.xml b/ruoyi-oa/src/main/resources/mapper/oa/OaArrivalDetailMapper.xml new file mode 100644 index 0000000..0fda017 --- /dev/null +++ b/ruoyi-oa/src/main/resources/mapper/oa/OaArrivalDetailMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-ui/src/api/oa/aiReview.js b/ruoyi-ui/src/api/oa/aiReview.js new file mode 100644 index 0000000..f3cf709 --- /dev/null +++ b/ruoyi-ui/src/api/oa/aiReview.js @@ -0,0 +1,28 @@ +import request from '@/utils/request' + +/** + * 上传合同/简历进行 AI 审核 + * @param {FormData} data 包含 file, reviewType(contract|resume), position(可选) + */ +export function analyzeAiReview (data) { + return request({ + url: '/oa/aiReview/analyze', + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data' }, + // 推理模型 + 长文档,单次可能较慢,放宽到 5 分钟 + timeout: 300000 + }) +} + +export function listAiReview (query) { + return request({ url: '/oa/aiReview/list', method: 'get', params: query }) +} + +export function getAiReview (id) { + return request({ url: '/oa/aiReview/' + id, method: 'get' }) +} + +export function delAiReview (ids) { + return request({ url: '/oa/aiReview/' + ids, method: 'delete' }) +} diff --git a/ruoyi-ui/src/api/oa/arrivalDetail.js b/ruoyi-ui/src/api/oa/arrivalDetail.js new file mode 100644 index 0000000..06b5f78 --- /dev/null +++ b/ruoyi-ui/src/api/oa/arrivalDetail.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 查询到货明细列表 +export function listArrivalDetail(query) { + return request({ + url: '/oa/arrivalDetail/list', + method: 'get', + params: query + }) +} + +// 查询到货明细详细 +export function getArrivalDetail(detailId) { + return request({ + url: '/oa/arrivalDetail/' + detailId, + method: 'get' + }) +} + +// 新增到货明细 +export function addArrivalDetail(data) { + return request({ + url: '/oa/arrivalDetail', + method: 'post', + data: data + }) +} + +// 修改到货明细 +export function updateArrivalDetail(data) { + return request({ + url: '/oa/arrivalDetail', + method: 'put', + data: data + }) +} + +// 删除到货明细 +export function delArrivalDetail(detailId) { + return request({ + url: '/oa/arrivalDetail/' + detailId, + method: 'delete' + }) +} diff --git a/ruoyi-ui/src/api/oa/meetingMinutes.js b/ruoyi-ui/src/api/oa/meetingMinutes.js new file mode 100644 index 0000000..63f1a2b --- /dev/null +++ b/ruoyi-ui/src/api/oa/meetingMinutes.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export function listMeetingMinutes (query) { + return request({ url: '/oa/meetingMinutes/list', method: 'get', params: query }) +} + +export function getMeetingMinutes (id) { + return request({ url: '/oa/meetingMinutes/' + id, method: 'get' }) +} + +export function addMeetingMinutes (data) { + return request({ url: '/oa/meetingMinutes', method: 'post', data }) +} + +export function updateMeetingMinutes (data) { + return request({ url: '/oa/meetingMinutes', method: 'put', data }) +} + +export function delMeetingMinutes (ids) { + return request({ url: '/oa/meetingMinutes/' + ids, method: 'delete' }) +} diff --git a/ruoyi-ui/src/assets/styles/index.scss b/ruoyi-ui/src/assets/styles/index.scss index 341ad29..2f078b1 100644 --- a/ruoyi-ui/src/assets/styles/index.scss +++ b/ruoyi-ui/src/assets/styles/index.scss @@ -65,6 +65,11 @@ body { .el-range-editor .el-range-separator { line-height: 24px !important; font-size: 12px !important; } .el-input__icon { line-height: 26px !important; } .el-input__suffix-inner .el-input__icon { line-height: 26px !important; } +/* 上面的 padding: 0 8px !important 会盖掉 element 给带图标输入框预留的 30px, + 导致占位文字/内容压在前后缀图标上(如日期选择器、带搜索图标的输入框),这里补回图标位 */ +.el-input--prefix .el-input__inner { padding-left: 28px !important; } +.el-input--suffix .el-input__inner { padding-right: 28px !important; } +.el-input--mini .el-input__icon { line-height: 22px !important; } .el-form-item__content { line-height: 26px; } /* 按钮:默认 26px 高,mini 22px,medium 28px */ diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js index 21008e7..d4bee6f 100644 --- a/ruoyi-ui/src/router/index.js +++ b/ruoyi-ui/src/router/index.js @@ -160,6 +160,37 @@ export const constantRoutes = [ }, ], }, + { + path: "/hint", + component: Layout, + hidden: true, + children: [ + { + path: "meeting/add", + component: () => import("@/views/oa/meeting/edit"), + name: "addMeetingMinutes", + meta: { title: "新增会议纪要", activeMenu: "/hint/meeting" }, + }, + { + path: "meeting/edit/:id(\\d+)", + component: () => import("@/views/oa/meeting/edit"), + name: "editMeetingMinutes", + meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" }, + }, + { + path: "aiReview/add", + component: () => import("@/views/oa/aiReview/add"), + name: "aiReviewAdd", + meta: { title: "新增审核", activeMenu: "/hint/aiReview" }, + }, + { + path: "aiReview/detail/:id(\\d+)", + component: () => import("@/views/oa/aiReview/detail"), + name: "aiReviewDetail", + meta: { title: "审核详情", activeMenu: "/hint/aiReview" }, + }, + ], + }, { path: "/claim", component: Layout, diff --git a/ruoyi-ui/src/views/oa/aiReview/add.vue b/ruoyi-ui/src/views/oa/aiReview/add.vue new file mode 100644 index 0000000..8455cc6 --- /dev/null +++ b/ruoyi-ui/src/views/oa/aiReview/add.vue @@ -0,0 +1,312 @@ +