推送项目重构代码

This commit is contained in:
2026-05-29 19:52:32 +08:00
parent 95141d0e1f
commit 3dafaceef2
65 changed files with 3762 additions and 583 deletions

View File

@@ -0,0 +1,50 @@
package com.ruoyi.oa.audit;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.oa.audit.mapper.OaWarehouseAuditLogMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 库房操作日志
*
* @author wangyu
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/oa/warehouseAudit")
public class OaWarehouseAuditController extends BaseController {
private final OaWarehouseAuditLogMapper mapper;
@GetMapping("/list")
public TableDataInfo<OaWarehouseAuditLog> list(OaWarehouseAuditLog query, PageQuery pageQuery) {
LambdaQueryWrapper<OaWarehouseAuditLog> qw = Wrappers.lambdaQuery();
if (query.getOpType() != null && !query.getOpType().isEmpty()) {
qw.eq(OaWarehouseAuditLog::getOpType, query.getOpType());
}
if (query.getRefType() != null && !query.getRefType().isEmpty()) {
qw.eq(OaWarehouseAuditLog::getRefType, query.getRefType());
}
if (query.getRefId() != null) {
qw.eq(OaWarehouseAuditLog::getRefId, query.getRefId());
}
if (query.getProjectId() != null) {
qw.eq(OaWarehouseAuditLog::getProjectId, query.getProjectId());
}
if (query.getWarehouseId() != null) {
qw.eq(OaWarehouseAuditLog::getWarehouseId, query.getWarehouseId());
}
if (query.getOpUserId() != null) {
qw.eq(OaWarehouseAuditLog::getOpUserId, query.getOpUserId());
}
qw.orderByDesc(OaWarehouseAuditLog::getOpTime);
return TableDataInfo.build(mapper.selectPage(pageQuery.build(), qw));
}
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.oa.audit;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 库房统一操作日志 sys_oa_warehouse_audit_log
*
* @author wangyu
*/
@Data
@TableName("sys_oa_warehouse_audit_log")
public class OaWarehouseAuditLog {
@TableId(value = "log_id")
private Long logId;
/** 操作类型,见 {@link OpType} */
private String opType;
/** 关联实体类型requirement/task/master/warehouse */
private String refType;
/** 关联实体 ID */
private Long refId;
private Long projectId;
private Long warehouseId;
private String summary;
private String beforeJson;
private String afterJson;
private Long opUserId;
private String opUserName;
private Date opTime;
private String remark;
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.oa.audit;
import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.oa.audit.mapper.OaWarehouseAuditLogMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 业务侧统一打点入口。
* 所有调用异步执行,失败不影响主业务。
*
* @author wangyu
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OaWarehouseAuditService {
private final OaWarehouseAuditLogMapper mapper;
/** 最常用:单参摘要 */
@Async
public void log(String opType, String refType, Long refId, String summary) {
log(opType, refType, refId, null, null, summary, null, null);
}
/** 完整版 */
@Async
public void log(String opType, String refType, Long refId,
Long projectId, Long warehouseId,
String summary, String beforeJson, String afterJson) {
try {
OaWarehouseAuditLog entity = new OaWarehouseAuditLog();
entity.setOpType(opType);
entity.setRefType(refType);
entity.setRefId(refId);
entity.setProjectId(projectId);
entity.setWarehouseId(warehouseId);
entity.setSummary(summary == null ? "" : summary);
entity.setBeforeJson(beforeJson);
entity.setAfterJson(afterJson);
try {
entity.setOpUserId(LoginHelper.getUserId());
entity.setOpUserName(LoginHelper.getNickName());
} catch (Exception ignore) {
// 定时任务/系统调用下无登录态
entity.setOpUserName("system");
}
mapper.insert(entity);
} catch (Exception e) {
log.warn("[audit] write failed: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,30 @@
package com.ruoyi.oa.audit;
/**
* 库房操作类型枚举。值用 String便于库里直接读懂。
*
* @author wangyu
*/
public final class OpType {
private OpType() {}
public static final String REQ_CREATE = "REQ_CREATE"; // 采购需求新建
public static final String REQ_UPDATE = "REQ_UPDATE"; // 采购需求修改
public static final String REQ_DONE = "REQ_DONE"; // 采购需求完成
public static final String REQ_CANCEL = "REQ_CANCEL"; // 采购需求取消
public static final String REQ_DELETE = "REQ_DELETE"; // 采购需求删除
public static final String TASK_CREATE = "TASK_CREATE"; // 车间采购创建
public static final String TASK_DONE = "TASK_DONE"; // 车间采购完成
public static final String TASK_CANCEL = "TASK_CANCEL"; // 车间采购取消
public static final String IN = "IN"; // 入库
public static final String OUT = "OUT"; // 出库
public static final String RETURN = "RETURN"; // 退库
public static final String STOCK_ADJUST = "STOCK_ADJUST"; // 库存盘点/修正
public static final String REF_REQUIREMENT = "requirement";
public static final String REF_TASK = "task";
public static final String REF_MASTER = "master";
public static final String REF_WAREHOUSE = "warehouse";
}

View File

@@ -0,0 +1,106 @@
package com.ruoyi.oa.audit;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ruoyi.oa.domain.SysOaWarehouse;
import com.ruoyi.oa.domain.SysOaWarehouseDetail;
import com.ruoyi.oa.domain.SysOaWarehouseMaster;
import com.ruoyi.oa.mapper.SysOaWarehouseDetailMapper;
import com.ruoyi.oa.mapper.SysOaWarehouseMapper;
import com.ruoyi.oa.mapper.SysOaWarehouseMasterMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
* 查询某个采购需求关联的入库批次列表。
*
* @author wangyu
*/
@Service
@RequiredArgsConstructor
public class RequirementBatchService {
private final SysOaWarehouseMasterMapper masterMapper;
private final SysOaWarehouseDetailMapper detailMapper;
private final SysOaWarehouseMapper warehouseMapper;
public List<RequirementBatchVo> listByRequirement(Long requirementId) {
// 1. 找所有 type=1 (入库) 且 requirement_id 匹配 的 master
List<SysOaWarehouseMaster> masters = masterMapper.selectList(
Wrappers.<SysOaWarehouseMaster>lambdaQuery()
.eq(SysOaWarehouseMaster::getRequirementId, requirementId)
.eq(SysOaWarehouseMaster::getType, 1)
.eq(SysOaWarehouseMaster::getDelFlag, 0)
.orderByDesc(SysOaWarehouseMaster::getSignTime));
if (masters.isEmpty()) return Collections.emptyList();
// 2. 一次性查这些 master 的 detail
List<Long> masterIds = masters.stream()
.map(SysOaWarehouseMaster::getMasterId).collect(Collectors.toList());
List<SysOaWarehouseDetail> details = detailMapper.selectList(
Wrappers.<SysOaWarehouseDetail>lambdaQuery()
.in(SysOaWarehouseDetail::getMasterId, masterIds)
.eq(SysOaWarehouseDetail::getDelFlag, 0));
Map<Long, List<SysOaWarehouseDetail>> detailsByMaster = details.stream()
.collect(Collectors.groupingBy(SysOaWarehouseDetail::getMasterId));
// 3. 一次性查涉及的物料
Set<Long> warehouseIds = details.stream()
.map(SysOaWarehouseDetail::getWarehouseId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, SysOaWarehouse> whMap = warehouseIds.isEmpty()
? Collections.emptyMap()
: warehouseMapper.selectBatchIds(warehouseIds).stream()
.collect(Collectors.toMap(SysOaWarehouse::getId, w -> w));
// 4. 组装
List<RequirementBatchVo> result = new ArrayList<>();
for (SysOaWarehouseMaster m : masters) {
RequirementBatchVo vo = new RequirementBatchVo();
vo.setMasterId(m.getMasterId());
vo.setMasterNum(m.getMasterNum());
vo.setSignTime(m.getSignTime());
vo.setSignUser(m.getSignUser());
List<SysOaWarehouseDetail> ds = detailsByMaster.getOrDefault(m.getMasterId(), Collections.emptyList());
long totalQty = 0L;
BigDecimal totalAmount = BigDecimal.ZERO;
List<RequirementBatchVo.BatchDetailVo> dvos = new ArrayList<>();
List<String> nameParts = new ArrayList<>();
for (SysOaWarehouseDetail d : ds) {
SysOaWarehouse w = whMap.get(d.getWarehouseId());
long qty = d.getAmount() == null ? 0L : d.getAmount();
totalQty += qty;
BigDecimal unitPrice = d.getSignPrice() == null ? null : BigDecimal.valueOf(d.getSignPrice());
if (unitPrice != null) {
totalAmount = totalAmount.add(unitPrice.multiply(BigDecimal.valueOf(qty)));
}
RequirementBatchVo.BatchDetailVo dv = new RequirementBatchVo.BatchDetailVo();
dv.setDetailId(d.getId());
dv.setWarehouseId(d.getWarehouseId());
dv.setAmount(d.getAmount());
dv.setSignPrice(unitPrice);
if (w != null) {
dv.setWarehouseName(w.getName());
dv.setModel(w.getModel());
dv.setSpecifications(w.getSpecifications());
dv.setUnit(w.getUnit());
if (nameParts.size() < 3) nameParts.add(w.getName());
}
dvos.add(dv);
}
vo.setDetails(dvos);
vo.setTotalQty(totalQty);
vo.setTotalAmount(totalAmount);
String summary = String.join("", nameParts);
if (dvos.size() > 3) summary += "" + dvos.size() + "";
vo.setSummary(summary);
result.add(vo);
}
return result;
}
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.oa.audit;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
/**
* 采购需求关联的入库批次(一个需求多批到货)
*
* @author wangyu
*/
@Data
public class RequirementBatchVo {
/** 入库单 ID */
private Long masterId;
/** 入库单编号 */
private String masterNum;
/** 入库时间 */
private Date signTime;
/** 入库人 */
private String signUser;
/** 本批次物料总件数detail.amount 之和) */
private Long totalQty;
/** 本批次金额 */
private BigDecimal totalAmount;
/** 物料概览(前几个名字拼接) */
private String summary;
/** 行明细 */
private List<BatchDetailVo> details;
@Data
public static class BatchDetailVo {
private Long detailId;
private Long warehouseId;
private String warehouseName;
private String model;
private String specifications;
private String unit;
private Long amount;
private BigDecimal signPrice;
}
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.oa.audit.mapper;
import com.ruoyi.common.core.mapper.BaseMapperPlus;
import com.ruoyi.oa.audit.OaWarehouseAuditLog;
/**
* 审计日志 Mapper
* 包路径包含 .mapper被 @MapperScan("com.ruoyi.**.mapper") 自动扫描
*/
public interface OaWarehouseAuditLogMapper extends BaseMapperPlus<OaWarehouseAuditLogMapper, OaWarehouseAuditLog, OaWarehouseAuditLog> {
}

View File

@@ -38,6 +38,17 @@ public class OaRequirementsController extends BaseController {
private final IOaRequirementsService iOaRequirementsService;
private final com.ruoyi.oa.audit.RequirementBatchService requirementBatchService;
/**
* 查询采购需求关联的入库批次列表
*/
@GetMapping("/{requirementId}/batches")
public R<java.util.List<com.ruoyi.oa.audit.RequirementBatchVo>> batches(
@PathVariable Long requirementId) {
return R.ok(requirementBatchService.listByRequirement(requirementId));
}
/**
* 查询OA 需求列表
*/

View File

@@ -17,7 +17,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_oa_warehouse_log")
@TableName("_deprecated_sys_oa_warehouse_log")
public class SysOaWarehouseLog extends BaseEntity {
private static final long serialVersionUID=1L;

View File

@@ -89,5 +89,8 @@ public class OaRequirementsVo extends BaseEntity {
private String ownerNickName;
private String projectName;
/** 附件文件列表(已联查 sys_oss每项形如 "<ossId>|<originalName>|<url>",逗号分隔) */
private String accessoryFiles;
}

View File

@@ -91,4 +91,11 @@ public class SysOaWarehouseMasterVo {
private Long requirementId;
private String requirementName;
/** 物料种类数 */
private Integer itemCount;
/** 入库/出库总数量 */
private Long totalQty;
/** 物料概览GROUP_CONCAT 名称) */
private String itemsSummary;
}

View File

@@ -0,0 +1,26 @@
package com.ruoyi.oa.im;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* OA-OpenIM 用户绑定
*
* @author wangyu
*/
@Data
@TableName("sys_user_im_bind")
public class ImBind {
@TableId(value = "user_id")
private Long userId;
private String phone;
private String imUserId;
private Integer bindStatus;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,88 @@
package com.ruoyi.oa.im;
import com.ruoyi.oa.im.mapper.ImBindMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* 业务侧统一推送入口。
* 业务代码只关心 OA userId不用碰 OpenIM 概念。
*
* @author wangyu
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ImSendService {
private final ImBindMapper bindMapper;
private final OpenImClient openImClient;
/**
* 为 OA 用户绑定一个 IM 账号。若 IM 侧已有该手机号则跳过创建;否则先在 chat 注册。
* 异步执行,新建/绑定失败不影响业务。
*/
@Async
public void bindOrRegister(Long oaUserId, String phone, String nickname) {
if (oaUserId == null || phone == null || phone.isEmpty()) { return; }
try {
ImBind exist = bindMapper.selectById(oaUserId);
if (exist != null && exist.getImUserId() != null) {
return;
}
String imUserId = openImClient.registerImUser(phone, nickname);
if (imUserId == null) {
log.info("[IM] register skipped for OA user {} phone {}", oaUserId, phone);
return;
}
ImBind bind = new ImBind();
bind.setUserId(oaUserId);
bind.setPhone(phone);
bind.setImUserId(imUserId);
bind.setBindStatus(1);
bindMapper.insert(bind);
log.info("[IM] bound OA {} -> IM {}", oaUserId, imUserId);
} catch (Exception e) {
log.warn("[IM] bindOrRegister failed for OA {} : {}", oaUserId, e.getMessage());
}
}
/**
* 给 OA 用户推送一条业务通知(自定义消息)。
* 异步执行,失败不抛出(业务流程不应被推送阻塞)。
*
* @param oaUserId OA 用户ID (sys_user.user_id)
* @param title 通知标题
* @param description 通知内容(手机端通知栏展示)
* @param bizType 业务类型,如 "task" / "project" / "salary"
* @param bizId 业务主键,前端点通知后可据此跳转
* @param route 前端跳转路径(可选)
*/
@Async
public void sendToOaUser(Long oaUserId, String title, String description,
String bizType, Object bizId, String route) {
if (oaUserId == null) { return; }
try {
ImBind bind = bindMapper.selectById(oaUserId);
if (bind == null || bind.getImUserId() == null
|| (bind.getBindStatus() != null && bind.getBindStatus() == 0)) {
log.debug("[IM] user {} not bound, skip push", oaUserId);
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("bizType", bizType);
payload.put("bizId", bizId);
if (route != null) { payload.put("route", route); }
openImClient.sendCustomToUser(bind.getImUserId(), title, description, payload);
} catch (Exception e) {
log.warn("[IM] push to OA user {} failed: {}", oaUserId, e.getMessage());
}
}
}

View File

@@ -0,0 +1,220 @@
package com.ruoyi.oa.im;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* OpenIM 服务端调用封装
* <p>
* 1. 管理 admin token带缓存与过期重取<br>
* 2. 发送自定义消息OA 业务通知)
*
* @author wangyu
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenImClient {
/** 自定义消息 contentTypeOpenIM 约定 110 以上为自定义 */
public static final int CUSTOM_CONTENT_TYPE = 110;
/** 会话类型:单聊 */
public static final int SESSION_SINGLE = 1;
private final OpenImProperties props;
/** 内存缓存的 admin token过期前重取 */
private volatile String adminToken;
private volatile long adminTokenExpireAt = 0L;
/** chat 后端管理员 token 缓存 */
private volatile String chatAdminToken;
private volatile long chatAdminTokenExpireAt = 0L;
/** 获取 admin token自动续期 */
public String getAdminToken() {
long now = System.currentTimeMillis();
if (adminToken != null && now < adminTokenExpireAt - 60_000L) {
return adminToken;
}
synchronized (this) {
if (adminToken != null && now < adminTokenExpireAt - 60_000L) {
return adminToken;
}
Map<String, Object> body = new HashMap<>(2);
body.put("secret", props.getSecret());
body.put("userID", props.getAdminUserId());
JSONObject resp = postJson(props.getApiUrl() + "/auth/get_admin_token", body, null);
JSONObject data = resp.getJSONObject("data");
if (data == null) {
throw new RuntimeException("get admin token failed: " + resp);
}
adminToken = data.getString("token");
long expireSec = data.getLongValue("expireTimeSeconds");
if (expireSec <= 0) { expireSec = 7 * 24 * 3600; }
adminTokenExpireAt = now + expireSec * 1000L;
log.info("[OpenIM] admin token refreshed, expires in {}s", expireSec);
return adminToken;
}
}
/**
* 发送自定义业务消息到指定 IM userID单聊发送者为 admin
*
* @param recvImUserId 接收者 OpenIM userID
* @param title 业务标题(如"任务分派"
* @param description 简短描述(通知栏展示)
* @param payload 额外业务字段(如 taskId、route 等)
* @return true 发送成功
*/
public boolean sendCustomToUser(String recvImUserId, String title, String description,
Map<String, Object> payload) {
if (!props.isEnabled()) {
log.debug("[OpenIM] disabled, skip send to {}", recvImUserId);
return false;
}
Map<String, Object> data = new HashMap<>();
data.put("title", title);
data.put("description", description);
if (payload != null) { data.putAll(payload); }
Map<String, Object> customElem = new HashMap<>(3);
customElem.put("data", JSON.toJSONString(data));
customElem.put("description", description);
customElem.put("extension", "");
Map<String, Object> content = new HashMap<>();
content.put("customElem", customElem);
Map<String, Object> offlinePush = new HashMap<>();
offlinePush.put("title", title);
offlinePush.put("desc", description);
offlinePush.put("ex", "");
offlinePush.put("iOSPushSound", "default");
offlinePush.put("iOSBadgeCount", true);
Map<String, Object> sendMsg = new HashMap<>();
sendMsg.put("sendID", props.getNotificationSender());
sendMsg.put("recvID", recvImUserId);
sendMsg.put("senderNickname", "系统通知");
sendMsg.put("senderPlatformID", 1);
sendMsg.put("content", content);
sendMsg.put("contentType", CUSTOM_CONTENT_TYPE);
sendMsg.put("sessionType", SESSION_SINGLE);
sendMsg.put("isOnlineOnly", false);
sendMsg.put("notOfflinePush", false);
sendMsg.put("offlinePushInfo", offlinePush);
Map<String, Object> body = new HashMap<>();
body.put("sendMsg", sendMsg);
JSONObject resp = postJson(props.getApiUrl() + "/msg/send_msg", body, getAdminToken());
Integer errCode = resp.getInteger("errCode");
if (errCode != null && errCode == 0) {
return true;
}
log.warn("[OpenIM] send_msg failed: {}", resp);
return false;
}
/** chat 后端 admin token */
public String getChatAdminToken() {
long now = System.currentTimeMillis();
if (chatAdminToken != null && now < chatAdminTokenExpireAt - 60_000L) {
return chatAdminToken;
}
synchronized (this) {
if (chatAdminToken != null && now < chatAdminTokenExpireAt - 60_000L) {
return chatAdminToken;
}
Map<String, Object> body = new HashMap<>();
body.put("account", props.getChatAdminAccount());
body.put("password", md5(props.getChatAdminPassword()));
JSONObject resp = postJson(props.getChatAdminUrl() + "/account/login", body, null);
JSONObject data = resp.getJSONObject("data");
if (data == null) { throw new RuntimeException("chat admin login failed: " + resp); }
chatAdminToken = data.getString("adminToken");
// chat admin token 默认 90 天,保守取 7 天续期
chatAdminTokenExpireAt = now + 7L * 24 * 3600_000L;
return chatAdminToken;
}
}
/**
* 在 chat 后端创建一个新 IM 账号(无需短信验证)。
* 成功返回新 userID已存在返回 null。
*/
public String registerImUser(String phoneNumber, String nickname) {
if (!props.isEnabled()) { return null; }
Map<String, Object> user = new HashMap<>();
user.put("nickname", nickname == null ? phoneNumber : nickname);
user.put("areaCode", props.getDefaultAreaCode());
user.put("phoneNumber", phoneNumber);
user.put("password", md5(props.getDefaultUserPassword()));
Map<String, Object> body = new HashMap<>();
body.put("verifyCode", "666666");
body.put("deviceID", "oa-backend");
body.put("platform", 5);
body.put("autoLogin", false);
body.put("user", user);
JSONObject resp = postJson(props.getChatUrl() + "/account/register", body, getChatAdminToken());
Integer errCode = resp.getInteger("errCode");
if (errCode != null && errCode == 0) {
JSONObject data = resp.getJSONObject("data");
return data == null ? null : data.getString("userID");
}
if (errCode != null && errCode == 20004) {
log.info("[OpenIM] phone {} already registered, skip", phoneNumber);
return null;
}
log.warn("[OpenIM] register failed for phone {}: {}", phoneNumber, resp);
return null;
}
private static String md5(String text) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) { sb.append(String.format("%02x", b)); }
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("md5 failed", e);
}
}
/** 内部POST JSON */
private JSONObject postJson(String url, Map<String, Object> body, String token) {
HttpRequest req = HttpRequest.post(url)
.header("Content-Type", "application/json")
.header("operationID", UUID.randomUUID().toString())
.body(JSON.toJSONString(body))
.timeout(8000);
if (token != null) { req.header("token", token); }
try (HttpResponse resp = req.execute()) {
String text = resp.body();
if (resp.getStatus() / 100 != 2) {
log.warn("[OpenIM] http {} {} -> {}", resp.getStatus(), url, text);
}
return JSON.parseObject(text);
} catch (Exception e) {
log.error("[OpenIM] call {} failed", url, e);
throw new RuntimeException("openim call failed: " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.oa.im;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* OpenIM 配置
*
* @author wangyu
*/
@Data
@Component
@ConfigurationProperties(prefix = "openim")
public class OpenImProperties {
/** OpenIM 核心 API 地址 */
private String apiUrl = "http://49.232.154.205:10002";
/** OpenIM chat 业务 API 地址 */
private String chatUrl = "http://49.232.154.205:10008";
/** OpenIM chat 管理 API 地址注册新用户、admin login */
private String chatAdminUrl = "http://49.232.154.205:10009";
/** chat 后端 admin 账号 */
private String chatAdminAccount = "chatAdmin";
/** chat 后端 admin 密码md5 后传输;明文配置,登录时自动哈希) */
private String chatAdminPassword = "chatAdmin";
/** 默认区号 */
private String defaultAreaCode = "+86";
/** 新建 IM 用户的默认密码(明文,注册时自动 md5 */
private String defaultUserPassword = "123456";
/** 与 OpenIM share.yml 一致的 secret */
private String secret = "openIM123";
/** 管理员 userID */
private String adminUserId = "imAdmin";
/** 系统通知发送者 userID */
private String notificationSender = "imAdmin";
/** 总开关 */
private boolean enabled = true;
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.oa.im.mapper;
import com.ruoyi.common.core.mapper.BaseMapperPlus;
import com.ruoyi.oa.im.ImBind;
/**
* OA-IM 绑定 Mapper
* 包路径包含 .mapper被 @MapperScan("com.ruoyi.**.mapper") 自动扫描
*/
public interface ImBindMapper extends BaseMapperPlus<ImBindMapper, ImBind, ImBind> {
}

View File

@@ -8,6 +8,8 @@ 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 com.ruoyi.oa.audit.OaWarehouseAuditService;
import com.ruoyi.oa.audit.OpType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import com.ruoyi.oa.domain.bo.OaRequirementsBo;
@@ -32,6 +34,8 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService {
private final OaRequirementsMapper baseMapper;
private final OaWarehouseAuditService auditService;
/**
* 查询OA 需求
*/
@@ -85,6 +89,8 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService {
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setRequirementId(add.getRequirementId());
auditService.log(OpType.REQ_CREATE, OpType.REF_REQUIREMENT, add.getRequirementId(),
"新建采购需求:" + add.getTitle());
}
return flag;
}
@@ -96,7 +102,22 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService {
public Boolean updateByBo(OaRequirementsBo bo) {
OaRequirements update = BeanUtil.toBean(bo, OaRequirements.class);
validEntityBeforeSave(update);
return baseMapper.updateById(update) > 0;
boolean ok = baseMapper.updateById(update) > 0;
if (ok) {
String op = OpType.REQ_UPDATE;
String summary = "修改采购需求:" + (update.getTitle() == null ? "" : update.getTitle());
if (bo.getStatus() != null) {
if (bo.getStatus() == 2) {
op = OpType.REQ_DONE;
summary = "采购需求完成:" + (update.getTitle() == null ? "" : update.getTitle());
} else if (bo.getStatus() == 3) {
op = OpType.REQ_CANCEL;
summary = "采购需求取消:" + (update.getTitle() == null ? "" : update.getTitle());
}
}
auditService.log(op, OpType.REF_REQUIREMENT, update.getRequirementId(), summary);
}
return ok;
}
/**
@@ -114,6 +135,12 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService {
if(isValid){
//TODO 做一些业务上的校验,判断是否需要校验
}
return baseMapper.deleteBatchIds(ids) > 0;
boolean ok = baseMapper.deleteBatchIds(ids) > 0;
if (ok) {
for (Long id : ids) {
auditService.log(OpType.REQ_DELETE, OpType.REF_REQUIREMENT, id, "删除采购需求");
}
}
return ok;
}
}

View File

@@ -24,6 +24,7 @@ import com.ruoyi.oa.domain.vo.SysOaTaskVo;
import com.ruoyi.oa.mapper.SysOaTaskMapper;
import com.ruoyi.oa.service.IOaProjectOperationLogService;
import com.ruoyi.oa.service.ISysOaTaskService;
import com.ruoyi.oa.im.ImSendService;
import org.springframework.context.annotation.Lazy;
import javax.annotation.Resource;
@@ -56,6 +57,8 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService {
private final SysUserMapper userMapper;
private final ImSendService imSendService;
@Lazy
@Resource
private IOaProjectOperationLogService operationLogService;
@@ -217,6 +220,17 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService {
if (flag) {
operationLogService.recordLog(add.getProjectId(), 3, add.getTaskId(),
add.getTaskTitle(), 1, "新增任务: " + add.getTaskTitle(), null, null);
// 推送给被分配的执行人(异步,失败不影响业务)
if (!Objects.equals(workerId, LoginHelper.getUserId())) {
imSendService.sendToOaUser(
workerId,
"新任务分派",
"您有新任务:" + add.getTaskTitle(),
"task",
add.getTaskId(),
"/task/task?taskId=" + add.getTaskId()
);
}
}
// 判断是否为报工模式
if (bo.getStatus()==1L){
@@ -307,6 +321,17 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService {
if (task != null) {
operationLogService.recordLog(task.getProjectId(), 3, bo.getTaskId(),
task.getTaskTitle(), 6, "任务申请延期: " + task.getTaskTitle(), null, null);
// 推送给任务创建人(审批人)
if (task.getCreateUserId() != null
&& !Objects.equals(task.getCreateUserId(), LoginHelper.getUserId())) {
imSendService.sendToOaUser(
task.getCreateUserId(),
"任务申请延期",
"任务【" + task.getTaskTitle() + "】申请延期,请审批",
"task", task.getTaskId(),
"/task/task?taskId=" + task.getTaskId()
);
}
}
}
return ok;
@@ -333,6 +358,16 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService {
if (ok) {
operationLogService.recordLog(sysOaTask.getProjectId(), 3, sysOaTask.getTaskId(),
sysOaTask.getTaskTitle(), 7, "任务延期审批通过: " + sysOaTask.getTaskTitle(), null, null);
// 推送给执行人,告知延期已批准
if (sysOaTask.getWorkerId() != null) {
imSendService.sendToOaUser(
sysOaTask.getWorkerId(),
"任务延期已通过",
"任务【" + sysOaTask.getTaskTitle() + "】延期审批已通过",
"task", sysOaTask.getTaskId(),
"/task/task?taskId=" + sysOaTask.getTaskId()
);
}
}
return ok;
}

View File

@@ -13,6 +13,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ruoyi.oa.domain.SysOaWarehouse;
import com.ruoyi.oa.domain.SysOaWarehouseDetail;
import com.ruoyi.oa.audit.OaWarehouseAuditService;
import com.ruoyi.oa.audit.OpType;
import com.ruoyi.oa.domain.SysOaWarehouseLog;
import com.ruoyi.oa.domain.bo.ReturnDetailBo;
import com.ruoyi.oa.domain.bo.SysOaWarehouseBo;
@@ -59,6 +61,9 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer
@Autowired
private SysOaWarehouseLogMapper warehouseLogMapper;
@Autowired
private OaWarehouseAuditService auditService;
/**
* 查询出库单管理
@@ -117,6 +122,10 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer
lqw.eq(bo.getType() != null, "sowm.type", bo.getType())
.eq(bo.getProjectId()!= null, "sowm.project_id", bo.getProjectId())
.eq(bo.getReturnType() != null, "sowm.return_type", bo.getReturnType())
.eq(bo.getStatus() != null, "sowm.status", bo.getStatus())
.eq(bo.getRequirementId() != null, "sowm.requirement_id", bo.getRequirementId())
.like(bo.getMasterNum() != null && !bo.getMasterNum().isEmpty(), "sowm.master_num", bo.getMasterNum())
.like(bo.getRemark() != null && !bo.getRemark().isEmpty(), "sowm.remark", bo.getRemark())
// 其他过滤……
.eq("sowm.del_flag", 0)
.orderByDesc("sowm.create_time");
@@ -154,6 +163,20 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer
public Boolean insertByBo(SysOaWarehouseMasterBo bo) {
SysOaWarehouseMaster add = BeanUtil.toBean(bo, SysOaWarehouseMaster.class);
validEntityBeforeSave(add);
// 规则 1出库type=0无项目 + 无备注 → 自动写入"办公耗材自动写入"
if (add.getType() != null && add.getType() == 0
&& add.getProjectId() == null
&& (add.getRemark() == null || add.getRemark().trim().isEmpty())) {
add.setRemark("办公耗材自动写入");
}
// 规则 2入库type=1必须强关联采购需求requirement_id
if (add.getType() != null && add.getType() == 1
&& add.getRequirementId() == null) {
throw new RuntimeException("入库必须关联采购需求,请先选择需求");
}
add.setReturnType(0); // 默认0
add.setSignUser(LoginHelper.getNickName());
boolean flag = baseMapper.insert(add) > 0;
@@ -164,6 +187,16 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer
sysOaWarehouseDetailBo.setType(bo.getType());
warehouseDetailService.insertByBo(sysOaWarehouseDetailBo);
}
// 审计:根据 type 写不同操作类型
String op = add.getType() == 0 ? OpType.OUT
: add.getType() == 1 ? OpType.IN
: OpType.RETURN;
String typeLabel = add.getType() == 0 ? "出库" : add.getType() == 1 ? "入库" : "退库";
auditService.log(op, OpType.REF_MASTER, add.getMasterId(),
add.getProjectId(), null,
typeLabel + "" + (add.getMasterNum() == null ? "" : add.getMasterNum())
+ ",共 " + (bo.getWarehouseList() == null ? 0 : bo.getWarehouseList().size()) + "",
null, null);
}
return flag;
}
@@ -211,13 +244,11 @@ public class SysOaWarehouseMasterServiceImpl implements ISysOaWarehouseMasterSer
SysOaWarehouse warehouse = warehouseMapper.selectById(detail.getWarehouseId());
warehouse.setInventory(warehouse.getInventory() + dto.getReturnNum());
warehouseMapper.updateById(warehouse);
// 日志表插入记录
SysOaWarehouseLog log = new SysOaWarehouseLog();
log.setMasterId(detail.getMasterId());
log.setWarehouseId(detail.getWarehouseId());
log.setNum(Long.valueOf(dto.getReturnNum()));
log.setRemark("退库操作");
warehouseLogMapper.insert(log);
// 统一审计日志
auditService.log(OpType.RETURN, OpType.REF_MASTER, detail.getMasterId(),
null, detail.getWarehouseId(),
"退库 " + dto.getReturnNum() + " 件,物料 " + warehouse.getName(),
null, null);
}
return true;
}

View File

@@ -20,6 +20,8 @@ import com.ruoyi.oa.mapper.SysOaWarehouseMapper;
import com.ruoyi.oa.mapper.SysOaWarehouseMasterMapper;
import com.ruoyi.oa.service.ISysOaWarehouseDetailService;
import com.ruoyi.oa.service.ISysOaWarehouseMasterService;
import com.ruoyi.oa.audit.OaWarehouseAuditService;
import com.ruoyi.oa.audit.OpType;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -43,6 +45,8 @@ public class SysOaWarehouseTaskServiceImpl implements ISysOaWarehouseTaskService
private final SysOaWarehouseTaskMapper baseMapper;
private final OaWarehouseAuditService auditService;
private final SysOaWarehouseMasterMapper masterMapper;
@Autowired
@@ -100,6 +104,11 @@ public class SysOaWarehouseTaskServiceImpl implements ISysOaWarehouseTaskService
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setTaskId(add.getTaskId());
auditService.log(OpType.TASK_CREATE, OpType.REF_TASK, add.getTaskId(),
null, add.getWarehouseId(),
"新建车间采购:" + (add.getName() == null ? "" : add.getName())
+ " × " + (add.getTaskInventory() == null ? 0 : add.getTaskInventory()),
null, null);
}
return flag;
}
@@ -111,7 +120,19 @@ public class SysOaWarehouseTaskServiceImpl implements ISysOaWarehouseTaskService
public Boolean updateByBo(SysOaWarehouseTaskBo bo) {
SysOaWarehouseTask update = BeanUtil.toBean(bo, SysOaWarehouseTask.class);
validEntityBeforeSave(update);
return baseMapper.updateById(update) > 0;
boolean ok = baseMapper.updateById(update) > 0;
if (ok && bo.getTaskStatus() != null) {
String op;
String label;
if (bo.getTaskStatus() == 2) { op = OpType.TASK_DONE; label = "完成"; }
else if (bo.getTaskStatus() == 3) { op = OpType.TASK_CANCEL; label = "取消"; }
else { op = "TASK_UPDATE"; label = "修改"; }
auditService.log(op, OpType.REF_TASK, update.getTaskId(),
null, update.getWarehouseId(),
label + "车间采购:" + (update.getName() == null ? "" : update.getName()),
null, null);
}
return ok;
}
/**

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ruoyi.oa.domain.OaSalaryMaster;
import com.ruoyi.oa.domain.SysOaRemind;
import com.ruoyi.oa.im.ImSendService;
import com.ruoyi.oa.mapper.OaSalaryMasterMapper;
import com.ruoyi.oa.mapper.SysOaRemindMapper;
import lombok.RequiredArgsConstructor;
@@ -19,6 +20,7 @@ public class OaSalaryRemindScheduler {
private final OaSalaryMasterMapper salaryMasterMapper;
private final SysOaRemindMapper remindMapper;
private final ImSendService imSendService;
// 老板用户ID
private static final Long BOSS_USER_ID = 1859252208375152641L;
@@ -60,6 +62,15 @@ public class OaSalaryRemindScheduler {
remind.setRemark(master.getRemark());
remindMapper.insert(remind);
// 同时通过 IM 推送给老板
imSendService.sendToOaUser(
BOSS_USER_ID,
"工资审批提醒",
remind.getContent(),
"salary", master.getMasterId(),
"/finance/salary/list"
);
}
}
}

View File

@@ -0,0 +1,82 @@
package com.ruoyi.oa.task;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.oa.domain.SysOaProject;
import com.ruoyi.oa.im.ImSendService;
import com.ruoyi.oa.mapper.SysOaProjectMapper;
import com.ruoyi.system.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
/**
* 项目到期提醒:每天 09:00 扫描 finishTime 在 [today, today+THRESHOLD_DAYS] 区间且未完结的项目,
* 通过 OpenIM 推送给项目负责人。
*
* @author wangyu
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProjectExpiryRemindScheduler {
/** 提前多少天提醒 */
private static final int THRESHOLD_DAYS = 3;
private final SysOaProjectMapper projectMapper;
private final SysUserMapper userMapper;
private final ImSendService imSendService;
@Scheduled(cron = "0 0 9 * * ?")
public void notifyExpiringProjects() {
Calendar cal = Calendar.getInstance();
cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0);
Date start = cal.getTime();
cal.add(Calendar.DAY_OF_MONTH, THRESHOLD_DAYS + 1);
Date end = cal.getTime();
LambdaQueryWrapper<SysOaProject> qw = Wrappers.lambdaQuery();
qw.between(SysOaProject::getFinishTime, start, end);
// 已结项的不推("3" 通常表示已完结,按你们的字典调整)
qw.ne(SysOaProject::getProjectStatus, "3");
List<SysOaProject> projects = projectMapper.selectList(qw);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (SysOaProject p : projects) {
String nickName = p.getFunctionary();
if (nickName == null || nickName.isEmpty()) { continue; }
// functionary 存的是 nickName反查 user_id
SysUser user = userMapper.selectOne(
Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getNickName, nickName)
.eq(SysUser::getDelFlag, "0")
.last("limit 1")
);
if (user == null) {
log.debug("[ProjectExpiry] nickName={} 未找到对应用户,跳过", nickName);
continue;
}
String title = "项目即将到期";
String desc = String.format("项目【%s】将于 %s 到期,请及时跟进",
p.getProjectName(), sdf.format(p.getFinishTime()));
imSendService.sendToOaUser(
user.getUserId(), title, desc,
"project", p.getProjectId(),
"/oa/project?projectId=" + p.getProjectId()
);
}
log.info("[ProjectExpiry] 推送 {} 个即将到期项目", projects.size());
}
}