feat: 会议提交剩余时间+解冻 + 会务/劳务材料批量下载 + 多角色人员/账号模块完善

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-25 06:53:18 +08:00
co-authored by Claude
parent 0c882ae846
commit 591a43fe61
126 changed files with 3789 additions and 1334 deletions
@@ -23,6 +23,8 @@ ruoyi:
accessKeySecret: gA0fTFa8arUYaYA5Us2ClPKVSM1Ypv
dirPrefix: ry8080/
expireSeconds: 3600
# 阿里云函数计算 (FC) 打 zip 端点 (与 hwt-serve 共用同一 bucket, 参考 OssApi.ossDownload)
zip-func-url: https://zip-oss-func-zip-oss-swqpembfsl.cn-beijing.fcapp.run
# 阿里云短信配置 (hwt-code ali.sms 模式)
# dev/prod 区分走代码: phone 以 "10" 开头视为 dev 测试 (固定码 1234), 其它走 aliyun 真发
sms:
@@ -45,7 +47,7 @@ ruoyi:
page-timeout-s: 15
total-timeout-s: 60
# QR 命中后是否继续跑全量 OCR (false = 快路径只返回 QR 3 字段)
qr-full-ocr: true
qr-full-ocr: false
lang: ch
# PDF 优先抽内嵌文本层 (电子发票秒出, 扫描件自动回退 ONNX)
use-pdf-text-first: true
@@ -78,9 +80,9 @@ server:
# 日志配置
logging:
level:
com.ruoyi: info
org.springframework: info
com.ruoyi.business: info
com.ruoyi: debug
org.springframework: debug
com.ruoyi.business: debug
# 用户配置
user:
@@ -6,9 +6,11 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizPerson;
@@ -159,7 +161,7 @@ public class BizAuthController extends BaseController {
return error("所属主账号不存在, 请联系管理员");
}
if (UserStatus.DISABLE.getCode().equals(parent.getStatus())) {
return error("所属企业主账号已被停用, 请联系企业管理员");
return error("所属的机构已禁用");
}
}
@@ -254,8 +256,7 @@ public class BizAuthController extends BaseController {
self.setPhone(phone);
self.setOrgId(orgId);
self.setDepartment("管理部");
self.setPosition("总负责人");
self.setRole("admin"); // 主账号 = 管理员 (与子账号的会议执行区分)
self.setPosition("管理员");
self.setUnitType("executor");
self.setUserId(userId);
self.setCreateBy(username);
@@ -266,31 +267,55 @@ public class BizAuthController extends BaseController {
}
/**
* 注册支持方 (主账号), 流程与 executor 一致:
* 校验 → 查重 → 写 sys_user + role_type=sponsor → 写 biz_org (sponsor 类型)
* 支持方注册下拉选项 (匿名公开): 从 biz_org(sponsor 类型) 搜企业, 含无主账号的 org.
* 返回 [{orgId, orgName, mainUserId}], mainUserId 为 null 表示该企业还没有主账号.
*/
@GetMapping("/sponsorOrgOptions")
public AjaxResult sponsorOrgOptions(@RequestParam(value = "orgName", required = false) String orgName) {
BizOrg query = new BizOrg();
query.setOrgName(orgName);
return success(bizOrgService.selectSponsorRegisterOptions(query));
}
/**
* 注册支持方 (子账号 SUB): 从已存在的 sponsor 企业里选一家注册, 不再新建企业.
* 校验 → 查重 → 写 sys_user (account_type=SUB + role_type=sponsor) → 写 biz_person 关联所选企业.
* 主账号归属 (见 /sponsorOrgOptions 的 mainUserId):
* - 企业已有主账号 (biz_org.user_id != null) → parent_user_id = 主账号 user_id
* - 企业暂无主账号 (biz_org.user_id == null) → parent_user_id 留空, 待 admin/manager 后续分配
*/
@PostMapping("/registerSponsor")
public AjaxResult registerSponsor(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
String unitName = (String) body.get("unitName");
String businessNature = (String) body.get("businessNature");
String orgIdStr = body.get("orgId") == null ? null : body.get("orgId").toString();
String phone = (String) body.get("phone");
String code = (String) body.get("smsCode");
String password = (String) body.get("password");
String confirmPassword = (String) body.get("confirmPassword");
String uuid = (String) body.get("uuid");
// 1. 基础校验 (与 executor 同)
// 1. 基础校验
if (username == null || username.length() < 4 || username.length() > 20) return error("用户名长度 4-20 位");
if (!username.matches("^[A-Za-z0-9_]+$")) return error("用户名只能包含字母/数字/下划线");
if (unitName == null || unitName.isEmpty()) return error("企业名称不能为空");
if (businessNature == null || businessNature.isEmpty()) return error("企业性质不能为空");
if (orgIdStr == null || orgIdStr.isEmpty()) return error("请选择企业");
if (phone == null || !phone.matches("^1\\d{10}$")) return error("手机号格式错误");
if (code == null || code.isEmpty()) return error("请输入短信验证码");
if (password == null || password.length() < 6 || password.length() > 20) return error("密码长度 6-20 位");
if (!password.equals(confirmPassword)) return error("两次密码输入不一致");
// 2. 校验短信验证码
// 2. 校验所选企业存在且为 sponsor (不新建企业)
Long orgId;
try {
orgId = Long.valueOf(orgIdStr);
} catch (NumberFormatException e) {
return error("企业参数错误");
}
BizOrg org = bizOrgService.getById(orgId);
if (org == null) return error("所选企业不存在");
if (!"sponsor".equals(org.getOrgType())) return error("所选企业不是支持方");
Long mainUserId = org.getUserId(); // 可为 null: 无主账号时留空待分配
// 3. 校验短信验证码
SmsValidForm smsForm = new SmsValidForm();
smsForm.setPhone(phone);
smsForm.setSmsCode(code);
@@ -301,7 +326,7 @@ public class BizAuthController extends BaseController {
return error("验证码错误或已过期: " + e.getMessage());
}
// 3. 查重
// 4. 查重
if (userService.isPhoneRegistered(phone)) {
return error("该手机号已注册, 请直接登录");
}
@@ -311,42 +336,28 @@ public class BizAuthController extends BaseController {
return error("用户名已被占用: " + username);
}
// 4. 写 sys_user (role_type=sponsor)
// 5. 写 sys_user: 全部注册为 SUB 子账号
// role_type 显式写 sponsor, 避免被 sys_user.role_type DB DEFAULT 'executor' 覆盖
SysUser user = new SysUser();
user.setUserName(username);
user.setNickName(unitName);
user.setNickName(org.getOrgName()); // 昵称用所选企业名
user.setPhonenumber(phone);
user.setPassword(passwordEncoder.encode(password));
user.setStatus("0");
user.setAccountType("SUB");
user.setParentUserId(mainUserId); // 有主账号则绑定, 无则留空待分配
user.setRoleType("sponsor");
userService.insertUser(user);
Long userId = user.getUserId();
userService.updateRoleType(userId, "sponsor");
// 5. 写 biz_org (sponsor 类型)
BizOrg org = new BizOrg();
org.setOrgId(null);
org.setUserId(userId); // 关联主账号, 让系统能反查 "我的公司"
org.setOrgName(unitName);
org.setOrgType("sponsor");
org.setBusinessNature(businessNature);
org.setContactPhone(phone);
org.setContactName(unitName);
org.setStatus("0");
bizOrgService.insert(org);
Long orgId = org.getOrgId();
// 6. 同步建 biz_person (主账号自己 = 管理员, 让"人员管理"能看到自己)
// 注意: 不能走 IBizPersonService.insert(BizPerson, Long), 那个方法是创建 SUB 子账号的
// (强制 accountType='SUB' + 需 loginUsername, 主账号自己不需要).
// sys_user 已在 step 4 建好, 这里只需把 biz_person 关联到主账号自己.
// 6. 写 biz_person (关联到所选企业, 不再新建 biz_org)
BizPerson self = new BizPerson();
SnowflakeId.injectIfEmpty(self, "personId");
self.setName(unitName);
self.setName(username); // 表单无姓名字段, 用登录用户名占位
self.setPhone(phone);
self.setOrgId(orgId);
self.setDepartment("管理部");
self.setPosition("总负责人");
self.setRole("admin"); // 主账号 = 管理员 (与子账号的监察员区分)
self.setDepartment("待分配");
self.setPosition("员工");
self.setUnitType("sponsor");
self.setUserId(userId);
self.setCreateBy(username);
@@ -9,6 +9,7 @@ import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizMeeting;
@@ -210,27 +211,7 @@ public class BizMeetingAttendeeController extends BaseController {
List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
List<BizMeetingAttendeeImportVo> exportList = new ArrayList<>(list.size());
for (BizMeetingAttendee a : list) {
BizMeetingAttendeeImportVo v = new BizMeetingAttendeeImportVo();
v.setName(a.getName());
v.setPhone(a.getPhone());
v.setWorkUnit(a.getWorkUnit());
v.setDepartment(a.getDepartment());
v.setTitle(a.getTitle());
v.setIdCard(a.getIdCard());
v.setBankName(a.getBankName());
v.setBankCard(a.getBankCard());
v.setBankBranch(a.getBankBranch());
v.setAccountName(a.getAccountName());
v.setBankRegion(a.getBankRegion());
v.setBankAddress(a.getBankAddress());
v.setIdCardAttachments(a.getIdCardAttachments());
v.setLaborForm(a.getLaborForm());
v.setFeePreTax(a.getFeePreTax());
v.setTax(a.getTax());
v.setVatAndSurcharge(a.getVatAndSurcharge());
v.setFee(a.getFee());
v.setSummary(a.getSummary());
exportList.add(v);
exportList.add(BizMeetingAttendeeImportVo.from(a));
}
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
util.exportExcel(response, exportList, "参会人");
@@ -267,4 +248,67 @@ public class BizMeetingAttendeeController extends BaseController {
}
return toAjax(rows);
}
/**
* 下载"劳务协议"空目录模板 zip (参会人管理 "下载协议模板" 按钮).
* zip 内为 劳务协议/{序号}_{姓名}/ 空目录, 用户把签好的协议放进对应目录后重新压缩上传.
* 仅 admin/manager 可触发.
*/
@GetMapping("/agreementTemplate/{meetingId}")
public void agreementTemplate(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
requireManagerOrAdmin();
byte[] data = attendeeService.buildAgreementTemplateZip(meetingId);
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment;filename=agreement-template.zip");
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
/**
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人,
* 上传 OSS 并回填 labor_protocol. 仅 admin/manager 可触发.
*/
@Log(title = "劳务协议回填", businessType = BusinessType.UPDATE)
@PostMapping("/uploadAgreements")
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
requireManagerOrAdmin();
int updated = attendeeService.uploadAgreements(file, meetingId);
return success(updated);
}
/**
* 下载"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
* zip 内为 专家照片/{序号}_{姓名}/ 空目录. 仅 admin/manager 可触发.
*/
@GetMapping("/expertPhotoTemplate/{meetingId}")
public void expertPhotoTemplate(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
requireManagerOrAdmin();
byte[] data = attendeeService.buildExpertPhotoTemplateZip(meetingId);
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment;filename=expert-photo-template.zip");
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
/**
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人,
* 上传 OSS 并逗号拼接回填 on_site_photos. 仅 admin/manager 可触发.
*/
@Log(title = "专家照片回填", businessType = BusinessType.UPDATE)
@PostMapping("/uploadExpertPhotos")
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
requireManagerOrAdmin();
int updated = attendeeService.uploadExpertPhotos(file, meetingId);
return success(updated);
}
/** 仅 admin/manager 可操作 (参会人劳务协议/专家照片的批量收发是管理端功能) */
private void requireManagerOrAdmin() {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可操作");
}
}
}
@@ -1,5 +1,6 @@
package com.ruoyi.business.controller;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Map;
@@ -22,9 +23,11 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.StageDeriver;
@@ -58,6 +61,8 @@ public class BizMeetingController extends BaseController {
@Autowired
private IBizProjectService bizProjectService;
@Autowired
private IBizOrgService bizOrgService;
@Autowired
private StageDeriver stageDeriver;
@Autowired
private PosterService posterService;
@@ -70,7 +75,7 @@ public class BizMeetingController extends BaseController {
if ("doctor".equals(roleType) || "expert".equals(roleType)) {
bizMeeting.setUserId(uid);
}
// sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_admin_user_id, SUB 走 sponsor_assign.monitor_user_id).
// sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_org_id, SUB 走 sponsor_assign.monitor_user_id).
// 与项目列表 selectSponsorList 的 MAIN/SUB 判定平行, 但刻意不带 biz_publicity_support_intent 关联.
else if ("sponsor".equals(roleType)) {
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
@@ -81,7 +86,7 @@ public class BizMeetingController extends BaseController {
}
}
// executor 数据权限: 只看"我的项目"下的会议 (与项目列表 selectExecutorList/selectExecutorStaffList 同源).
// MAIN 走 biz_project_assign (exec_user_id / execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id.
// MAIN 走 biz_project_assign (execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id.
else if ("executor".equals(roleType)) {
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) {
@@ -129,6 +134,8 @@ public class BizMeetingController extends BaseController {
bizMeeting.setCreateTime(new Date());
bizMeeting.setUpdateBy(SecurityUtils.getUsername());
bizMeeting.setUpdateTime(new Date());
// 提交截止时间: 建会时按 end_time + 项目 submit_deadline_days 天 落库 (项目未设天数则为 null → 永不冻结)
bizMeeting.setSubmitDeadline(computeSubmitDeadline(bizMeeting.getProjectId(), bizMeeting.getEndTime()));
int rows = bizMeetingService.insert(bizMeeting);
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
@@ -230,45 +237,10 @@ public class BizMeetingController extends BaseController {
}
/**
* 执行人员提交凭证 (校验 LV_PAYMENT + SV_PAYMENT)
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/submit-voucher")
public AjaxResult submitVoucher(@PathVariable("meetingId") Long meetingId) {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId();
if (!bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) {
throw new ServiceException("您不是该项目的执行方, 无法提交凭证");
}
if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交凭证");
if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交凭证");
String stage = m.getVoucherAuditStage();
if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) {
throw new ServiceException("当前阶段 (" + stage + ") 不允许提交凭证");
}
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLv = mats.stream().anyMatch(x -> "LV_PAYMENT".equals(x.getSubType()));
boolean hasSv = mats.stream().anyMatch(x -> "SV_PAYMENT".equals(x.getSubType()));
if (!hasLv || !hasSv) {
throw new ServiceException("请同时上传劳务凭证和会务凭证");
}
m.setVoucherAuditStage("SUBMITTED");
m.setVoucherComplianceApproved(0);
m.setVoucherAuditTime(new Date());
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "VOUCHER", "SUBMITTED", "执行人员提交凭证");
return success("SUBMITTED");
}
/**
* 合规审核 (role_type=manager), 两级审核中的第一级.
* 合规审核 (role_type=manager), 材料两级审核中的第一级.
* <p>
* body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." }
* <p>合规审中判据: stage=SUBMITTED 且 compliance_approved=0.
* body: { "approved": true|false, "opinion": "..." }
* <p>合规审中判据: material_audit_stage=SUBMITTED 且 compliance_approved=0.
* 通过 → compliance_approved=1 (转入支持方审), 拒绝 → REJECTED (退回执行方).
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@@ -280,97 +252,97 @@ public class BizMeetingController extends BaseController {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
String[] types = resolveTypes(body.getAuditType());
boolean approved = Boolean.TRUE.equals(body.getApproved());
if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
String result = approved ? "APPROVED" : "REJECTED";
for (String type : types) {
String stage = stageOf(m, type);
boolean complianceDone = complianceApprovedOf(m, type);
if (!"SUBMITTED".equals(stage) || complianceDone) {
throw new ServiceException(type + " 当前阶段不允许合规审核");
}
if (approved) {
setComplianceApproved(m, type, 1);
} else {
setStage(m, type, "REJECTED");
}
setAuditTime(m, type, new Date());
Integer compliance = m.getMaterialComplianceApproved();
if (!"SUBMITTED".equals(m.getMaterialAuditStage()) || (compliance != null && compliance == 1)) {
throw new ServiceException("当前阶段不允许合规审核");
}
String result = approved ? "APPROVED" : "REJECTED";
if (approved) {
m.setMaterialComplianceApproved(1);
} else {
m.setMaterialAuditStage("REJECTED");
// 退回 → 提交截止时间重新计算 (now + 项目天数)
m.setSubmitDeadline(computeSubmitDeadline(m.getProjectId(), new Date()));
}
m.setMaterialAuditTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m);
for (String type : types) {
appendAuditLog(m, type, result, body.getOpinion());
}
appendAuditLog(m, "MATERIAL", result, body.getOpinion());
return success(result);
}
/**
* 支持方(监察员) 审核, 两级审核中的第二级.
* 支持方(监察员) 审核, 材料两级审核中的第二级.
* <p>
* body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." }
* <p>支持方审中判据: stage=SUBMITTED 且 compliance_approved=1.
* 通过 → APPROVED (材料通过时一并写监管意见), 拒绝 → REJECTED (退回执行方).
* body: { "approved": true|false, "opinion": "..." }
* <p>支持方审中判据: material_audit_stage=SUBMITTED 且 compliance_approved=1.
* 通过 → APPROVED (一并写监管意见), 拒绝 → REJECTED (退回执行方).
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-supervision")
public AjaxResult auditSupervision(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) {
Long userId = SecurityUtils.getUserId();
Long myOrgId = bizOrgService.selectOrgIdByUserId(userId);
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
// 授权: 监察员 (biz_meeting_supervisor) 或 支持方 MAIN 账号 (biz_project.sponsor_admin_user_id) 均可审
// 授权: 监察员 (biz_meeting_supervisor.sponsor_org_id) 或 支持方企业 (biz_project.sponsor_org_id) 均可审
boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream()
.anyMatch(s -> userId.equals(s.getUserId()));
.anyMatch(s -> myOrgId != null && myOrgId.equals(s.getSponsorOrgId()));
BizProject project = m.getProjectId() == null ? null : bizProjectService.getById(m.getProjectId());
boolean isSponsorMain = project != null && userId.equals(project.getSponsorAdminUserId());
boolean isSponsorMain = project != null && myOrgId != null && myOrgId.equals(project.getSponsorOrgId());
if (!isSupervisor && !isSponsorMain) {
throw new ServiceException("您不是该会议监察员, 无权监察");
}
String[] types = resolveTypes(body.getAuditType());
boolean approved = Boolean.TRUE.equals(body.getApproved());
if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
String result = approved ? "APPROVED" : "REJECTED";
for (String type : types) {
String stage = stageOf(m, type);
boolean complianceDone = complianceApprovedOf(m, type);
if (!"SUBMITTED".equals(stage) || !complianceDone) {
throw new ServiceException(type + " 当前阶段不允许监察审核");
}
setStage(m, type, approved ? "APPROVED" : "REJECTED");
setAuditTime(m, type, new Date());
Integer compliance = m.getMaterialComplianceApproved();
if (!"SUBMITTED".equals(m.getMaterialAuditStage()) || compliance == null || compliance != 1) {
throw new ServiceException("当前阶段不允许监察审核");
}
String result = approved ? "APPROVED" : "REJECTED";
m.setMaterialAuditStage(approved ? "APPROVED" : "REJECTED");
// 退回 → 提交截止时间重新计算 (now + 项目天数)
if (!approved) {
m.setSubmitDeadline(computeSubmitDeadline(m.getProjectId(), new Date()));
}
m.setMaterialAuditTime(new Date());
// 材料通过 → 写监管意见 (支持方的书面意见)
if (approved && java.util.Arrays.asList(types).contains("MATERIAL")) {
if (approved) {
m.setSupervisionOpinion(body.getOpinion());
m.setSupervisionBy(SecurityUtils.getUsername());
m.setSupervisionTime(new Date());
}
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m);
for (String type : types) {
appendAuditLog(m, type, result, body.getOpinion());
}
appendAuditLog(m, "MATERIAL", result, body.getOpinion());
// 退回 → 通知执行方 (待整改 + 说明)
if (!approved) {
for (BizMeetingExecutor e : bizMeetingExecutorService.selectByMeetingId(meetingId)) {
bizNotifyService.meetingSupervisionRejected(e.getUserId(), meetingId, m.getMeetingName(), body.getOpinion());
bizNotifyService.meetingSupervisionRejected(resolveMainUserIdByOrgId(e.getExecutorOrgId()), meetingId, m.getMeetingName(), body.getOpinion());
}
}
return success(result);
}
/**
* 结算 (合规/管理员 手动点击). 前置: material+voucher 都 APPROVED.
* 结算 (合规/管理员 手动点击).
* <p>
* 前置: 材料已 APPROVED + 费用已汇总 (fee_calc_status=1) + 劳务/会务付款凭证都已上传.
* 凭证由合规人员在"劳务凭证/会务凭证" tab 随时上传保存, 结算时校验是否已存在, 不再随结算一起提交.
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/settle")
@@ -382,8 +354,8 @@ public class BizMeetingController extends BaseController {
}
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
if (!"APPROVED".equals(m.getMaterialAuditStage()) || !"APPROVED".equals(m.getVoucherAuditStage())) {
throw new ServiceException("材料与凭证均审核通过后才能结算");
if (!"APPROVED".equals(m.getMaterialAuditStage())) {
throw new ServiceException("材料审核通过后才能结算");
}
if (isSettled(m)) throw new ServiceException("会议已结算");
// 费用未汇总完 (fee_calc_status=0) 禁止结算: 此时 labor_fee/meeting_fee 可能为旧值/0, 直接回写会污染项目金额.
@@ -391,6 +363,15 @@ public class BizMeetingController extends BaseController {
if (m.getFeeCalcStatus() == null || m.getFeeCalcStatus() != 1) {
throw new ServiceException("会议费用尚未汇总完成,无法结算");
}
// 凭证校验: 劳务(LV_PAYMENT) + 会务(SV_PAYMENT) 必须已上传保存, 否则拒绝结算
List<BizMeetingMaterial> existingMaterials = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLv = existingMaterials != null && existingMaterials.stream().anyMatch(v -> "LV_PAYMENT".equals(v.getSubType()) && v.getOssUrl() != null && !v.getOssUrl().isEmpty());
boolean hasSv = existingMaterials != null && existingMaterials.stream().anyMatch(v -> "SV_PAYMENT".equals(v.getSubType()) && v.getOssUrl() != null && !v.getOssUrl().isEmpty());
if (!hasLv || !hasSv) {
throw new ServiceException("请先上传付款凭证");
}
m.setIsSettled(1);
m.setSettleTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
@@ -425,6 +406,30 @@ public class BizMeetingController extends BaseController {
return success("FINISHED");
}
/**
* 解冻 (合规/管理员 手动点击). 前置: is_frozen=1.
* 清除冻结标记 (is_frozen=0), 保留 freeze_time 作为"曾被冻结过"的历史凭证 (列表据此显示"超时提交"),
* 提交截止时间重新计算 (now + 项目天数) → 冻结倒计时重新开始.
*/
@Log(title = "会议", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/unfreeze")
public AjaxResult unfreeze(@PathVariable("meetingId") Long meetingId) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType) && !"admin".equals(roleType)) {
throw new ServiceException("只有合规或管理员可解冻");
}
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
if (!isFrozen(m)) throw new ServiceException("会议未冻结, 无需解冻");
m.setIsFrozen(0);
m.setSubmitDeadline(computeSubmitDeadline(m.getProjectId(), new Date()));
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
m.setUpdateBy(SecurityUtils.getUsername());
m.setUpdateTime(new Date());
bizMeetingService.updateByPrimaryKey(m);
return success("UNFROZEN");
}
/**
* 审核轨迹 (audit_log 列表, 按时间排序)
*/
@@ -445,32 +450,25 @@ public class BizMeetingController extends BaseController {
private static boolean isSettled(BizMeeting m) { return m.getIsSettled() != null && m.getIsSettled() == 1; }
private static boolean isFinished(BizMeeting m) { return m.getIsFinished() != null && m.getIsFinished() == 1; }
/** 材料/凭证 子状态读取 */
private static String stageOf(BizMeeting m, String type) {
return "MATERIAL".equals(type) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
}
private static boolean complianceApprovedOf(BizMeeting m, String type) {
Integer v = "MATERIAL".equals(type) ? m.getMaterialComplianceApproved() : m.getVoucherComplianceApproved();
return v != null && v == 1;
}
private static void setStage(BizMeeting m, String type, String stage) {
if ("MATERIAL".equals(type)) m.setMaterialAuditStage(stage);
else m.setVoucherAuditStage(stage);
}
private static void setComplianceApproved(BizMeeting m, String type, int v) {
if ("MATERIAL".equals(type)) m.setMaterialComplianceApproved(v);
else m.setVoucherComplianceApproved(v);
}
private static void setAuditTime(BizMeeting m, String type, Date t) {
if ("MATERIAL".equals(type)) m.setMaterialAuditTime(t);
else m.setVoucherAuditTime(t);
/** org_id → MAIN 主账号 user_id (biz_org.user_id), null 安全 */
private Long resolveMainUserIdByOrgId(Long orgId) {
if (orgId == null) return null;
BizOrg org = bizOrgService.getById(orgId);
return org == null ? null : org.getUserId();
}
/** auditType: MATERIAL / VOUCHER / BOTH → 处理类型数组 */
private static String[] resolveTypes(String auditType) {
if ("BOTH".equals(auditType)) return new String[] { "MATERIAL", "VOUCHER" };
if ("MATERIAL".equals(auditType) || "VOUCHER".equals(auditType)) return new String[] { auditType };
throw new ServiceException("auditType 必须是 MATERIAL / VOUCHER / BOTH");
/**
* 计算提交截止时间 = 基准时刻 + 项目 submit_deadline_days 天.
* 项目缺失或未设天数 → 返回 null (会议永不冻结, 与旧 markFrozen 的 p.submit_deadline_days is not null 语义一致).
*/
private Date computeSubmitDeadline(Long projectId, Date base) {
if (projectId == null) return null;
BizProject p = bizProjectService.getById(projectId);
if (p == null || p.getSubmitDeadlineDays() == null) return null;
Calendar cal = Calendar.getInstance();
cal.setTime(base == null ? new Date() : base);
cal.add(Calendar.DAY_OF_MONTH, p.getSubmitDeadlineDays());
return cal.getTime();
}
/**
@@ -494,14 +492,12 @@ public class BizMeetingController extends BaseController {
/** request body for audit endpoints */
public static class AuditBody {
private String auditType; // MATERIAL / VOUCHER / BOTH
private Boolean approved; // true=通过 false=拒绝
private String opinion; // 意见
public String getAuditType() { return auditType; }
public void setAuditType(String auditType) { this.auditType = auditType; }
public Boolean getApproved() { return approved; }
public void setApproved(Boolean approved) { this.approved = approved; }
public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; }
}
}
@@ -29,20 +29,20 @@ public class BizMeetingExecutorController extends BaseController {
/**
* 分配执行人员 (全删全插)
* body: { "userIds": [1, 2, 3] }
* body: { "orgIds": [1, 2, 3] } (执行方企业 org_id 列表)
*/
@Log(title = "会议-执行人员", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult assign(@PathVariable("meetingId") Long meetingId, @RequestBody AssignBody body) {
Long assignedBy = SecurityUtils.getUserId();
int n = bizMeetingExecutorService.replaceByMeetingId(meetingId, body.getUserIds(), assignedBy);
int n = bizMeetingExecutorService.replaceByMeetingId(meetingId, body.getOrgIds(), assignedBy);
return success(n);
}
/** request body wrapper */
public static class AssignBody {
private List<Long> userIds;
public List<Long> getUserIds() { return userIds; }
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
private List<Long> orgIds;
public List<Long> getOrgIds() { return orgIds; }
public void setOrgIds(List<Long> orgIds) { this.orgIds = orgIds; }
}
}
@@ -3,15 +3,19 @@ package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService;
import jakarta.servlet.http.HttpServletResponse;
/**
* 会议材料 Controller
* <p>
@@ -58,6 +62,23 @@ public class BizMeetingMaterialController extends BaseController {
return success(saved);
}
/**
* 合规/管理员 随时保存付款凭证 (LV_PAYMENT + SV_PAYMENT).
* <p>
* 只清/插这两个 subType, 不碰其他材料; 付款凭证非发票, 不触发 OCR, 不影响会议费用.
* 结算前必须先已上传保存; 结算接口只校验凭证是否已存在.
*/
@Log(title = "会议材料", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/saveVouchers")
public AjaxResult saveVouchers(@PathVariable("meetingId") Long meetingId, @RequestBody List<BizMeetingMaterial> vouchers) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType) && !"admin".equals(roleType)) {
throw new ServiceException("只有合规或管理员可上传凭证");
}
bizMeetingMaterialService.saveVouchers(meetingId, vouchers);
return success();
}
/**
* 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL).
* <p>
@@ -69,4 +90,91 @@ public class BizMeetingMaterialController extends BaseController {
bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrl(), body.getExtraOssUrl());
return success();
}
/**
* 会务下载: 把该会议所有"会务"材料 (SERVICE + SERVICE_VOUCHER) 在 OSS 端打 zip, 返回下载 URL.
* 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮).
*/
@GetMapping("/{meetingId}/downloadZip")
public AjaxResult downloadZip(@PathVariable("meetingId") Long meetingId) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载会务材料");
}
// 注意: 不能用 success(String), 否则 URL 会被塞进 msg 字段 (BaseController.success(String) 重载陷阱)
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildServiceZipUrl(meetingId));
}
/**
* 劳务下载: 把该会议所有"劳务"材料 (LABOR + LABOR_VOUCHER) + 参会人信息在 OSS 端打 zip, 返回下载 URL.
* 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮).
*/
@GetMapping("/{meetingId}/downloadLaborZip")
public AjaxResult downloadLaborZip(@PathVariable("meetingId") Long meetingId) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载劳务材料");
}
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildLaborZipUrl(meetingId));
}
/**
* 批量会务下载: 多会议会务材料合并打一个 zip, 返回下载 URL.
* body: { "meetingIds": [1,2,3] }, 仅 admin/manager.
*/
@PostMapping("/batchDownloadZip")
public AjaxResult batchDownloadZip(@RequestBody(required = false) BatchDownloadBody body) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载会务材料");
}
List<Long> meetingIds = body == null ? null : body.getMeetingIds();
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildBatchServiceZipUrl(meetingIds));
}
/**
* 批量劳务下载: 多会议劳务材料(含参会人信息)合并打一个 zip, 返回下载 URL.
* body: { "meetingIds": [1,2,3] }, 仅 admin/manager.
*/
@PostMapping("/batchDownloadLaborZip")
public AjaxResult batchDownloadLaborZip(@RequestBody(required = false) BatchDownloadBody body) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载劳务材料");
}
List<Long> meetingIds = body == null ? null : body.getMeetingIds();
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildBatchLaborZipUrl(meetingIds));
}
/**
* 会务材料"打包上传" — 下载空目录模板 zip (会务材料/物料制作·酒店·…/ 全空目录).
*/
@GetMapping("/serviceTemplate/{meetingId}")
public void serviceTemplate(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
byte[] data = bizMeetingMaterialService.buildServiceTemplateZip(meetingId);
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment;filename=service-material-template.zip");
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
/**
* 会务材料"打包上传" — 上传 zip, 解压后按目录名匹配 subType, 单文件直传 OSS / 多文件打 zip 传 OSS,
* 回填 biz_meeting_material. 材料变化 → 会议费用待重算.
*/
@Log(title = "会务材料回填", businessType = BusinessType.UPDATE)
@PostMapping("/uploadServiceMaterials")
public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId);
bizMeetingService.markFeeCalcPending(meetingId);
return success(updated);
}
/** request body for batch download endpoints */
public static class BatchDownloadBody {
private List<Long> meetingIds;
public List<Long> getMeetingIds() { return meetingIds; }
public void setMeetingIds(List<Long> meetingIds) { this.meetingIds = meetingIds; }
}
}
@@ -29,20 +29,20 @@ public class BizMeetingSupervisorController extends BaseController {
/**
* 分配监察员 (全删全插)
* body: { "userIds": [1, 2, 3] }
* body: { "orgIds": [1, 2, 3] } (支持方企业 org_id 列表)
*/
@Log(title = "会议-监察员", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult assign(@PathVariable("meetingId") Long meetingId, @RequestBody AssignBody body) {
Long assignedBy = SecurityUtils.getUserId();
int n = bizMeetingSupervisorService.replaceByMeetingId(meetingId, body.getUserIds(), assignedBy);
int n = bizMeetingSupervisorService.replaceByMeetingId(meetingId, body.getOrgIds(), assignedBy);
return success(n);
}
/** request body wrapper */
public static class AssignBody {
private List<Long> userIds;
public List<Long> getUserIds() { return userIds; }
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
private List<Long> orgIds;
public List<Long> getOrgIds() { return orgIds; }
public void setOrgIds(List<Long> orgIds) { this.orgIds = orgIds; }
}
}
@@ -2,14 +2,19 @@ package com.ruoyi.business.controller;
import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizOrgImportVo;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.common.utils.SecurityUtils;
@@ -111,4 +116,25 @@ public class BizOrgController extends BaseController {
public AjaxResult remove(@PathVariable Long[] orgIds) {
return toAjax(bizOrgService.deleteByPrimaryKeys(orgIds));
}
/**
* 下载支持单位导入模板
*/
@GetMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) {
ExcelUtil<BizOrgImportVo> util = new ExcelUtil<>(BizOrgImportVo.class);
util.importTemplateExcel(response, "支持单位数据");
}
/**
* 批量导入支持单位 (只导 org, 不新增人员 — 不建 sys_user, userId 留空)
* 返回 {@link ImportResult} 含成功/失败计数 + 失败明细 (行号 + 原因)
*/
@Log(title = "公司管理", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file) throws Exception {
String operName = SecurityUtils.getUsername();
ImportResult result = bizOrgService.importOrg(file, operName);
return success(result);
}
}
@@ -13,6 +13,7 @@ import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.domain.BizPersonExecutorImportVO;
import com.ruoyi.business.domain.BizPersonImportVO;
import com.ruoyi.business.service.IBizPersonService;
import com.ruoyi.common.core.domain.entity.SysUser;
@@ -37,7 +38,7 @@ public class BizPersonController extends BaseController
* (sys_user.parent_user_id → sys_user.user_id → biz_person.user_id 链路过滤)
*/
private static final java.util.Set<String> BUSINESS_MAIN_ROLES = java.util.Set.of(
"executor", "sponsor", "doctor", "manager"
"executor", "sponsor", "doctor"
);
@GetMapping("/list")
@@ -113,6 +114,16 @@ public class BizPersonController extends BaseController
bizPerson.setUpdateBy(getUsername());
return toAjax(bizPersonService.updateByPrimaryKey(bizPerson));
}
/**
* 更换机构管理员 (admin/sponsor-people 管理员 switch)
* body: { personId } — 把该人员晋升为机构 MAIN, 原管理员降为 SUB
*/
@Log(title = "更换机构管理员", businessType = BusinessType.UPDATE)
@PutMapping("/changeAdmin")
public AjaxResult changeAdmin(@RequestBody BizPerson bizPerson)
{
return toAjax(bizPersonService.changeOrgAdmin(bizPerson.getPersonId()));
}
@Log(title = "人员", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable String[] ids)
@@ -124,7 +135,7 @@ public class BizPersonController extends BaseController
/**
* 下载 sponsor 端人员导入模板 (.xlsx)
* 模板列头按 BizPersonImportVO @Excel 注解自动生成 (中文: 姓名/手机号/工作单位/部门/职务/角色/状态)
* 模板列头按 BizPersonImportVO @Excel 注解自动生成 (中文: 姓名/手机号/部门/职务)
*/
@GetMapping("/sponsorImportTemplate")
public void sponsorImportTemplate(HttpServletResponse response)
@@ -140,9 +151,10 @@ public class BizPersonController extends BaseController
*/
@Log(title = "人员批量导入", businessType = BusinessType.INSERT)
@PostMapping("/sponsorImport")
public AjaxResult sponsorImport(@RequestParam("file") MultipartFile file) throws Exception
public AjaxResult sponsorImport(@RequestParam("file") MultipartFile file,
@RequestParam(value = "orgId", required = false) Long orgId) throws Exception
{
return doImport(file, "sponsor");
return doImport(file, "sponsor", orgId);
}
/**
@@ -152,22 +164,23 @@ public class BizPersonController extends BaseController
@GetMapping("/executorImportTemplate")
public void executorImportTemplate(HttpServletResponse response)
{
ExcelUtil<BizPersonImportVO> util = new ExcelUtil<>(BizPersonImportVO.class);
ExcelUtil<BizPersonExecutorImportVO> util = new ExcelUtil<>(BizPersonExecutorImportVO.class);
util.importTemplateExcel(response, "人员导入");
}
@Log(title = "人员批量导入", businessType = BusinessType.INSERT)
@PostMapping("/executorImport")
public AjaxResult executorImport(@RequestParam("file") MultipartFile file) throws Exception
public AjaxResult executorImport(@RequestParam("file") MultipartFile file,
@RequestParam(value = "orgId", required = false) Long orgId) throws Exception
{
return doImport(file, "executor");
return doExecutorImport(file, orgId);
}
/**
* 通用人员导入逻辑 (sponsor / executor 共用)
* @param unitType 'sponsor' 或 'executor' - 决定 orgName 匹配和 person.unitType 字段
*/
private AjaxResult doImport(MultipartFile file, String unitType) throws Exception
private AjaxResult doImport(MultipartFile file, String unitType, Long orgId) throws Exception
{
if (file == null || file.isEmpty()) {
throw new ServiceException("请选择要上传的文件");
@@ -187,16 +200,14 @@ public class BizPersonController extends BaseController
r.put("name", row.getName());
r.put("phone", row.getPhone());
try {
// 1. orgId 由 BizPersonServiceImpl.insert 按 mainUid + unitType 自动反查主账号 biz_org 兜底 (无需 Excel 提供)
// 2. role 跟 unitType 强绑定: sponsor→supervisor, executor→meetingExecutor (无需 Excel 提供)
BizPerson p = new BizPerson();
p.setName(row.getName());
p.setPhone(row.getPhone());
// p.setOrgId(orgId); ← 不再设置, service 层按 mainUserId 兜底
if (orgId != null) {
p.setOrgId(orgId);
}
p.setDepartment(row.getDepartment());
p.setPosition(row.getPosition());
p.setRole("sponsor".equals(unitType) ? "supervisor" : "meetingExecutor");
p.setStatus("0"); // 默认正常
p.setUnitType(unitType);
p.setLoginUsername(row.getPhone()); // 用户名 = 手机号 (要求唯一)
@@ -219,4 +230,62 @@ public class BizPersonController extends BaseController
ajax.put("results", results);
return ajax;
}
/**
* executor 端人员导入 (模板多一个必填「邮箱」列, 与 sponsor 分开, 不污染 sponsor 模板)
*/
private AjaxResult doExecutorImport(MultipartFile file, Long orgId) throws Exception
{
if (file == null || file.isEmpty()) {
throw new ServiceException("请选择要上传的文件");
}
ExcelUtil<BizPersonExecutorImportVO> util = new ExcelUtil<>(BizPersonExecutorImportVO.class);
List<BizPersonExecutorImportVO> rows = util.importExcel(file.getInputStream(), 0);
if (rows == null || rows.isEmpty()) {
throw new ServiceException("导入文件无有效数据");
}
Long mainUid = getUserId();
List<java.util.Map<String, Object>> results = new ArrayList<>();
int ok = 0;
for (int i = 0; i < rows.size(); i++) {
BizPersonExecutorImportVO row = rows.get(i);
java.util.Map<String, Object> r = new java.util.LinkedHashMap<>();
r.put("rowNo", i + 2);
r.put("name", row.getName());
r.put("phone", row.getPhone());
try {
if (row.getEmail() == null || row.getEmail().trim().isEmpty()) {
throw new ServiceException("邮箱不能为空");
}
BizPerson p = new BizPerson();
p.setName(row.getName());
p.setPhone(row.getPhone());
p.setEmail(row.getEmail());
p.setDepartment(row.getDepartment());
p.setPosition(row.getPosition());
p.setStatus("0");
p.setUnitType("executor");
if (orgId != null) {
p.setOrgId(orgId);
}
p.setLoginUsername(row.getPhone());
p.setLoginPassword("123456");
p.setCreateBy(getUsername());
p.setUpdateBy(getUsername());
bizPersonService.insert(p, mainUid);
ok++;
r.put("ok", true);
r.put("message", "成功");
} catch (Exception e) {
r.put("ok", false);
r.put("message", e.getMessage());
}
results.add(r);
}
AjaxResult ajax = success();
ajax.put("total", rows.size());
ajax.put("ok", ok);
ajax.put("results", results);
return ajax;
}
}
@@ -17,9 +17,11 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizExecutionIntent;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizProjectAssign;
import com.ruoyi.business.domain.BizProjectRating;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.domain.BizProjectSponsorAssign;
@@ -62,6 +64,8 @@ public class BizProjectController extends BaseController
private BizSysUserQueryMapper bizSysUserQueryMapper;
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private IBizOrgService bizOrgService;
/**
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
@@ -75,10 +79,7 @@ public class BizProjectController extends BaseController
q.setUserId(uid);
List<BizExecutionIntent> intents = bizExecutionIntentService.selectList(q);
if (intents == null || intents.isEmpty()) {
TableDataInfo r = new TableDataInfo();
r.setRows(new ArrayList<>());
r.setTotal(0);
return r;
return getDataTable(new ArrayList<>());
}
java.util.Set<String> noSet = new java.util.LinkedHashSet<>();
for (BizExecutionIntent it : intents) {
@@ -198,15 +199,30 @@ public class BizProjectController extends BaseController
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
// 后端角色兜底: 仅 admin / manager 可删项目 (前端 Projects.vue 已 v-if, 此处防 devtools 绕过)
// 后端角色兜底: 仅 admin 可删项目 (前端 Projects.vue 已 v-if, 此处防 devtools 绕过)
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或经理可删除项目");
if (!"admin".equals(roleType)) {
throw new ServiceException("只有管理员可删除项目");
}
bizProjectService.softDeleteCascadeBatch(ids);
return success();
}
/**
* 删除公告: 将 3 个公示 URL 字段置 NULL (未发布).
* 后端角色兜底: 仅 admin / manager 可删公告 (前端 Projects.vue 已 v-if, 此处防 devtools 绕过).
*/
@Log(title = "项目", businessType = BusinessType.UPDATE)
@DeleteMapping("/{projectId}/announcement")
public AjaxResult clearAnnouncement(@PathVariable("projectId") Long projectId)
{
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或经理可删除公告");
}
return toAjax(bizProjectService.clearAnnouncement(projectId));
}
// ========== 项目执行方分配 sub-resource ==========
@GetMapping("/{projectId}/assigns")
public AjaxResult getAssigns(@PathVariable("projectId") Long projectId)
@@ -220,12 +236,12 @@ public class BizProjectController extends BaseController
{
if (assigns == null) assigns = new ArrayList<>();
bizProjectAssignService.validateSum(projectId, assigns);
// #3 通知去重: 拉旧数据按 execUserId 索引, 同 (execUserId, sessions, amount) → 无变化 → 跳过
// 业务场景: manager 仅调整其他执行时, 老的 executor 不应被打扰
// #3 通知去重: 拉旧数据按 executionUnitId 索引, 同 (executionUnitId, sessions, amount) → 无变化 → 跳过
// 业务场景: manager 仅调整其他执行单位时, 老的 executor 不应被打扰
List<BizProjectAssign> oldList = bizProjectAssignService.selectByProjectId(projectId);
Map<Long, BizProjectAssign> oldByExec = new HashMap<>();
for (BizProjectAssign o : oldList) {
if (o.getExecUserId() != null) oldByExec.put(o.getExecUserId(), o);
if (o.getExecutionUnitId() != null) oldByExec.put(o.getExecutionUnitId(), o);
}
bizProjectAssignService.deleteByProjectId(projectId);
// #3 通知: 查一次项目名, 避免循环里重复查 DB
@@ -235,16 +251,18 @@ public class BizProjectController extends BaseController
a.setProjectId(projectId);
if (a.getStatus() == null) a.setStatus("0");
bizProjectAssignService.insert(a);
// 跳过空 execUserId 行 (notify 内部也会跳过, 这里直接 continue 省 lookup)
if (a.getExecUserId() == null) continue;
BizProjectAssign old = oldByExec.get(a.getExecUserId());
// 跳过空 executionUnitId 行 (notify 内部也会跳过, 这里直接 continue 省 lookup)
if (a.getExecutionUnitId() == null) continue;
BizProjectAssign old = oldByExec.get(a.getExecutionUnitId());
if (isAssignUnchanged(old, a)) {
logger.debug("[projectAssign] execUserId={} (sessions, amount) 未变, 跳过通知", a.getExecUserId());
logger.debug("[projectAssign] executionUnitId={} (sessions, amount) 未变, 跳过通知", a.getExecutionUnitId());
continue;
}
// #3 通知被分配的 executor (待办: 去承接). 业务事务回滚时通知自动回滚
// 执行单位 org_id → 主账号 MAIN user_id (biz_org.user_id) 派生
Long mainUserId = resolveMainUserIdByOrgId(a.getExecutionUnitId());
bizNotifyService.projectAssignedToExecutor(
a.getExecUserId(), projectId, projectName, a.getSessions(), a.getAmount());
mainUserId, projectId, projectName, a.getSessions(), a.getAmount());
}
return success();
}
@@ -264,6 +282,13 @@ public class BizProjectController extends BaseController
return oa.compareTo(na) == 0;
}
/** 执行单位 org_id → 主账号 MAIN user_id (biz_org.user_id), 无主账号返回 null */
private Long resolveMainUserIdByOrgId(Long orgId) {
if (orgId == null) return null;
BizOrg org = bizOrgService.getById(orgId);
return org == null ? null : org.getUserId();
}
/**
* 比较两条 sponsor 分配是否对监察员而言"未变".
* 判定维度: assignDesc + assignPoints. (projectId 隐含相同, 旧数据就是本 projectId 的)
@@ -374,7 +399,7 @@ public class BizProjectController extends BaseController
}
}
body.setCreateBy(SecurityUtils.getUsername());
body.setSponsorUserId(SecurityUtils.getUserId());
body.setSponsorOrgId(bizOrgService.selectOrgIdByUserId(SecurityUtils.getUserId()));
// 一项目支持 N 监察员: service 内一次性 delete + 逐个 insert, 不会循环 delete
int inserted = bizProjectSponsorAssignService.assignMonitorsForProject(body, mids);
@@ -427,7 +452,7 @@ public class BizProjectController extends BaseController
}
}
body.setCreateBy(SecurityUtils.getUsername());
body.setExecutorUserId(SecurityUtils.getUserId());
body.setExecutorOrgId(bizOrgService.selectOrgIdByUserId(SecurityUtils.getUserId()));
// 一项目支持 N 执行人: service 内一次性 delete + 逐个 insert, 不会循环 delete
int inserted = bizProjectExecutorAssignService.assignStaffForProject(body, sids);
return toAjax(inserted);
@@ -453,7 +478,7 @@ public class BizProjectController extends BaseController
public TableDataInfo listByRole(SysUserExtendVo vo)
{
if (vo.getRoleType() == null || vo.getRoleType().isEmpty()) {
return new TableDataInfo();
return getDataTable(new ArrayList<>());
}
startPage();
List<SysUserExtendVo> list = bizSysUserQueryMapper.selectActiveByRole(vo);
@@ -483,7 +508,7 @@ public class BizProjectController extends BaseController
throw new IllegalArgumentException("projectId / monitorUserId 必填");
}
body.setCreateBy(loginName);
body.setSponsorUserId(loginUid);
body.setSponsorOrgId(bizOrgService.selectOrgIdByUserId(loginUid));
// 通知去重: 拉旧分配, 找同 monitorUserId, 比较 assignDesc/assignPoints 是否变化
BizProjectSponsorAssign old = null;
List<BizProjectSponsorAssign> oldList = bizProjectSponsorAssignService.listByProjectId(body.getProjectId());
@@ -61,7 +61,7 @@ public class BizMeeting extends BaseEntity {
private Long projectId;
/** 项目名称 */
private String projectName;
/** 所属公司名称 (冗余字段, 由 biz_project.sponsor_admin_user_name 同步) */
/** 所属公司名称 (派生字段, 由 biz_project.sponsor_org_id JOIN biz_org.org_name 得出, 不落库) */
private String orgName;
/** 监察意见 */
private String supervisionOpinion;
@@ -72,8 +72,6 @@ public class BizMeeting extends BaseEntity {
private Date supervisionTime;
/** 材料审核阶段 (NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED, 见 MeetingAuditStageEnum) */
private String materialAuditStage;
/** 凭证审核阶段 (同上) */
private String voucherAuditStage;
/** 是否执行 0否1是 (会议开始时间到, scheduler 置1) */
private Integer isExecuted;
/** 执行时间 */
@@ -97,15 +95,15 @@ public class BizMeeting extends BaseEntity {
/** 材料最近一次审核动作时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date materialAuditTime;
/** 凭证最近一次审核动作时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date voucherAuditTime;
/** 材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */
private Integer materialComplianceApproved;
/** 凭证合规是否已通过 0否1是 */
private Integer voucherComplianceApproved;
/** 邀请函URL */
/** 提交截止时间 (建会 = end_time + 项目 submit_deadline_days 天; 退回/解冻 = now + 天数; null = 项目未设天数, 永不冻结) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submitDeadline;
/** 邀请函URL (会议级 biz_meeting.invitation_url) */
private String invitationUrl;
/** 项目邀请函URL (派生字段, 由 biz_project.invitation_url 子查询得出, 不落库; 医生会议列表用) */
private String projectInvitationUrl;
/** 日程海报URL */
private String scheduleUrl;
/** 生成的海报URL (生成海报按钮产出) */
@@ -175,6 +173,8 @@ public class BizMeeting extends BaseEntity {
public void setSupervisionTime(Date supervisionTime) { this.supervisionTime = supervisionTime; }
public String getInvitationUrl() { return invitationUrl; }
public void setInvitationUrl(String invitationUrl) { this.invitationUrl = invitationUrl; }
public String getProjectInvitationUrl() { return projectInvitationUrl; }
public void setProjectInvitationUrl(String projectInvitationUrl) { this.projectInvitationUrl = projectInvitationUrl; }
public String getScheduleUrl() { return scheduleUrl; }
public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; }
public String getPosterUrl() { return posterUrl; }
@@ -183,8 +183,6 @@ public class BizMeeting extends BaseEntity {
public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; }
public String getMaterialAuditStage() { return materialAuditStage; }
public void setMaterialAuditStage(String materialAuditStage) { this.materialAuditStage = materialAuditStage; }
public String getVoucherAuditStage() { return voucherAuditStage; }
public void setVoucherAuditStage(String voucherAuditStage) { this.voucherAuditStage = voucherAuditStage; }
public Integer getIsExecuted() { return isExecuted; }
public void setIsExecuted(Integer isExecuted) { this.isExecuted = isExecuted; }
public Date getExecuteTime() { return executeTime; }
@@ -203,12 +201,10 @@ public class BizMeeting extends BaseEntity {
public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; }
public Date getMaterialAuditTime() { return materialAuditTime; }
public void setMaterialAuditTime(Date materialAuditTime) { this.materialAuditTime = materialAuditTime; }
public Date getVoucherAuditTime() { return voucherAuditTime; }
public void setVoucherAuditTime(Date voucherAuditTime) { this.voucherAuditTime = voucherAuditTime; }
public Integer getMaterialComplianceApproved() { return materialComplianceApproved; }
public void setMaterialComplianceApproved(Integer materialComplianceApproved) { this.materialComplianceApproved = materialComplianceApproved; }
public Integer getVoucherComplianceApproved() { return voucherComplianceApproved; }
public void setVoucherComplianceApproved(Integer voucherComplianceApproved) { this.voucherComplianceApproved = voucherComplianceApproved; }
public Date getSubmitDeadline() { return submitDeadline; }
public void setSubmitDeadline(Date submitDeadline) { this.submitDeadline = submitDeadline; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getAttendeeId() { return attendeeId; }
@@ -7,7 +7,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
* 会议审核流程日志对象 biz_meeting_audit_log
* <p>
* 记录会议审核的每一次流转: 谁、什么时间、什么意见、当前阶段.
* 与 biz_meeting.material_audit_stage / voucher_audit_stage 配合, 还原审核轨迹.
* 与 biz_meeting.material_audit_stage 配合, 还原审核轨迹.
*/
public class BizMeetingAuditLog {
@@ -18,8 +18,11 @@ public class BizMeetingExecutor {
/** 会议ID */
private Long meetingId;
/** sys_user.user_id (executor 主账号, 后续支持子账号) */
private Long userId;
/** executor 企业 org_id (原 sys_user.user_id 主账号, 单一可信源) */
private Long executorOrgId;
/** 执行企业名称 (派生字段, JOIN biz_org.org_name, 不落库) */
private String orgName;
/** 分配人 user_id (审计) */
private Long assignedBy;
@@ -37,8 +40,11 @@ public class BizMeetingExecutor {
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getExecutorOrgId() { return executorOrgId; }
public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; }
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
public Long getAssignedBy() { return assignedBy; }
public void setAssignedBy(Long assignedBy) { this.assignedBy = assignedBy; }
@@ -18,8 +18,11 @@ public class BizMeetingSupervisor {
/** 会议ID */
private Long meetingId;
/** sys_user.user_id (sponsor 主账号, 后续支持子账号) */
private Long userId;
/** sponsor 企业 org_id (原 sys_user.user_id 主账号, 单一可信源) */
private Long sponsorOrgId;
/** 监察企业名称 (派生字段, JOIN biz_org.org_name, 不落库) */
private String orgName;
/** 分配人 user_id (审计) */
private Long assignedBy;
@@ -37,8 +40,11 @@ public class BizMeetingSupervisor {
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getSponsorOrgId() { return sponsorOrgId; }
public void setSponsorOrgId(Long sponsorOrgId) { this.sponsorOrgId = sponsorOrgId; }
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
public Long getAssignedBy() { return assignedBy; }
public void setAssignedBy(Long assignedBy) { this.assignedBy = assignedBy; }
@@ -29,9 +29,6 @@ public class BizPerson extends BaseEntity {
/** position */
@Excel(name = "position")
private String position;
/** role */
@Excel(name = "role")
private String role;
/** create_by */
@Excel(name = "create_by")
private String createBy;
@@ -60,6 +57,8 @@ public class BizPerson extends BaseEntity {
/** 登录账号 (来自 sys_user.user_name, 跟 accountType / parentUserId 一样仅展示, 不入库) */
@com.fasterxml.jackson.annotation.JsonProperty("account")
private String account;
/** 邮箱 (来自 sys_user.email, 展示 + 前端回传写入 sys_user, 不入 biz_person 表) */
private String email;
/** 子账号登录账号 (前端传入, 用于创建 sys_user 子账号) - 非持久化字段 */
@com.fasterxml.jackson.annotation.JsonProperty("userName")
private transient String loginUsername;
@@ -82,8 +81,6 @@ public class BizPerson extends BaseEntity {
public void setDepartment(String department) { this.department = department; }
public String getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
/** 兼容前端调用, 实际读 sys_user.delFlag */
public String getDelFlag() { return null; }
public void setDelFlag(String delFlag) { /* noop - 跟 sys_user 同步 */ }
@@ -107,6 +104,8 @@ public class BizPerson extends BaseEntity {
public void setParentUserId(Long parentUserId) { this.parentUserId = parentUserId; }
public String getAccount() { return account; }
public void setAccount(String account) { this.account = account; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getLoginUsername() { return loginUsername; }
public void setLoginUsername(String loginUsername) { this.loginUsername = loginUsername; }
public String getLoginPassword() { return loginPassword; }
@@ -0,0 +1,49 @@
package com.ruoyi.business.domain;
import com.ruoyi.common.annotation.Excel;
/**
* executor 端人员批量导入 VO (中文列头, 用 ruoyi ExcelUtil 解析)
*
* <p>区别于 sponsor 端 {@link BizPersonImportVO}: executor 多一个必填「邮箱」列,
* 列头 5 列: 姓名/手机号/邮箱/部门/职务. 所属公司由后端兜底:
* <ul>
* <li>所属公司: 由 BizPersonServiceImpl.insert 按 mainUid + unitType 自动反查主账号的 biz_org</li>
* </ul>
*
* <p>导入后端会按以下规则创建 sys_user 子账号 (parent_user_id=当前登录主账号):
* <ul>
* <li>loginUsername = 手机号 (要求唯一)</li>
* <li>loginPassword = "123456" (默认密码)</li>
* <li>email = 邮箱 (必填, 写入 sys_user.email)</li>
* <li>unitType = "executor"</li>
* </ul>
*/
public class BizPersonExecutorImportVO
{
@Excel(name = "姓名", sort = 1)
private String name;
@Excel(name = "手机号", sort = 2)
private String phone;
@Excel(name = "邮箱", sort = 3)
private String email;
@Excel(name = "部门", sort = 4)
private String department;
@Excel(name = "职务", sort = 5)
private String position;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public String getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
}
@@ -6,10 +6,9 @@ import com.ruoyi.common.annotation.Excel;
* sponsor 端人员批量导入 VO (中文列头, 用 ruoyi ExcelUtil 解析)
*
* <p>区别于 BizPerson 实体: 这里只暴露用户能填写的业务字段, 不包含内部 id/审计字段,
* 列头精简到 4 列: 姓名/手机号/部门/职务. 所属公司 + 角色 由后端兜底:
* 列头精简到 4 列: 姓名/手机号/部门/职务. 所属公司由后端兜底:
* <ul>
* <li>所属公司: 由 BizPersonServiceImpl.insert 按 mainUid + unitType 自动反查主账号的 biz_org</li>
* <li>角色: sponsor → supervisor (监察员), executor → meetingExecutor (会议执行), 跟 unitType 强绑定</li>
* </ul>
*
* <p>导入后端会按以下规则创建 sys_user 子账号 (parent_user_id=当前登录主账号):
@@ -20,7 +19,7 @@ import com.ruoyi.common.annotation.Excel;
* <li>orgId = service 层按 mainUserId + unitType 兜底 (无需 Excel 提供)</li>
* </ul>
*
* <p>历史兼容: orgName/role 字段及 setter 保留, 但不再标注 @Excel; 用户上传老模板多列也能解析, 值被忽略.
* <p>历史兼容: orgName 字段及 setter 保留, 但不再标注 @Excel; 用户上传老模板多列也能解析, 值被忽略.
*/
public class BizPersonImportVO
{
@@ -40,10 +39,6 @@ public class BizPersonImportVO
@Excel(name = "职务", sort = 4)
private String position;
// 角色: 跟 unitType 强绑定, sponsor→supervisor / executor→meetingExecutor, 无需用户填
// 保留 setter 兼容老模板, 但不再导出为 Excel 列
private String role;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
@@ -54,6 +49,4 @@ public class BizPersonImportVO
public void setDepartment(String department) { this.department = department; }
public String getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
@@ -56,12 +56,12 @@ public class BizProject extends BaseEntity {
private String sponsorAdminUserName;
/** 支持单位名称 (列表展示列, JOIN biz_org 取 org_name, org_type='sponsor') */
private String sponsorOrgName;
/** 支持单位筛选 (sys_user.user_id 列表, IN 查询, 多选 select 透传) */
private List<Long> sponsorAdminUserIds;
/** 支持单位筛选 (biz_org.org_id 列表, IN 查询, 多选 select 透传) */
private List<Long> sponsorOrgIds;
/** 服务机构名称列表, 多个用英文逗号连接 (GROUP_CONCAT 派生, 不入库, 仅展示) */
private String execOrgNames;
/** 服务机构筛选 (biz_project_assign.exec_user_id 列表, IN 查询, 多选 select 透传) */
private List<Long> execAdminUserIds;
/** 服务机构筛选 (biz_project_assign.execution_unit_id 列表, IN 查询, 多选 select 透传) */
private List<Long> execOrgIds;
/** project_form */
@Excel(name = "project_form")
private String projectForm;
@@ -71,9 +71,9 @@ public class BizProject extends BaseEntity {
/** is_settled */
@Excel(name = "is_settled")
private String isSettled;
/** 支持方负责人用户ID (sys_user.user_id, role_type=sponsor) — 原 org_id */
@Excel(name = "sponsor_admin_user_id")
private Long sponsorAdminUserId;
/** 支持方企业ID (biz_org.org_id, org_type='sponsor') — 单一可信源, 负责人 MAIN user_id 由 biz_org.user_id 派生 */
@Excel(name = "sponsor_org_id")
private Long sponsorOrgId;
/** 项目负责人 user_id (sys_user.user_id, role_type=manager 合规管理员) */
@Excel(name = "lead_user_id")
private Long leadUserId;
@@ -122,6 +122,8 @@ public class BizProject extends BaseEntity {
private String supportLetterUrl;
/** 已发布公告URL */
private String publishUrl;
/** 日程文件URL */
private String scheduleUrl;
/** 是否已发布公示 0否 1是 */
private String isPublished;
/** 发布时间 */
@@ -129,6 +131,11 @@ public class BizProject extends BaseEntity {
private Date publishTime;
/** 公告类型 (邀请函/支持函/通知/日程/公示) */
private String announcementType;
/** 开通截止时间 (date, 到期后由 OpenStatusScheduler 置 open_status=N) */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date openDeadline;
/** 开通状态 Y/N (默认 N; Y 由经理"开通"置位, 到期回收为 N) */
private String openStatus;
/* ============ 支持方评分字段 (来自 biz_project_sponsor 中间表, 仅展示用) ============ */
/** 当前 login 用户对该项目的平均分 */
@@ -176,20 +183,20 @@ public class BizProject extends BaseEntity {
public void setSponsorAdminUserName(String sponsorAdminUserName) { this.sponsorAdminUserName = sponsorAdminUserName; }
public String getSponsorOrgName() { return sponsorOrgName; }
public void setSponsorOrgName(String sponsorOrgName) { this.sponsorOrgName = sponsorOrgName; }
public List<Long> getSponsorAdminUserIds() { return sponsorAdminUserIds; }
public void setSponsorAdminUserIds(List<Long> sponsorAdminUserIds) { this.sponsorAdminUserIds = sponsorAdminUserIds; }
public List<Long> getSponsorOrgIds() { return sponsorOrgIds; }
public void setSponsorOrgIds(List<Long> sponsorOrgIds) { this.sponsorOrgIds = sponsorOrgIds; }
public String getExecOrgNames() { return execOrgNames; }
public void setExecOrgNames(String execOrgNames) { this.execOrgNames = execOrgNames; }
public List<Long> getExecAdminUserIds() { return execAdminUserIds; }
public void setExecAdminUserIds(List<Long> execAdminUserIds) { this.execAdminUserIds = execAdminUserIds; }
public List<Long> getExecOrgIds() { return execOrgIds; }
public void setExecOrgIds(List<Long> execOrgIds) { this.execOrgIds = execOrgIds; }
public String getProjectForm() { return projectForm; }
public void setProjectForm(String projectForm) { this.projectForm = projectForm; }
public String getIsFinished() { return isFinished; }
public void setIsFinished(String isFinished) { this.isFinished = isFinished; }
public String getIsSettled() { return isSettled; }
public void setIsSettled(String isSettled) { this.isSettled = isSettled; }
public Long getSponsorAdminUserId() { return sponsorAdminUserId; }
public void setSponsorAdminUserId(Long sponsorAdminUserId) { this.sponsorAdminUserId = sponsorAdminUserId; }
public Long getSponsorOrgId() { return sponsorOrgId; }
public void setSponsorOrgId(Long sponsorOrgId) { this.sponsorOrgId = sponsorOrgId; }
public Long getLeadUserId() { return leadUserId; }
public void setLeadUserId(Long leadUserId) { this.leadUserId = leadUserId; }
public String getLeadUserName() { return leadUserName; }
@@ -228,12 +235,18 @@ public class BizProject extends BaseEntity {
public void setSupportLetterUrl(String supportLetterUrl) { this.supportLetterUrl = supportLetterUrl; }
public String getPublishUrl() { return publishUrl; }
public void setPublishUrl(String publishUrl) { this.publishUrl = publishUrl; }
public String getScheduleUrl() { return scheduleUrl; }
public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; }
public String getIsPublished() { return isPublished; }
public void setIsPublished(String isPublished) { this.isPublished = isPublished; }
public Date getPublishTime() { return publishTime; }
public void setPublishTime(Date publishTime) { this.publishTime = publishTime; }
public String getAnnouncementType() { return announcementType; }
public void setAnnouncementType(String announcementType) { this.announcementType = announcementType; }
public Date getOpenDeadline() { return openDeadline; }
public void setOpenDeadline(Date openDeadline) { this.openDeadline = openDeadline; }
public String getOpenStatus() { return openStatus; }
public void setOpenStatus(String openStatus) { this.openStatus = openStatus; }
public BigDecimal getSponsorRating() { return sponsorRating; }
public void setSponsorRating(BigDecimal sponsorRating) { this.sponsorRating = sponsorRating; }
public Integer getSponsorQ1() { return sponsorQ1; }
@@ -14,10 +14,8 @@ public class BizProjectAssign extends BaseEntity {
private String assignId;
/** project_id */
private Long projectId;
/** 执行单位ID (FK: biz_org.org_id, NOT NULL) - service 层从 execUserId 反查 biz_org 写入 */
/** 执行单位ID (FK: biz_org.org_id, NOT NULL) - 业务主键, 前端直接传, 单一可信源 */
private Long executionUnitId;
/** 执行方用户ID (sys_user.user_id, executor 主账号) - 业务主键,前端直接传 */
private Long execUserId;
/** 分配场次 */
private Integer sessions;
/** 分配金额 */
@@ -35,8 +33,6 @@ public class BizProjectAssign extends BaseEntity {
public void setProjectId(Long projectId) { this.projectId = projectId; }
public Long getExecutionUnitId() { return executionUnitId; }
public void setExecutionUnitId(Long executionUnitId) { this.executionUnitId = executionUnitId; }
public Long getExecUserId() { return execUserId; }
public void setExecUserId(Long execUserId) { this.execUserId = execUserId; }
public Integer getSessions() { return sessions; }
public void setSessions(Integer sessions) { this.sessions = sessions; }
public BigDecimal getAmount() { return amount; }
@@ -9,8 +9,8 @@ public class BizProjectExecutorAssign extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
private String projectId;
/** 执行方主账号 user_id (分配人, 审计) */
private Long executorUserId;
/** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 executor_user_id) */
private Long executorOrgId;
/** 执行人 user_id (被分配) */
private Long staffUserId;
/** 多个执行人 userId (前端 multi-select 传入, 控制器循环 insert, 不入库) */
@@ -35,8 +35,8 @@ public class BizProjectExecutorAssign extends BaseEntity {
public void setId(Long id) { this.id = id; }
public String getProjectId() { return projectId; }
public void setProjectId(String projectId) { this.projectId = projectId; }
public Long getExecutorUserId() { return executorUserId; }
public void setExecutorUserId(Long executorUserId) { this.executorUserId = executorUserId; }
public Long getExecutorOrgId() { return executorOrgId; }
public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; }
public Long getStaffUserId() { return staffUserId; }
public void setStaffUserId(Long staffUserId) { this.staffUserId = staffUserId; }
public java.util.List<Long> getStaffUserIds() { return staffUserIds; }
@@ -30,6 +30,8 @@ public class BizProjectPlan extends BaseEntity {
/** design_file_url */
@Excel(name = "design_file_url")
private String designFileUrl;
/** 学科方向 */
private String subjectDirection;
/** status */
@Excel(name = "status")
private String status;
@@ -91,6 +93,8 @@ public class BizProjectPlan extends BaseEntity {
public void setProjectForm(String projectForm) { this.projectForm = projectForm; }
public String getDesignFileUrl() { return designFileUrl; }
public void setDesignFileUrl(String designFileUrl) { this.designFileUrl = designFileUrl; }
public String getSubjectDirection() { return subjectDirection; }
public void setSubjectDirection(String subjectDirection) { this.subjectDirection = subjectDirection; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getIsSettled() { return isSettled; }
@@ -9,7 +9,8 @@ public class BizProjectSponsorAssign extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
private String projectId;
private Long sponsorUserId;
/** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 sponsor_user_id) */
private Long sponsorOrgId;
private Long monitorUserId;
/** 多个监察员 userId (前端 multi-select 传入, 控制器循环 insert, 不入库) */
@com.fasterxml.jackson.annotation.JsonProperty("monitorUserIds")
@@ -33,8 +34,8 @@ public class BizProjectSponsorAssign extends BaseEntity {
public void setId(Long id) { this.id = id; }
public String getProjectId() { return projectId; }
public void setProjectId(String projectId) { this.projectId = projectId; }
public Long getSponsorUserId() { return sponsorUserId; }
public void setSponsorUserId(Long sponsorUserId) { this.sponsorUserId = sponsorUserId; }
public Long getSponsorOrgId() { return sponsorOrgId; }
public void setSponsorOrgId(Long sponsorOrgId) { this.sponsorOrgId = sponsorOrgId; }
public Long getMonitorUserId() { return monitorUserId; }
public void setMonitorUserId(Long monitorUserId) { this.monitorUserId = monitorUserId; }
public java.util.List<Long> getMonitorUserIds() { return monitorUserIds; }
@@ -1,6 +1,7 @@
package com.ruoyi.business.domain.vo;
import java.math.BigDecimal;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.common.annotation.Excel;
/**
@@ -134,4 +135,30 @@ public class BizMeetingAttendeeImportVo {
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getIdCardAttachments() { return idCardAttachments; }
public void setIdCardAttachments(String idCardAttachments) { this.idCardAttachments = idCardAttachments; }
/** 从参会人实体转导出 VO (与 /export 端点、劳务下载打包共用同一列映射) */
public static BizMeetingAttendeeImportVo from(BizMeetingAttendee a) {
BizMeetingAttendeeImportVo v = new BizMeetingAttendeeImportVo();
if (a == null) return v;
v.setName(a.getName());
v.setPhone(a.getPhone());
v.setWorkUnit(a.getWorkUnit());
v.setDepartment(a.getDepartment());
v.setTitle(a.getTitle());
v.setIdCard(a.getIdCard());
v.setBankName(a.getBankName());
v.setBankCard(a.getBankCard());
v.setBankBranch(a.getBankBranch());
v.setAccountName(a.getAccountName());
v.setBankRegion(a.getBankRegion());
v.setBankAddress(a.getBankAddress());
v.setIdCardAttachments(a.getIdCardAttachments());
v.setLaborForm(a.getLaborForm());
v.setFeePreTax(a.getFeePreTax());
v.setTax(a.getTax());
v.setVatAndSurcharge(a.getVatAndSurcharge());
v.setFee(a.getFee());
v.setSummary(a.getSummary());
return v;
}
}
@@ -0,0 +1,59 @@
package com.ruoyi.business.domain.vo;
import com.ruoyi.common.annotation.Excel;
/**
* 支持单位导入 VO
*
* <p>仅用于 Excel 批量导入 / 模板下载, 不参与业务逻辑.
* 字段顺序与 Excel 列一致, 调整时同步修改模板下载体验.
*
* <p>只导入 org (biz_org), <b>不新增人员</b> (不建 sys_user / biz_person),
* 因此不包含登录账号相关字段. 后端固定默认值:
* <ul>
* <li>orgType — 固定 'sponsor' (本入口是支持单位管理页)</li>
* <li>status — 默认 '0' (正常)</li>
* <li>userId — 不设置 (无主账号, 后续单独在人员管理里关联)</li>
* </ul>
*
* @author guoju
*/
public class BizOrgImportVo {
/** 公司名称 (必填) */
@Excel(name = "公司名称", sort = 1)
private String orgName;
/** 税号/统一社会信用代码 */
@Excel(name = "税号/统一社会信用代码", sort = 2)
private String taxNo;
/** 企业性质 私营/国营/中外合资/外资/其他 */
@Excel(name = "企业性质", sort = 3)
private String businessNature;
/** 公司地址 */
@Excel(name = "公司地址", sort = 4)
private String address;
/** 联系人 */
@Excel(name = "联系人", sort = 5)
private String contactName;
/** 联系电话 */
@Excel(name = "联系电话", sort = 6)
private String contactPhone;
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
public String getTaxNo() { return taxNo; }
public void setTaxNo(String taxNo) { this.taxNo = taxNo; }
public String getBusinessNature() { return businessNature; }
public void setBusinessNature(String businessNature) { this.businessNature = businessNature; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getContactName() { return contactName; }
public void setContactName(String contactName) { this.contactName = contactName; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
}
@@ -14,9 +14,6 @@ public interface BizMeetingExecutorMapper {
/** 按会议ID查该会议的所有执行人员 */
List<BizMeetingExecutor> selectByMeetingId(Long meetingId);
/** 按 userId 查该执行人员被分配到哪些会议 */
List<BizMeetingExecutor> selectByUserId(Long userId);
/** 条件查询 */
List<BizMeetingExecutor> selectList(BizMeetingExecutor entity);
@@ -14,6 +14,10 @@ public interface BizMeetingMaterialMapper {
/** 按会议ID查该会议所有材料记录 */
List<BizMeetingMaterial> selectByMeetingId(Long meetingId);
/** 按 (meetingId, subType) 查单条 (subType 会议内唯一), 用于批量插入后查回真实 id */
BizMeetingMaterial selectByMeetingAndSubType(@org.apache.ibatis.annotations.Param("meetingId") Long meetingId,
@org.apache.ibatis.annotations.Param("subType") String subType);
/** 插入 (id 走 DB AUTO_INCREMENT, 不接受前端传入的 id) */
int insert(BizMeetingMaterial entity);
@@ -37,4 +41,13 @@ public interface BizMeetingMaterialMapper {
/** 单条更新 fee_status (OCR 完成/材料保存时设置 0未算 1已算) */
int updateFeeStatus(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("feeStatus") Integer feeStatus);
/**
* 会务材料"打包上传"回填: 替换单条材料的文件 URL/文件名, 并清空金额(待重新 OCR) + 置 fee_status.
* 与 updateByPrimaryKey 不同, 这里显式把 amount 置 null (updateByPrimaryKey 的 if 会跳过 null).
*/
int updateFile(@org.apache.ibatis.annotations.Param("id") Long id,
@org.apache.ibatis.annotations.Param("ossUrl") String ossUrl,
@org.apache.ibatis.annotations.Param("fileName") String fileName,
@org.apache.ibatis.annotations.Param("feeStatus") Integer feeStatus);
}
@@ -14,9 +14,6 @@ public interface BizMeetingSupervisorMapper {
/** 按会议ID查该会议的所有监察员 */
List<BizMeetingSupervisor> selectByMeetingId(Long meetingId);
/** 按 userId 查该监察员被分配到哪些会议 */
List<BizMeetingSupervisor> selectByUserId(Long userId);
/** 条件查询 */
List<BizMeetingSupervisor> selectList(BizMeetingSupervisor entity);
@@ -10,18 +10,25 @@ public interface BizOrgMapper {
int insert(BizOrg entity);
int updateByPrimaryKey(BizOrg entity);
int deleteByPrimaryKeys(Long[] orgIds);
/** 支持方下拉选项 (JOIN sys_user.user_name), 用于 manager 项目分配弹窗 */
/** 支持方下拉选项 (JOIN sys_user.user_name), 用于 manager 项目分配弹窗, 返回 orgId/orgName/userName */
List<Map<String, Object>> selectSponsorOrgOptions(BizOrg entity);
/** 执行方下拉选项 (JOIN sys_user MAIN 账号 user_name), 用于 manager 项目分配弹窗
* 返回 Map: userId / orgName / userName
* 返回 Map: orgId / orgName / userName
* 过滤条件: orgType='executor' + sys_user MAIN 账号 (parent_user_id IS NULL)
* 注意: value 必须 MAIN user_id (写入 biz_project_assign.exec_user_id);
* 注意: value 必须 org_id (写入 biz_project_assign.execution_unit_id);
* label 必须 org_name, 不用 user_name 以避免把同公司的普通员工带出来 */
List<Map<String, Object>> selectExecutorOrgOptions(BizOrg entity);
/** 支持方注册下拉选项 (匿名公开, register-sponsor 选企业注册 SUB 子账号)
* 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId
* 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
/** 当前登录 sponsor 的所属公司
* 主账号 (sys_user.parent_user_id IS NULL): biz_org.user_id = #{userId}
* 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id
* 返回 Map: orgId / orgName / isOwner (1=主账号, 0=子账号)
* 用于 /sponsor/account 页面回显 + 主账号修改 org_name */
Map<String, Object> selectMySponsorCompany(Long userId);
/** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id)
* MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */
Long selectOrgIdByUserId(Long userId);
}
@@ -1,6 +1,5 @@
package com.ruoyi.business.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizPerson;
/**
@@ -20,6 +19,4 @@ public interface BizPersonMapper
int deleteByPrimaryKeys(String[] personIds);
/** 联动: person 逻辑删除后, 把对应 sys_user 子账号也逻辑删除 */
int softDeleteSysUserByPersonIds(String[] personIds);
/** 校验某 org 下是否已有 admin 角色 (注册时兜底, 保证一公司一管理员) */
int countAdminByOrgId(@Param("orgId") Long orgId);
}
@@ -34,4 +34,8 @@ public interface BizProjectMapper
* 并重算 available_amount = total_amount - manage_fee - 已支付劳务 - 已支付会务 (幂等, 无累计副作用).
*/
int recomputeSettledAmounts(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
/** 删除公告: 将 invitation_url / support_letter_url / publish_url 置 NULL */
int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus();
}
@@ -45,7 +45,7 @@ public class OcrProperties {
/** 整流程 OCR 超时 (秒) */
private int totalTimeoutS = 60;
/** QR 命中后是否继续跑全量 OCR + 字段抽取 (false = 快路径, 只返回 QR 3 字段) */
private boolean qrFullOcr = true;
private boolean qrFullOcr = false;
/** OCR 语言: ch (简中) / en / chinese_cht */
private String lang = "ch";
/** 模型目录: 相对路径 → classpath:models/, 绝对路径 → 直读 */
@@ -284,6 +284,8 @@ public class RecognizeService {
private InvoiceResult buildQrOnlyResult(QrDecodeResult qr, int elapsedMs, int pageCount) {
InvoiceFields fields = new InvoiceFields();
// QR 命中即证明是电子发票; isRecognizedAsInvoice 门控要求 invoiceType 非空, 这里补上类型
fields.setInvoiceType("电子发票");
fields.setInvoiceNo(qr.getInvoiceNo());
fields.setAmount(qr.getAmount());
fields.setInvoiceDate(qr.getInvoiceDate());
@@ -22,6 +22,7 @@ public class OssConfMeta
private final String bucket;
private final String accessKeyId;
private final String accessKeySecret;
private final String zipFuncUrl;
public OssConfMeta(RuoYiConfig cfg)
{
@@ -33,6 +34,7 @@ public class OssConfMeta
this.bucket = p.getBucket();
this.accessKeyId = p.getAccessKeyId();
this.accessKeySecret = p.getAccessKeySecret();
this.zipFuncUrl = p.getZipFuncUrl();
// endpoint: 剥协议头 + 剥 bucket 前缀 → 纯 OSS endpoint (OSSClient 构造/URL 拼接都要求裸 endpoint, 不带 bucket)
this.endpoint = stripBucketPrefix(stripScheme(p.getEndpoint()), this.bucket);
}
@@ -65,4 +67,5 @@ public class OssConfMeta
public String getBucket() { return bucket; }
public String getAccessKeyId() { return accessKeyId; }
public String getAccessKeySecret() { return accessKeySecret; }
public String getZipFuncUrl() { return zipFuncUrl; }
}
@@ -0,0 +1,177 @@
package com.ruoyi.business.oss;
import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.aliyun.oss.OSSClient;
import com.aliyun.oss.model.DeleteObjectsRequest;
import com.aliyun.oss.model.DeleteObjectsResult;
import com.aliyun.oss.model.ObjectListing;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.OSSObjectSummary;
import com.ruoyi.common.exception.ServiceException;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
/**
* 阿里云 OSS 打 zip (复用 hwt-serve 的阿里云函数计算 FC 端点)
* <p>
* 思路 (与 hwt-serve OssApi.ossDownload 一致): 不在 Java 里用 ZipOutputStream 打 zip,
* 而是把"在 OSS 端打包"外包给阿里云函数计算 (FC). Java 只做:
* <ol>
* <li>把要打包的文件先 copy 到一个 staging 前缀 (目录结构 = 最终 zip 内目录结构)</li>
* <li>POST {bucket, source-dir} 到 FC 端点</li>
* <li>读 301/302 的 Location 头, 拿到 zip 签名下载 URL</li>
* </ol>
* 与 hwt-serve 的差异: 不再用 unirest (本项目无此依赖), 改用 hutool-http.
*/
@Slf4j
@Component
public class OssZipService
{
@Autowired
private OssConfMeta ossConfMeta;
private OSSClient newClient()
{
return new OSSClient(
ossConfMeta.getEndpoint(),
ossConfMeta.getAccessKeyId(),
ossConfMeta.getAccessKeySecret());
}
/**
* 从完整 ossUrl 提取 object key (去掉 https://{bucket}.{endpoint}/ 前缀, 顺带去掉 query)
* 例: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com/ry8080/meeting/1/service/xxx.jpg
* → ry8080/meeting/1/service/xxx.jpg
*/
public String extractKey(String ossUrl)
{
if (ossUrl == null) return null;
String u = ossUrl;
int q = u.indexOf('?');
if (q >= 0) u = u.substring(0, q);
String httpsHost = "https://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/";
if (u.startsWith(httpsHost)) return u.substring(httpsHost.length());
String httpHost = "http://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/";
if (u.startsWith(httpHost)) return u.substring(httpHost.length());
// 兜底: 取 "://" 之后第一个 "/" 之后的部分 (CNAME / 自定义域名)
int i = u.indexOf("://");
if (i >= 0)
{
int slash = u.indexOf('/', i + 3);
if (slash >= 0) return u.substring(slash + 1);
}
return u;
}
/** OSS 服务端 copy (同 bucket 内), 用于把散落的材料文件复制到 staging 前缀, 不经过 Java 内存 */
public void copyObject(String srcKey, String dstKey)
{
OSSClient client = newClient();
try
{
client.copyObject(ossConfMeta.getBucket(), srcKey, ossConfMeta.getBucket(), dstKey);
}
finally
{
client.shutdown();
}
}
/** 上传字节到指定 OSS key (把后端生成的参会人信息 Excel 直接放进 staging 前缀, 不经过本地文件) */
public void putObject(String key, byte[] data, String contentType)
{
OSSClient client = newClient();
try
{
ObjectMetadata meta = new ObjectMetadata();
meta.setContentType(contentType);
client.putObject(ossConfMeta.getBucket(), key, new ByteArrayInputStream(data), meta);
}
finally
{
client.shutdown();
}
}
/** 删除某前缀下所有对象 (staging 清场, 避免上次下载的残留文件混进本次 zip) */
public void clearPrefix(String prefix)
{
OSSClient client = newClient();
try
{
ObjectListing listing = client.listObjects(ossConfMeta.getBucket(), prefix);
List<String> keys = new ArrayList<>();
for (OSSObjectSummary s : listing.getObjectSummaries())
{
keys.add(s.getKey());
}
if (!keys.isEmpty())
{
DeleteObjectsResult r = client.deleteObjects(
new DeleteObjectsRequest(ossConfMeta.getBucket()).withKeys(keys));
log.info("[OSS-ZIP] 清场 {} 个对象, 前缀 {}", r.getDeletedObjects().size(), prefix);
}
}
finally
{
client.shutdown();
}
}
/**
* 调阿里云 FC 在 OSS 端打 zip, 返回 zip 的签名下载 URL.
* <p>
* FC 打完后返回 301/302, Location 头即 zip 签名 URL; unirest/hutool 默认不跟随重定向,
* 所以能直接读到 Location. http→https 强转防浏览器 Mixed Content (OSS 双协议都支持).
*/
public String zipDownload(String sourceDir)
{
String funcUrl = ossConfMeta.getZipFuncUrl();
if (funcUrl == null || funcUrl.isEmpty())
{
throw new ServiceException("OSS 打 zip 端点未配置 (ruoyi.oss.zip-func-url)");
}
Map<String, Object> body = new HashMap<>();
body.put("bucket", ossConfMeta.getBucket());
body.put("source-dir", sourceDir);
log.info("[OSS-ZIP] 请求 FC 打包 bucket={} source-dir={}", ossConfMeta.getBucket(), sourceDir);
HttpResponse resp;
try
{
resp = HttpRequest.post(funcUrl)
.body(JSONUtil.toJsonStr(body), "application/json")
.setFollowRedirects(false)
.timeout(60000)
.execute();
}
catch (Exception e)
{
throw new ServiceException("OSS 打 zip 请求失败: " + e.getMessage());
}
int status = resp.getStatus();
String location = resp.header("Location");
if (status != 301 && status != 302)
{
log.error("[OSS-ZIP] FC 响应异常 status={}", status);
throw new ServiceException("打包失败, FC 响应状态 " + status);
}
if (location == null || location.isEmpty())
{
throw new ServiceException("打包失败, 未获取到下载链接");
}
return location.replaceFirst("^http://", "https://");
}
}
@@ -10,12 +10,12 @@ import lombok.extern.slf4j.Slf4j;
* 会议事实/阶段 自动流转调度器 (每分钟一次).
* <p>
* 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实
* (is_executed / is_frozen / material_audit_stage / voucher_audit_stage / 审核时间 …),
* (is_executed / is_frozen / material_audit_stage / 审核时间 …),
* 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的三类事实落地:
* <pre>
* 1) start_time 到 → is_executed=1 (执行中)
* 2) end_time + submit_deadline_days 到 且 material 未提交 → is_frozen=1 (冻结)
* 3) material+voucher 都通过 且 最晚审核时间过 24h → 待结算 (current_stage 缓存翻 AWAITING_SETTLEMENT)
* 3) material 通过 且 审核时间过 24h → 待结算 (current_stage 缓存翻 AWAITING_SETTLEMENT)
* </pre>
* 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围.
* <p>
@@ -0,0 +1,42 @@
package com.ruoyi.business.scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.ruoyi.business.service.IBizProjectService;
import lombok.extern.slf4j.Slf4j;
/**
* 项目"开通"到期回收调度器 (每天 00:05 一次).
* <p>
* 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline &lt;= 今天)回收为 N.
* 结题(is_finished=1)且 open_status=N 的项目, sponsor 不可见
* (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤).
* <p>
* 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有).
*/
@Slf4j
@Component
public class OpenStatusScheduler
{
@Autowired
private IBizProjectService bizProjectService;
/** 每天 00:05:00 执行一次 */
@Scheduled(cron = "0 5 0 * * ?")
public void closeExpiredOpenStatus()
{
try
{
int n = bizProjectService.closeExpiredOpenStatus();
if (n > 0)
{
log.info("[OpenStatusScheduler] 开通到期回收 {} 个项目", n);
}
}
catch (Exception e)
{
log.warn("[OpenStatusScheduler] 回收异常 (跳过, 明天再试)", e);
}
}
}
@@ -84,4 +84,55 @@ public interface IBizMeetingAttendeeService {
* @return 成功邀请 (站内信+标记) 的人数
*/
int invite(List<Long> attendeeIds);
/**
* 生成"参会人信息" Excel 字节 (劳务下载打包用).
* 复用 BizMeetingAttendeeImportVo 的 @Excel 列头, 与 /export 端点同列.
*
* @param list 已查出的参会人 (is_deleted=0)
* @return xlsx 字节
*/
byte[] buildAttendeeInfoExcel(List<BizMeetingAttendee> list);
/**
* 生成"劳务协议"空目录模板 zip (参会人管理 "下载协议模板" 按钮).
* <p>
* zip 内为 劳务协议/{序号}_{姓名}/ 空目录, 每个参会人一个; 序号 = 列表 1 起下标
* (与前端表格 type="index" 一致, 依赖 selectByMeetingId 的 order by id 稳定排序).
*
* @param meetingId 会议 id
* @return zip 字节 (UTF-8 目录名)
*/
byte[] buildAgreementTemplateZip(Long meetingId);
/**
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人,
* 取目录内第一个文件上传 OSS 并回填 labor_protocol (覆盖式).
*
* @param file 用户重新压缩的 zip
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
* @return 成功回填的参会人数
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
*/
int uploadAgreements(MultipartFile file, Long meetingId) throws Exception;
/**
* 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
* zip 内为 专家照片/{序号}_{姓名}/ 空目录, 结构与劳务协议模板一致.
*
* @param meetingId 会议 id
* @return zip 字节 (UTF-8 目录名)
*/
byte[] buildExpertPhotoTemplateZip(Long meetingId);
/**
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人,
* 目录内所有文件上传 OSS 后逗号拼接, 覆盖式回填 on_site_photos.
*
* @param file 用户重新压缩的 zip
* @param meetingId 会议 id
* @return 成功回填的参会人数
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
*/
int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
}
@@ -12,11 +12,10 @@ public interface IBizMeetingExecutorService {
List<BizMeetingExecutor> selectByMeetingId(Long meetingId);
List<BizMeetingExecutor> selectByUserId(Long userId);
/**
* 替换该会议的执行人员 (全删全插, 一个事务)
* @param orgIds 执行方企业 org_id 列表
* @param assignedBy 分配人 user_id
*/
int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy);
int replaceByMeetingId(Long meetingId, List<Long> orgIds, Long assignedBy);
}
@@ -1,6 +1,7 @@
package com.ruoyi.business.service;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizMeetingMaterial;
/**
@@ -46,4 +47,59 @@ public interface IBizMeetingMaterialService {
* 白名单 subType + 会议存在校验 (公开端点防滥用).
*/
void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl);
/**
* 结算时保存付款凭证 (LV_PAYMENT + SV_PAYMENT, 合规人员上传).
* 只清/插这两个 subType, 不碰其他材料; 付款凭证非发票, 不触发 OCR, 不影响会议费用.
* 与 replaceByMeetingId 不同, 这里不做金额回填 (凭证无金额).
*/
void saveVouchers(Long meetingId, List<BizMeetingMaterial> vouchers);
/**
* 把该会议所有"会务"材料 (SERVICE + SERVICE_VOUCHER) 在 OSS 端打成一个 zip, 返回下载 URL.
* <p>
* zip 内目录结构: {项目名称}/{材料类型中文名}/{材料文件}.
* 实现: 先 staging copy 到 download/huiwu/{meetingId}/ 前缀 (目录结构即 zip 结构),
* 再调阿里云 FC 在 OSS 端打 zip.
*/
String buildServiceZipUrl(Long meetingId);
/**
* 把该会议所有"劳务"材料 (LABOR + LABOR_VOUCHER) + 参会人信息 Excel 在 OSS 端打成一个 zip, 返回下载 URL.
* 前全景/后全景两张图归入"会议现场全景"目录.
*/
String buildLaborZipUrl(Long meetingId);
/**
* 批量会务下载: 多个会议的会务材料合并打一个 zip, 返回下载 URL.
* meetingIds 去重去空, 上限 20 个; 单个会议无材料跳过, 全部无材料抛异常.
*/
String buildBatchServiceZipUrl(List<Long> meetingIds);
/**
* 批量劳务下载: 多个会议的劳务材料(含参会人信息)合并打一个 zip, 返回下载 URL.
*/
String buildBatchLaborZipUrl(List<Long> meetingIds);
/**
* 生成"会务材料"空目录模板 zip (会务材料 tab "打包上传" 的"下载目录"按钮).
* <p>
* zip 内为 会务材料/{材料类型中文名}/ 空目录 (物料制作/酒店/大交通/小交通/执行费/设计费/其他/总结算单/总发票),
* 用户把材料文件放进对应目录后重新压缩上传.
*
* @param meetingId 会议 id
* @return zip 字节 (UTF-8 目录名)
*/
byte[] buildServiceTemplateZip(Long meetingId);
/**
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType,
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填.
*
* @param file 用户重新压缩的 zip
* @param meetingId 会议 id (决定 OSS 子目录)
* @return 成功回填的材料类数
* @throws Exception 文件为空 / zip 解析异常 / 未找到有效目录
*/
int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception;
}
@@ -12,11 +12,10 @@ public interface IBizMeetingSupervisorService {
List<BizMeetingSupervisor> selectByMeetingId(Long meetingId);
List<BizMeetingSupervisor> selectByUserId(Long userId);
/**
* 替换该会议的监察员 (全删全插, 一个事务)
* @param orgIds 支持方企业 org_id 列表
* @param assignedBy 分配人 user_id (前端 / manager 自己)
*/
int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy);
int replaceByMeetingId(Long meetingId, List<Long> orgIds, Long assignedBy);
}
@@ -2,7 +2,9 @@ package com.ruoyi.business.service;
import java.util.List;
import java.util.Map;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.dto.ImportResult;
public interface IBizOrgService {
BizOrg getById(Long orgId);
@@ -10,14 +12,19 @@ public interface IBizOrgService {
int insert(BizOrg entity);
int updateByPrimaryKey(BizOrg entity);
int deleteByPrimaryKeys(Long[] orgIds);
/** 支持方下拉选项 (userId/orgName/userName), 用于 manager 项目分配弹窗 */
/** 支持方下拉选项 (orgId/orgName/userName), 用于 manager 项目分配弹窗 */
List<Map<String, Object>> selectSponsorOrgOptions(BizOrg entity);
/** 执行方下拉选项 (userId/orgName/userName), 用于 manager 项目分配弹窗
/** 执行方下拉选项 (orgId/orgName/userName), 用于 manager 项目分配弹窗
* 只返回 biz_org.org_type='executor' 对应的 MAIN 账号 (parent_user_id IS NULL) */
List<Map<String, Object>> selectExecutorOrgOptions(BizOrg entity);
/** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
/** 当前登录 sponsor 的所属公司 (主账号可改, 子账号只读) */
Map<String, Object> selectMySponsorCompany(Long userId);
/** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */
Long selectOrgIdByUserId(Long userId);
/**
* 启用/禁用 org, 同步主账号 sys_user.status
* biz_org.status: '禁用' → sys_user.status='1', '正常'/'合作中' → '0'
@@ -25,4 +32,11 @@ public interface IBizOrgService {
* (子账号登录时由 BizAuthController 校验主账号状态拦截)
*/
int toggleStatus(Long orgId, String newBizOrgStatus);
/**
* 批量导入支持单位 (只导 org, 不新增人员)
* orgType 固定 'sponsor', status 固定 '0', 不建 sys_user (userId 留空)
* 返回 {@link ImportResult} 含成功/失败计数 + 失败明细 (行号 + 原因)
*/
ImportResult importOrg(MultipartFile file, String operName) throws Exception;
}
@@ -23,4 +23,9 @@ public interface IBizPersonService
int updateByPrimaryKey(BizPerson entity);
int deleteByPrimaryKey(String personId);
int deleteByPrimaryKeys(String[] personId);
/**
* 更换机构管理员: 把 personId 对应人员晋升为该机构 MAIN (管理员),
* 原管理员由 MAIN 降为 SUB, 其余子账号 re-point 到新管理员, biz_org.user_id 同步指向新管理员
*/
int changeOrgAdmin(String personId);
}
@@ -45,4 +45,10 @@ public interface IBizProjectService
* 并重算 available_amount. 幂等 (每次全量重算), 无累计副作用.
*/
void recomputeSettledAmounts(Long projectId);
/** 删除公告: 将 invitation_url / support_letter_url / publish_url 置 NULL (未发布) */
int clearAnnouncement(Long projectId);
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus();
}
@@ -1,6 +1,5 @@
package com.ruoyi.business.service;
import java.util.Date;
import org.springframework.stereotype.Component;
import com.ruoyi.business.domain.BizMeeting;
@@ -8,7 +7,7 @@ import com.ruoyi.business.domain.BizMeeting;
* 会议阶段推导器 (单一可信源).
* <p>
* 事实与展示分离: {@code biz_meeting} 只存事实 (is_executed/is_settled/is_finished/is_frozen
* + material_audit_stage/voucher_audit_stage + 审核时间 + compliance_approved), 各角色看到的
* + material_audit_stage + 审核时间 + compliance_approved), 各角色看到的
* 「阶段名称」由本类实时计算.
* <ul>
* <li>{@link #derivePhysicalStage(BizMeeting)}: 10 值物理阶段 (current_stage 缓存 + 列表筛选).</li>
@@ -22,20 +21,12 @@ public class StageDeriver
{
private static final long H24 = 24L * 3600 * 1000;
/** 待结算: 材料+凭证都审核通过 且 最晚通过时间已超 24h (用户拍板口径) */
/** 待结算: 材料审核通过 且 材料审核时间已超 24h (用户拍板口径) */
private boolean settlementReady(BizMeeting m)
{
if (!"APPROVED".equals(m.getVoucherAuditStage())) return false;
Date later = later(m.getMaterialAuditTime(), m.getVoucherAuditTime());
if (later == null) return false;
return System.currentTimeMillis() - later.getTime() >= H24;
}
private Date later(Date a, Date b)
{
if (a == null) return b;
if (b == null) return a;
return a.after(b) ? a : b;
if (!"APPROVED".equals(m.getMaterialAuditStage())) return false;
if (m.getMaterialAuditTime() == null) return false;
return System.currentTimeMillis() - m.getMaterialAuditTime().getTime() >= H24;
}
private static boolean t(Integer v)
@@ -1,13 +1,23 @@
package com.ruoyi.business.service.impl;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -24,6 +34,7 @@ import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper;
import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.sms.AliyunSmsSender;
@@ -60,6 +71,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
private BizNotifyService bizNotifyService;
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
private OssUploader ossUploader;
@Override
public int insert(BizMeetingAttendee entity) {
@@ -507,4 +520,217 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return sent;
}
@Override
public byte[] buildAttendeeInfoExcel(List<BizMeetingAttendee> list) {
List<BizMeetingAttendeeImportVo> voList = new ArrayList<>();
if (list != null) {
for (BizMeetingAttendee a : list) {
voList.add(BizMeetingAttendeeImportVo.from(a));
}
}
SXSSFWorkbook wb = new SXSSFWorkbook(500);
try {
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
util.initWithWorkbook(wb, voList, "参会人", null);
util.writeSheet();
ByteArrayOutputStream out = new ByteArrayOutputStream();
wb.write(out);
return out.toByteArray();
} catch (Exception e) {
throw new ServiceException("生成参会人信息失败: " + e.getMessage());
} finally {
wb.dispose();
}
}
/** 劳务协议 zip 顶层目录名 */
private static final String AGREEMENT_ZIP_ROOT = "劳务协议";
/** 专家照片 zip 顶层目录名 */
private static final String EXPERT_PHOTO_ZIP_ROOT = "专家照片";
/** 目录名里的姓名段 (下划线后) 要剔除路径/zip 非法字符, 避免 Windows 解压异常 */
private static String safeAttendeeName(String name) {
if (name == null || name.trim().isEmpty()) return "未知";
return name.replaceAll("[\\\\/:*?\"<>|\\r\\n\\t]", "").trim();
}
@Override
public byte[] buildAgreementTemplateZip(Long meetingId) {
return buildTemplateZip(meetingId, AGREEMENT_ZIP_ROOT);
}
@Override
public byte[] buildExpertPhotoTemplateZip(Long meetingId) {
return buildTemplateZip(meetingId, EXPERT_PHOTO_ZIP_ROOT);
}
/** 生成空目录模板 zip: {rootDir}/{序号}_{姓名}/, 每个参会人一个空目录 */
private byte[] buildTemplateZip(Long meetingId, String rootDir) {
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
for (int i = 0; i < list.size(); i++) {
BizMeetingAttendee a = list.get(i);
// 空目录 entry (以 / 结尾), 序号 = 列表 1 起下标, 与前端表格 type="index" 一致
String dir = rootDir + "/" + (i + 1) + "_" + safeAttendeeName(a.getName()) + "/";
zos.putNextEntry(new ZipEntry(dir));
zos.closeEntry();
}
} catch (IOException e) {
throw new ServiceException("生成目录模板失败: " + e.getMessage());
}
return baos.toByteArray();
}
@Override
public int uploadAgreements(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
byte[] zipBytes = file.getBytes();
// 目录序号 (下划线前数字, 0-based) → 该目录下文件字节 + 文件名
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
Map<Integer, List<String>> folderNames = new HashMap<>();
try {
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
} catch (IllegalArgumentException e) {
// 中文 Windows 打包器 (WinRAR/资源管理器"发送到压缩文件夹") 用 GBK 编码条目名且不置 UTF-8 标志,
// 强制 UTF-8 解码会抛 malformed input → 回退 GBK 重新解析
log.warn("[attendee] 协议 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
folderBytes.clear();
folderNames.clear();
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
}
int updated = 0;
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
int idx = e.getKey();
if (idx < 0 || idx >= list.size()) {
log.warn("[attendee] 协议 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
continue;
}
BizMeetingAttendee a = list.get(idx);
List<String> names = folderNames.get(idx);
String fileName = (names != null && !names.isEmpty()) ? names.get(0) : "labor-protocol.pdf";
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议
String url = ossUploader.upload(e.getValue().get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId());
entity.setLaborProtocol(url);
entity.setUpdateBy(SecurityUtils.getUsername());
updateLaborProtocol(entity);
updated++;
}
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个", meetingId, updated);
return updated;
}
@Override
public int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
byte[] zipBytes = file.getBytes();
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
Map<Integer, List<String>> folderNames = new HashMap<>();
try {
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
} catch (IllegalArgumentException e) {
log.warn("[attendee] 专家照片 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
folderBytes.clear();
folderNames.clear();
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
}
int updated = 0;
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
int idx = e.getKey();
if (idx < 0 || idx >= list.size()) {
log.warn("[attendee] 专家照片 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
continue;
}
BizMeetingAttendee a = list.get(idx);
List<byte[]> files = e.getValue();
List<String> names = folderNames.get(idx);
// 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos
StringBuilder sb = new StringBuilder();
for (int i = 0; i < files.size(); i++) {
String fileName = (names != null && i < names.size()) ? names.get(i) : ("photo-" + (i + 1) + ".jpg");
String url = ossUploader.upload(files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
if (sb.length() > 0) sb.append(",");
sb.append(url);
}
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId());
entity.setOnSitePhotos(sb.toString());
entity.setUpdateBy(SecurityUtils.getUsername());
updateProfile(entity);
updated++;
}
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个", meetingId, updated);
return updated;
}
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应参会人序号 */
private void readAttendeeZip(byte[] zipBytes, Charset charset,
Map<Integer, List<byte[]>> folderBytes,
Map<Integer, List<String>> folderNames) throws IOException {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String path = entry.getName();
// 跳过 macOS 打包产生的元数据
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
Integer idx = parseAgreementIndex(path);
if (idx == null) continue; // 不落在 {序号}_姓名 目录下, 忽略
byte[] data = readAllBytes(zis);
folderBytes.computeIfAbsent(idx, k -> new ArrayList<>()).add(data);
folderNames.computeIfAbsent(idx, k -> new ArrayList<>()).add(lastSegment(path));
}
}
}
/** 从 zip 条目路径里解析出 {序号}_姓名 目录的 0-based 序号; 不匹配返回 null */
private static Integer parseAgreementIndex(String path) {
if (path == null || path.isEmpty()) return null;
for (String seg : path.split("/")) {
if (seg.isEmpty()) continue;
int underscore = seg.indexOf('_');
if (underscore <= 0) continue;
String numPart = seg.substring(0, underscore);
if (!numPart.matches("\\d+")) continue;
try {
return Integer.parseInt(numPart) - 1;
} catch (NumberFormatException ex) {
return null;
}
}
return null;
}
private static String lastSegment(String path) {
int i = path.lastIndexOf('/');
return i >= 0 ? path.substring(i + 1) : path;
}
private static byte[] readAllBytes(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
@@ -25,23 +25,18 @@ public class BizMeetingExecutorServiceImpl implements IBizMeetingExecutorService
return bizMeetingExecutorMapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingExecutor> selectByUserId(Long userId) {
return bizMeetingExecutorMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy) {
public int replaceByMeetingId(Long meetingId, List<Long> orgIds, Long assignedBy) {
bizMeetingExecutorMapper.deleteByMeetingId(meetingId);
if (userIds == null || userIds.isEmpty()) {
if (orgIds == null || orgIds.isEmpty()) {
return 0;
}
List<BizMeetingExecutor> list = new ArrayList<>(userIds.size());
for (Long uid : userIds) {
List<BizMeetingExecutor> list = new ArrayList<>(orgIds.size());
for (Long orgId : orgIds) {
BizMeetingExecutor m = new BizMeetingExecutor();
m.setMeetingId(meetingId);
m.setUserId(uid);
m.setExecutorOrgId(orgId);
m.setAssignedBy(assignedBy);
list.add(m);
}
@@ -1,6 +1,7 @@
package com.ruoyi.business.service.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
@@ -10,15 +11,34 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.oss.OssZipService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingMaterialService;
@Service
@@ -35,10 +55,73 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF 才触发 OCR */
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf)$", Pattern.CASE_INSENSITIVE);
/** 会务下载范围: 会务材料 (SERVICE) + 会务凭证 (SERVICE_VOUCHER) */
private static final Set<String> SERVICE_MATERIAL_TYPES = new HashSet<>(Arrays.asList("SERVICE", "SERVICE_VOUCHER"));
/** 会务 subType → 中文材料类型名 (与前端 MeetingDetail.ROW_CONFIG 对齐), 作为 zip 内 "材料类型" 目录名 */
private static final Map<String, String> SERVICE_SUBTYPE_LABEL = new HashMap<>();
static {
SERVICE_SUBTYPE_LABEL.put("M_MATERIAL", "物料制作");
SERVICE_SUBTYPE_LABEL.put("M_HOTEL", "酒店");
SERVICE_SUBTYPE_LABEL.put("M_TRAFFIC_BIG", "大交通");
SERVICE_SUBTYPE_LABEL.put("M_TRAFFIC_SMALL", "小交通");
SERVICE_SUBTYPE_LABEL.put("M_EXECUTION", "执行费");
SERVICE_SUBTYPE_LABEL.put("M_DESIGN", "设计费");
SERVICE_SUBTYPE_LABEL.put("M_OTHER", "其他");
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT", "总结算单");
SERVICE_SUBTYPE_LABEL.put("M_INVOICE", "总发票");
SERVICE_SUBTYPE_LABEL.put("SV_PAYMENT", "会务付款凭证");
}
/** 会务材料 (SERVICE) 子类顺序 (与前端 ROW_CONFIG SERVICE 行对齐), 用于"打包上传"下载空目录模板 + 回填匹配 */
private static final List<String> SERVICE_MATERIAL_SUBTYPES = Arrays.asList(
"M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL",
"M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_INVOICE");
/** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */
private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>();
static {
for (String sub : SERVICE_MATERIAL_SUBTYPES) {
SERVICE_LABEL_TO_SUBTYPE.put(SERVICE_SUBTYPE_LABEL.get(sub), sub);
}
}
/** 会务材料"打包上传" zip 顶层目录名 */
private static final String SERVICE_ZIP_ROOT = "会务材料";
/** 劳务下载范围: 劳务材料 (LABOR) + 劳务凭证 (LABOR_VOUCHER) */
private static final Set<String> LABOR_MATERIAL_TYPES = new HashSet<>(Arrays.asList("LABOR", "LABOR_VOUCHER"));
/** 劳务 subType → 中文材料类型名 (与前端 MeetingDetail.ROW_CONFIG 对齐) */
private static final Map<String, String> LABOR_SUBTYPE_LABEL = new HashMap<>();
static {
LABOR_SUBTYPE_LABEL.put("L_DETAIL", "劳务明细表");
LABOR_SUBTYPE_LABEL.put("L_AGREEMENT", "劳务协议");
LABOR_SUBTYPE_LABEL.put("L_ENTERPRISE_BENEFIT", "企业权益");
LABOR_SUBTYPE_LABEL.put("L_SIGN_IN", "签到表");
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_FRONT", "前全景");
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_BACK", "后全景");
LABOR_SUBTYPE_LABEL.put("L_EXPERT_PHOTO", "专家照片");
LABOR_SUBTYPE_LABEL.put("LV_PAYMENT", "劳务付款凭证");
}
private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
@Autowired
private BizMeetingMaterialMapper bizMeetingMaterialMapper;
@Autowired
private BizMeetingMapper bizMeetingMapper;
@Autowired
private OssZipService ossZipService;
@Autowired
private IBizMeetingAttendeeService attendeeService;
@Autowired
private OssUploader ossUploader;
@Autowired
@Lazy
private InvoiceOcrService invoiceOcrService;
private static final Logger log = LoggerFactory.getLogger(BizMeetingMaterialServiceImpl.class);
@Override
public BizMeetingMaterial getById(Long id) {
@@ -103,6 +186,19 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
} catch (DuplicateKeyException e) {
throw new ServiceException("材料上传重复, 请检查 (同一会议下同一资料类型同一子分类只能有一条记录)");
}
// foreach 批量插入的 useGeneratedKeys 不可靠, 插完后再查回真实 id (subType 会议内唯一), 前端据此触发 OCR
List<BizMeetingMaterial> inserted = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
Map<String, Long> idBySubType = new HashMap<>();
if (inserted != null) {
for (BizMeetingMaterial im : inserted) {
if (im.getSubType() != null) idBySubType.put(im.getSubType(), im.getId());
}
}
for (BizMeetingMaterial m : list) {
if (m.getSubType() != null) {
m.setId(idBySubType.get(m.getSubType()));
}
}
return list;
}
@@ -162,6 +258,444 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
}
}
/**
* 结算时保存付款凭证 (LV_PAYMENT + SV_PAYMENT, 合规人员上传).
* 只清/插这两个 subType, 不碰其他材料; 付款凭证非发票, 不触发 OCR, 不影响会议费用.
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void saveVouchers(Long meetingId, List<BizMeetingMaterial> vouchers) {
// 删旧 LV_PAYMENT / SV_PAYMENT (幂等, 避免 uk_meeting_type_sub 冲突)
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
if (existing != null) {
for (BizMeetingMaterial m : existing) {
if ("LV_PAYMENT".equals(m.getSubType()) || "SV_PAYMENT".equals(m.getSubType())) {
bizMeetingMaterialMapper.deleteByPrimaryKey(m.getId());
}
}
}
if (vouchers == null || vouchers.isEmpty()) return;
List<BizMeetingMaterial> toInsert = new java.util.ArrayList<>();
for (BizMeetingMaterial v : vouchers) {
if (v == null || v.getSubType() == null || v.getOssUrl() == null || v.getOssUrl().isEmpty()) continue;
if (!"LV_PAYMENT".equals(v.getSubType()) && !"SV_PAYMENT".equals(v.getSubType())) continue;
v.setId(null);
v.setMeetingId(meetingId);
v.setMaterialType("LV_PAYMENT".equals(v.getSubType()) ? "LABOR_VOUCHER" : "SERVICE_VOUCHER");
v.setAmount(null);
v.setFeeStatus(1); // 付款凭证非发票, 直接标记已计算, 不参与费用汇总
v.setCreateTime(new Date());
v.setCreatorId(SecurityUtils.getUserId());
toInsert.add(v);
}
if (!toInsert.isEmpty()) {
bizMeetingMaterialMapper.insertBatch(toInsert);
}
}
/**
* 把该会议所有"会务"材料 (SERVICE + SERVICE_VOUCHER) 在 OSS 端打成一个 zip, 返回下载 URL.
* <p>
* zip 内目录结构: {项目名称}/{材料类型中文名}/{材料文件}. 实现分两步:
* <ol>
* <li>staging copy: 把散落的材料文件复制到 download/huiwu/{meetingId}/ 前缀下,
* key 结构即 zip 内目录结构 (FC 按 source-dir 前缀打包, zip 内相对路径 = key 相对前缀部分)</li>
* <li>FC 打包: POST {bucket, source-dir} 给阿里云函数计算, 拿 zip 签名 URL</li>
* </ol>
* 文件名: 优先用 fileName (历史数据可能有), 否则用 {材料类型中文名}{扩展名}.
*/
@Override
public String buildServiceZipUrl(Long meetingId)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
String prefix = "download/huiwu/" + meetingId + "/";
ossZipService.clearPrefix(prefix);
int copied = stageServiceMaterials(meetingId, prefix, projectFolderName(meeting));
if (copied == 0) throw new ServiceException("该会议暂无可下载的会务材料");
return ossZipService.zipDownload(prefix);
}
/**
* 把该会议所有"劳务"材料 (LABOR + LABOR_VOUCHER) + 参会人信息 Excel 在 OSS 端打成一个 zip, 返回下载 URL.
* <p>
* zip 内目录结构: {项目名称}/{材料类型中文名}/{材料文件}, 外加 {项目名称}/参会人信息/参会人信息.xlsx.
* 前全景(L_PANORAMA_FRONT)/后全景(L_PANORAMA_BACK) 归入"会议现场全景"目录 (文件名分别用 前全景/后全景 区分, 避免同目录重名).
*/
@Override
public String buildLaborZipUrl(Long meetingId)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
String prefix = "download/labor/" + meetingId + "/";
ossZipService.clearPrefix(prefix);
int copied = stageLaborMaterials(meetingId, prefix, projectFolderName(meeting));
if (copied == 0) throw new ServiceException("该会议暂无可下载的劳务材料");
return ossZipService.zipDownload(prefix);
}
// ===================================================================
// 批量下载 (多会议合并打一个 zip)
// ===================================================================
/** 批量会务下载: 多个会议的会务材料合并 staging 到一个 batch 前缀, 一次 FC 打 zip 返回 URL */
@Override
public String buildBatchServiceZipUrl(List<Long> meetingIds)
{
List<Long> ids = normalizeIds(meetingIds);
String prefix = "download/huiwu/batch/" + System.currentTimeMillis() + "/";
ossZipService.clearPrefix(prefix);
Set<String> usedFolders = new HashSet<>();
int copied = 0;
for (Long id : ids)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(id);
if (meeting == null) continue; // 已软删/不存在, 跳过
copied += stageServiceMaterials(id, prefix, uniqueFolder(usedFolders, batchMeetingFolderName(meeting)));
}
if (copied == 0) throw new ServiceException("所选会议暂无可下载的会务材料");
return ossZipService.zipDownload(prefix);
}
/** 批量劳务下载: 多个会议的劳务材料(含参会人信息)合并 staging 到一个 batch 前缀, 一次 FC 打 zip 返回 URL */
@Override
public String buildBatchLaborZipUrl(List<Long> meetingIds)
{
List<Long> ids = normalizeIds(meetingIds);
String prefix = "download/labor/batch/" + System.currentTimeMillis() + "/";
ossZipService.clearPrefix(prefix);
Set<String> usedFolders = new HashSet<>();
int copied = 0;
for (Long id : ids)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(id);
if (meeting == null) continue;
copied += stageLaborMaterials(id, prefix, uniqueFolder(usedFolders, batchMeetingFolderName(meeting)));
}
if (copied == 0) throw new ServiceException("所选会议暂无可下载的劳务材料");
return ossZipService.zipDownload(prefix);
}
/** 收集该会议会务材料并 copy 到 staging 前缀 prefix + folderName 下, 返回 copy 的文件数 (0 = 无会务材料) */
private int stageServiceMaterials(Long meetingId, String prefix, String folderName)
{
int copied = 0;
List<BizMeetingMaterial> all = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
if (all == null) return copied;
for (BizMeetingMaterial m : all)
{
if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue;
if (m.getMaterialType() == null || !SERVICE_MATERIAL_TYPES.contains(m.getMaterialType())) continue;
String srcKey = ossZipService.extractKey(m.getOssUrl());
if (srcKey == null || srcKey.isEmpty()) continue;
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(m.getSubType(), (m.getSubType() == null ? "其他" : m.getSubType()));
String ext = extOf(srcKey);
String fileName = (m.getFileName() != null && !m.getFileName().trim().isEmpty())
? safeName(m.getFileName()) : safeName(label) + ext;
String dstKey = prefix + folderName + "/" + safeName(label) + "/" + fileName;
ossZipService.copyObject(srcKey, dstKey);
copied++;
}
return copied;
}
/** 收集该会议劳务材料(含参会人信息 Excel)并 staging 到 prefix + folderName 下, 返回写入的文件数 (0 = 无劳务材料且无参会人) */
private int stageLaborMaterials(Long meetingId, String prefix, String folderName)
{
int copied = 0;
List<BizMeetingMaterial> all = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
if (all != null)
{
for (BizMeetingMaterial m : all)
{
if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue;
if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue;
String srcKey = ossZipService.extractKey(m.getOssUrl());
if (srcKey == null || srcKey.isEmpty()) continue;
String folder = laborFolderName(m.getSubType());
String fileBase = laborFileLabel(m.getSubType());
String ext = extOf(srcKey);
String fileName = (m.getFileName() != null && !m.getFileName().trim().isEmpty())
? safeName(m.getFileName()) : safeName(fileBase) + ext;
String dstKey = prefix + folderName + "/" + safeName(folder) + "/" + fileName;
ossZipService.copyObject(srcKey, dstKey);
copied++;
}
}
List<BizMeetingAttendee> attendees = attendeeService.selectByMeetingId(meetingId);
if (attendees != null && !attendees.isEmpty())
{
byte[] excel = attendeeService.buildAttendeeInfoExcel(attendees);
ossZipService.putObject(prefix + folderName + "/参会人信息/参会人信息.xlsx", excel, XLSX_CONTENT_TYPE);
copied++;
}
return copied;
}
/** 单会议 zip 顶层目录名: 项目名 (为空回退项目编号, 再回退会议ID), 统一消毒 */
private static String projectFolderName(BizMeeting meeting)
{
String name = meeting.getProjectName();
if (name == null || name.trim().isEmpty())
{
name = (meeting.getProjectNo() != null && !meeting.getProjectNo().trim().isEmpty())
? meeting.getProjectNo() : "会议" + meeting.getMeetingId();
}
return safeName(name);
}
/** 批量 zip 每个会议顶层目录名: 项目编号_会议名_第N期 (缺项跳过, 全空回退会议ID) */
private static String batchMeetingFolderName(BizMeeting meeting)
{
StringBuilder sb = new StringBuilder();
appendPart(sb, meeting.getProjectNo());
appendPart(sb, meeting.getMeetingName());
if (meeting.getPeriodNo() != null)
{
appendPart(sb, "" + meeting.getPeriodNo() + "");
}
String name = sb.toString();
if (name.isEmpty()) return "会议" + meeting.getMeetingId();
return safeName(name);
}
private static void appendPart(StringBuilder sb, String v)
{
if (v == null || v.trim().isEmpty()) return;
if (sb.length() > 0) sb.append("_");
sb.append(v.trim());
}
/** 目录名去重: 同名会议加 _2/_3 后缀, 避免 OSS 同名 key 静默覆盖丢数据 */
private static String uniqueFolder(Set<String> used, String base)
{
String name = base;
int i = 2;
while (!used.add(name))
{
name = base + "_" + (i++);
}
return name;
}
/** 批量 id 清洗: 去空 + 去重 + 保序 + 上限校验 */
private static List<Long> normalizeIds(List<Long> meetingIds)
{
if (meetingIds == null || meetingIds.isEmpty()) throw new ServiceException("请先选择要下载的会议");
Set<Long> seen = new HashSet<>();
List<Long> ids = new ArrayList<>();
for (Long id : meetingIds)
{
if (id == null || !seen.add(id)) continue;
ids.add(id);
}
if (ids.size() > 20) throw new ServiceException("一次最多批量下载 20 个会议");
return ids;
}
/** 劳务材料目录名: 前/后全景归入"会议现场全景", 其余用 subType 中文名 */
private static String laborFolderName(String subType)
{
if ("L_PANORAMA_FRONT".equals(subType) || "L_PANORAMA_BACK".equals(subType)) return "会议现场全景";
return LABOR_SUBTYPE_LABEL.getOrDefault(subType, (subType == null ? "其他" : subType));
}
/** 劳务材料文件名 (fileName 为空时用它+扩展名): 前/后全景分别用 前全景/后全景 区分 */
private static String laborFileLabel(String subType)
{
return LABOR_SUBTYPE_LABEL.getOrDefault(subType, (subType == null ? "其他" : subType));
}
/** 取 object key 的扩展名 (含点), 无则空串 */
private static String extOf(String key)
{
int i = key.lastIndexOf('.');
return i >= 0 ? key.substring(i) : "";
}
/** 目录/文件名消毒: 去掉 OSS key 与 zip 路径里的非法字符 (斜杠/反斜杠/冒号等) */
private static String safeName(String s)
{
if (s == null) return "";
String r = s.replaceAll("[\\\\/:*?\"<>|\\r\\n\\t]", "_").trim();
return r.isEmpty() ? "_" : r;
}
/**
* 生成"会务材料"空目录模板 zip (会务材料 tab "打包上传" 的"下载目录"按钮).
* zip 内为 会务材料/{材料类型中文名}/ 空目录 (物料制作/酒店/大交通/小交通/执行费/设计费/其他/总结算单/总发票),
* 用户把材料文件放进对应目录后重新压缩上传.
*/
@Override
public byte[] buildServiceTemplateZip(Long meetingId) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
for (String sub : SERVICE_MATERIAL_SUBTYPES) {
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(sub, sub);
String dir = SERVICE_ZIP_ROOT + "/" + label + "/";
zos.putNextEntry(new ZipEntry(dir));
zos.closeEntry();
}
} catch (IOException e) {
throw new ServiceException("生成会务材料目录模板失败: " + e.getMessage());
}
return baos.toByteArray();
}
/**
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType,
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填.
* 替换文件会清空 amount; 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 直接后台触发 OCR 回写金额.
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
byte[] zipBytes = file.getBytes();
// subType -> 该目录下文件字节 + 文件名
Map<String, List<byte[]>> folderBytes = new HashMap<>();
Map<String, List<String>> folderNames = new HashMap<>();
try {
readServiceZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
} catch (IllegalArgumentException e) {
// 中文 Windows 打包器 (WinRAR/资源管理器) 用 GBK 编码条目名且不置 UTF-8 标志, 回退 GBK 重解析
log.warn("[material] 会务材料 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
folderBytes.clear();
folderNames.clear();
readServiceZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
}
if (folderBytes.isEmpty()) throw new ServiceException("未在 zip 中找到会务材料目录, 请按下载模板的目录结构放置文件");
// 已存在的会务材料 subType -> 行 (决定 update / insert)
Map<String, BizMeetingMaterial> existingBySubType = new HashMap<>();
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
if (existing != null) {
for (BizMeetingMaterial m : existing) {
if (m.getSubType() != null) existingBySubType.put(m.getSubType(), m);
}
}
String subDir = "ry8080/meeting/" + meetingId + "/service";
int updated = 0;
for (Map.Entry<String, List<byte[]>> e : folderBytes.entrySet()) {
String subType = e.getKey();
List<byte[]> files = e.getValue();
if (files == null || files.isEmpty()) continue;
List<String> names = folderNames.get(subType);
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
String ossUrl;
String fileName;
if (files.size() == 1) {
fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg");
ossUrl = ossUploader.upload(files.get(0), fileName, subDir);
} else {
// 目录内多文件 → 先打 zip 再上传 OSS
fileName = label + ".zip";
ossUrl = ossUploader.upload(zipFiles(files, names), fileName, subDir);
}
// 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 待 OCR(0), 其余已计算(1)
boolean isZip = files.size() > 1;
boolean ocrNeeded = isZip || (ossUrl != null && RECOGNIZABLE.matcher(ossUrl).find());
int feeStatus = ocrNeeded ? 0 : 1;
BizMeetingMaterial old = existingBySubType.get(subType);
Long materialId;
Long oldMaterialId;
if (old != null) {
materialId = old.getId();
bizMeetingMaterialMapper.updateFile(old.getId(), ossUrl, fileName, feeStatus);
oldMaterialId = old.getId(); // 替换: 清理该 material 的旧 invoice 行
} else {
BizMeetingMaterial ins = new BizMeetingMaterial();
ins.setMeetingId(meetingId);
ins.setMaterialType("SERVICE");
ins.setSubType(subType);
ins.setOssUrl(ossUrl);
ins.setFileName(fileName);
ins.setAmount(null);
ins.setFeeStatus(feeStatus);
ins.setCreatorId(SecurityUtils.getUserId());
ins.setCreateTime(new Date());
bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins));
// foreach 批量插入 useGeneratedKeys 不可靠, 按 subType 查回真实 id
BizMeetingMaterial inserted = bizMeetingMaterialMapper.selectByMeetingAndSubType(meetingId, subType);
materialId = inserted != null ? inserted.getId() : null;
oldMaterialId = null;
}
// 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总)
if (ocrNeeded) {
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, isZip, oldMaterialId);
}
updated++;
}
log.info("[material] 会务材料回填完成 meetingId={} 共{}类", meetingId, updated);
return updated;
}
/** 按指定字符集解压 zip, 把 会务材料/{材料中文名}/ 目录下的文件归集到对应 subType */
private void readServiceZip(byte[] zipBytes, Charset charset,
Map<String, List<byte[]>> folderBytes,
Map<String, List<String>> folderNames) throws IOException {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String path = entry.getName();
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
String subType = matchServiceLabel(path);
if (subType == null) continue; // 不落在 会务材料/{材料中文名}/ 目录下, 忽略
byte[] data = readAllBytes(zis);
folderBytes.computeIfAbsent(subType, k -> new ArrayList<>()).add(data);
folderNames.computeIfAbsent(subType, k -> new ArrayList<>()).add(lastSegment(path));
}
}
}
/** 从 zip 条目路径里匹配出会务材料 subType (扫描路径段命中材料中文名); 不匹配返回 null */
private static String matchServiceLabel(String path) {
if (path == null || path.isEmpty()) return null;
for (String seg : path.split("/")) {
if (seg.isEmpty()) continue;
String sub = SERVICE_LABEL_TO_SUBTYPE.get(seg);
if (sub != null) return sub;
}
return null;
}
/** 把目录内多个文件打成内存 zip (保留原始文件名) */
private static byte[] zipFiles(List<byte[]> files, List<String> names) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
for (int i = 0; i < files.size(); i++) {
String name = (names != null && i < names.size()) ? names.get(i) : ("file-" + (i + 1));
zos.putNextEntry(new ZipEntry(name));
zos.write(files.get(i));
zos.closeEntry();
}
}
return baos.toByteArray();
}
private static String lastSegment(String path) {
int i = path.lastIndexOf('/');
return i >= 0 ? path.substring(i + 1) : path;
}
private static byte[] readAllBytes(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
return out.toByteArray();
}
/**
* 是否会被前端提交 OCR (与 MeetingDetail.saveMaterials 门控一致):
* 文件可识别 (jpg/jpeg/png/pdf) 且 不在非发票 subType 集合里.
@@ -46,16 +46,13 @@ public class BizMeetingServiceImpl implements IBizMeetingService
}
// 新建会议初始状态: DB 列默认值 '0' 会让前端 stageLabel 显示成 0 而不是 enum 项, 这里显式兜底成 enum.
// current_stage = NOT_STARTED (未执行, 等 scheduler 过 startTime 置 is_executed 转 RUNNING)
// 材料/凭证审核子状态 = NOT_SUBMITTED (未提交, 供执行方 submit-material/submit-voucher 校验)
// 材料审核子状态 = NOT_SUBMITTED (未提交, 供执行方 submit-material 校验)
if (entity.getCurrentStage() == null || entity.getCurrentStage().isEmpty()) {
entity.setCurrentStage(BizMeetingStageEnum.NOT_STARTED.getCode());
}
if (entity.getMaterialAuditStage() == null || entity.getMaterialAuditStage().isEmpty()) {
entity.setMaterialAuditStage("NOT_SUBMITTED");
}
if (entity.getVoucherAuditStage() == null || entity.getVoucherAuditStage().isEmpty()) {
entity.setVoucherAuditStage("NOT_SUBMITTED");
}
return bizMeetingMapper.insert(entity);
}
@Override
@@ -25,23 +25,18 @@ public class BizMeetingSupervisorServiceImpl implements IBizMeetingSupervisorSer
return bizMeetingSupervisorMapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingSupervisor> selectByUserId(Long userId) {
return bizMeetingSupervisorMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy) {
public int replaceByMeetingId(Long meetingId, List<Long> orgIds, Long assignedBy) {
bizMeetingSupervisorMapper.deleteByMeetingId(meetingId);
if (userIds == null || userIds.isEmpty()) {
if (orgIds == null || orgIds.isEmpty()) {
return 0;
}
List<BizMeetingSupervisor> list = new ArrayList<>(userIds.size());
for (Long uid : userIds) {
List<BizMeetingSupervisor> list = new ArrayList<>(orgIds.size());
for (Long orgId : orgIds) {
BizMeetingSupervisor m = new BizMeetingSupervisor();
m.setMeetingId(meetingId);
m.setUserId(uid);
m.setSponsorOrgId(orgId);
m.setAssignedBy(assignedBy);
list.add(m);
}
@@ -1,15 +1,21 @@
package com.ruoyi.business.service.impl;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizOrgImportVo;
import com.ruoyi.business.mapper.BizOrgMapper;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.system.mapper.SysUserMapper;
@Service
@@ -52,11 +58,22 @@ public class BizOrgServiceImpl implements IBizOrgService {
return bizOrgMapper.selectExecutorOrgOptions(entity);
}
@Override
public List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity) {
return bizOrgMapper.selectSponsorRegisterOptions(entity);
}
@Override
public Map<String, Object> selectMySponsorCompany(Long userId) {
return bizOrgMapper.selectMySponsorCompany(userId);
}
@Override
public Long selectOrgIdByUserId(Long userId) {
if (userId == null) return null;
return bizOrgMapper.selectOrgIdByUserId(userId);
}
/**
* 启用/禁用 org, 同步主账号 sys_user.status
* biz_org.status: '0' (正常) / '1' (禁用) → sys_user.status 同码 ('0' 正常, '1' 停用)
@@ -82,4 +99,80 @@ public class BizOrgServiceImpl implements IBizOrgService {
return rows;
}
/**
* 批量导入支持单位 (只导 org, 不新增人员):
* <ol>
* <li>ExcelUtil 反序列化 + 行级校验 (公司名称必填)</li>
* <li>批内去重 (公司名称 / 税号) + DB 去重 (sponsor 下同税号已存在)</li>
* <li>逐行 insert biz_org: orgType='sponsor', status='0', userId 留空 (不建 sys_user)</li>
* </ol>
* 失败行为: 单行失败只记 ngList, 不影响其它行 (跨行不回滚).
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ImportResult importOrg(MultipartFile file, String operName) throws Exception {
ExcelUtil<BizOrgImportVo> util = new ExcelUtil<>(BizOrgImportVo.class);
List<BizOrgImportVo> importList = util.importExcel(file.getInputStream());
if (importList == null || importList.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
ImportResult result = new ImportResult();
Set<String> seenOrgName = new HashSet<>();
Set<String> seenTaxNo = new HashSet<>();
for (int i = 0; i < importList.size(); i++) {
BizOrgImportVo vo = importList.get(i);
int rowNo = i + 2; // Excel 行号 (1=表头)
try {
if (vo.getOrgName() == null || vo.getOrgName().trim().isEmpty()) {
throw new ServiceException("公司名称不能为空");
}
String orgName = vo.getOrgName().trim();
String taxNo = vo.getTaxNo() == null ? "" : vo.getTaxNo().trim();
// 批内去重
if (!seenOrgName.add(orgName)) {
throw new ServiceException("公司名称「" + orgName + "」在文件中重复");
}
if (!taxNo.isEmpty()) {
if (!seenTaxNo.add(taxNo)) {
throw new ServiceException("税号「" + taxNo + "」在文件中重复");
}
// DB 去重: sponsor 下同税号已存在
BizOrg probe = new BizOrg();
probe.setOrgType("sponsor");
probe.setTaxNo(taxNo);
if (!bizOrgMapper.selectList(probe).isEmpty()) {
throw new ServiceException("税号「" + taxNo + "」已存在");
}
}
BizOrg org = new BizOrg();
org.setOrgType("sponsor");
org.setOrgName(orgName);
org.setTaxNo(taxNo.isEmpty() ? null : taxNo);
org.setBusinessNature(trimToNull(vo.getBusinessNature()));
org.setAddress(trimToNull(vo.getAddress()));
org.setContactName(trimToNull(vo.getContactName()));
org.setContactPhone(trimToNull(vo.getContactPhone()));
org.setStatus("0");
org.setCreateBy(operName);
bizOrgMapper.insert(org);
result.ok();
} catch (Exception e) {
result.fail(rowNo, e.getMessage() == null ? "导入失败" : e.getMessage());
}
}
return result;
}
/** 空串转 null (避免插空字符串脏数据) */
private String trimToNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
}
@@ -3,6 +3,7 @@ package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.mapper.BizOrgMapper;
@@ -69,7 +70,12 @@ public class BizPersonServiceImpl implements IBizPersonService
}
}
if (entity.getOrgId() == null) {
throw new ServiceException("请填写所属公司 (主账号尚未注册公司, 请联系 admin 在 [支持单位管理/服务机构管理] 录入 orgType=" + entity.getUnitType() + " 的公司并关联 user_id)");
// 支持单位: 公司由 admin 在 [支持单位管理] 预录, 支持方注册时选公司 (不新建);
// 执行单位: 主账号注册时自行创建公司 (registerExecutor 建 biz_org), 不走 admin 录入.
if ("executor".equals(entity.getUnitType())) {
throw new ServiceException("请填写所属公司 (执行单位需自行创建公司, 请先完成公司注册)");
}
throw new ServiceException("请填写所属公司 (主账号尚未注册公司, 请联系 admin 在 [支持单位管理] 录入 orgType=" + entity.getUnitType() + " 的公司并关联 user_id)");
}
// 1. 创建 sys_user 子账号
@@ -80,6 +86,7 @@ public class BizPersonServiceImpl implements IBizPersonService
newUser.setUserName(entity.getLoginUsername());
newUser.setNickName(entity.getName());
newUser.setPhonenumber(entity.getPhone());
newUser.setEmail(entity.getEmail());
newUser.setPassword(SecurityUtils.encryptPassword(entity.getLoginPassword()));
newUser.setAccountType("SUB");
newUser.setParentUserId(mainUserId);
@@ -116,6 +123,7 @@ public class BizPersonServiceImpl implements IBizPersonService
u.setUserId(entity.getUserId());
u.setNickName(entity.getName());
u.setPhonenumber(entity.getPhone());
u.setEmail(entity.getEmail());
u.setUpdateBy(SecurityUtils.getUsername());
// status 同步 (如果前端传了 status 字段)
if (entity.getStatus() != null) {
@@ -139,4 +147,50 @@ public class BizPersonServiceImpl implements IBizPersonService
int n = bizPersonMapper.deleteByPrimaryKeys(personIds);
return n;
}
/**
* 更换机构管理员: 目标人员晋升 MAIN (管理员), 原管理员降为 SUB
* 数据口径: MAIN ⇔ parent_user_id IS NULL, SUB ⇔ parent_user_id = 主账号 user_id
* 操作顺序避免出现三层/环: 先 re-point 其余子账号 → 晋升新管理员 → 降级原管理员 → 改 biz_org 归属
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int changeOrgAdmin(String personId) {
BizPerson target = bizPersonMapper.selectByPrimaryKey(personId);
if (target == null || target.getUserId() == null) {
throw new ServiceException("人员不存在或未关联登录账号");
}
Long newAdminUid = target.getUserId();
SysUser newUser = sysUserMapper.selectUserById(newAdminUid);
if (newUser == null) {
throw new ServiceException("登录账号不存在");
}
if ("MAIN".equals(newUser.getAccountType())) {
return 0; // 已是管理员, 无需操作
}
if (target.getOrgId() == null) {
throw new ServiceException("该人员未关联机构, 无法设为管理员");
}
BizOrg org = bizOrgMapper.selectByPrimaryKey(target.getOrgId());
Long oldAdminUid = (org == null) ? null : org.getUserId();
// 1. 原管理员的其他子账号 re-point 到新管理员 (排除新管理员自己, 其 parent 当前仍指向原管理员)
if (oldAdminUid != null && !oldAdminUid.equals(newAdminUid)) {
sysUserMapper.updateParentUserId(oldAdminUid, newAdminUid);
}
// 2. 新管理员晋升 MAIN (parent_user_id = NULL)
sysUserMapper.updateAccountType(newAdminUid, "MAIN", null);
// 3. 原管理员降级 SUB (parent 指向新管理员)
if (oldAdminUid != null && !oldAdminUid.equals(newAdminUid)) {
sysUserMapper.updateAccountType(oldAdminUid, "SUB", newAdminUid);
}
// 4. biz_org.user_id 同步指向新管理员 (机构归属/反查)
if (org != null) {
BizOrg u = new BizOrg();
u.setOrgId(target.getOrgId());
u.setUserId(newAdminUid);
bizOrgMapper.updateByPrimaryKey(u);
}
return 1;
}
}
@@ -5,10 +5,8 @@ import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizProjectAssign;
import com.ruoyi.business.mapper.BizOrgMapper;
import com.ruoyi.business.mapper.BizProjectAssignMapper;
import com.ruoyi.business.mapper.BizProjectMapper;
import com.ruoyi.business.service.IBizProjectAssignService;
@@ -22,8 +20,6 @@ public class BizProjectAssignServiceImpl implements IBizProjectAssignService
@Autowired
private BizProjectAssignMapper bizProjectAssignMapper;
@Autowired
private BizOrgMapper bizOrgMapper;
@Autowired
private BizProjectMapper bizProjectMapper;
@Override
@@ -37,30 +33,19 @@ public class BizProjectAssignServiceImpl implements IBizProjectAssignService
/**
* 插入项目执行方分配:
* 1. execUserId → biz_org(org_type='executor') 反查 org_id 写入 executionUnitId (NOT NULL, 找不到 throw)
* 1. executionUnitId (前端必传, 单一可信源 biz_org.org_id)
* 2. 自动补 audit 字段 (createBy/createTime/updateBy/updateTime) 从 SecurityUtils
* 3. SnowflakeId 主键
* 4. status='0' 默认
*/
@Override
public int insert(BizProjectAssign entity) {
// 0. 必须有 execUserId (前端必传)
if (entity.getExecUserId() == null) {
throw new ServiceException("执行方用户ID不能为空");
// 0. 必须有 executionUnitId (前端必传)
if (entity.getExecutionUnitId() == null) {
throw new ServiceException("执行单位ID不能为空");
}
// 1. execUserId → biz_org(executor) → executionUnitId
BizOrg q = new BizOrg();
q.setUserId(entity.getExecUserId());
q.setOrgType("executor");
List<BizOrg> matched = bizOrgMapper.selectList(q);
if (matched == null || matched.isEmpty()) {
throw new ServiceException("执行方用户 " + entity.getExecUserId() + " 未关联执行单位 (请检查 biz_org.org_type='executor' + user_id 是否绑定)");
}
BizOrg org = matched.get(0);
entity.setExecutionUnitId(org.getOrgId());
// 2. audit 字段
// 1. audit 字段
Date now = new Date();
String operator = SecurityUtils.getUsername();
if (entity.getCreateBy() == null || entity.getCreateBy().isEmpty()) entity.setCreateBy(operator);
@@ -68,10 +53,10 @@ public class BizProjectAssignServiceImpl implements IBizProjectAssignService
entity.setUpdateBy(operator);
entity.setUpdateTime(now);
// 3. 默认值
// 2. 默认值
if (entity.getStatus() == null || entity.getStatus().isEmpty()) entity.setStatus("0");
// 4. SnowflakeId
// 3. SnowflakeId
SnowflakeId.injectIfEmpty(entity, "assignId");
return bizProjectAssignMapper.insert(entity);
@@ -39,7 +39,7 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
item.setAssignDesc(body.getAssignDesc());
item.setAssignPoints(body.getAssignPoints());
item.setCreateBy(body.getCreateBy());
item.setExecutorUserId(body.getExecutorUserId());
item.setExecutorOrgId(body.getExecutorOrgId());
inserted += mapper.insertAssign(item);
}
return inserted;
@@ -138,4 +138,16 @@ public class BizProjectServiceImpl implements IBizProjectService
if (projectId == null) return;
bizProjectMapper.recomputeSettledAmounts(projectId);
}
/** 删除公告: 将 3 个公示 URL 置 NULL (未发布) */
@Override
public int clearAnnouncement(Long projectId) {
return bizProjectMapper.clearAnnouncement(projectId);
}
/** 开通到期回收 */
@Override
public int closeExpiredOpenStatus() {
return bizProjectMapper.closeExpiredOpenStatus();
}
}
@@ -39,7 +39,7 @@ public class BizProjectSponsorAssignServiceImpl implements IBizProjectSponsorAss
item.setAssignDesc(body.getAssignDesc());
item.setAssignPoints(body.getAssignPoints());
item.setCreateBy(body.getCreateBy());
item.setSponsorUserId(body.getSponsorUserId());
item.setSponsorOrgId(body.getSponsorOrgId());
inserted += mapper.insertAssign(item);
}
return inserted;
@@ -154,6 +154,7 @@
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, labor_protocol_masked, create_by, create_time, is_deleted, is_esigned, is_invited
from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
order by id
</select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted, is_esigned, is_invited
@@ -5,14 +5,15 @@
<resultMap type="BizMeetingExecutor" id="BizMeetingExecutorResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="executorOrgId" column="executor_org_id" />
<result property="orgName" column="org_name" />
<result property="assignedBy" column="assigned_by" />
<result property="createTime" column="create_time" />
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, user_id, assigned_by, create_time, is_deleted
select id, meeting_id, executor_org_id, assigned_by, create_time, is_deleted
from biz_meeting_executor
</sql>
@@ -22,15 +23,12 @@
</select>
<select id="selectByMeetingId" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId} and is_deleted = 0
order by id asc
</select>
<select id="selectByUserId" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId} and is_deleted = 0
order by id desc
select e.id, e.meeting_id, e.executor_org_id, e.assigned_by, e.create_time, e.is_deleted,
o.org_name
from biz_meeting_executor e
left join biz_org o on o.org_id = e.executor_org_id
where e.meeting_id = #{meetingId} and e.is_deleted = 0
order by e.id asc
</select>
<select id="selectList" resultMap="BizMeetingExecutorResult" parameterType="BizMeetingExecutor">
@@ -38,7 +36,7 @@
<where>
is_deleted = 0
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="userId != null">and user_id = #{userId}</if>
<if test="executorOrgId != null">and executor_org_id = #{executorOrgId}</if>
</where>
order by id asc
</select>
@@ -47,30 +45,30 @@
insert into biz_meeting_executor
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="userId != null">user_id,</if>
<if test="executorOrgId != null">executor_org_id,</if>
<if test="assignedBy != null">assigned_by,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="userId != null">#{userId},</if>
<if test="executorOrgId != null">#{executorOrgId},</if>
<if test="assignedBy != null">#{assignedBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_executor (meeting_id, user_id, assigned_by, create_time)
insert into biz_meeting_executor (meeting_id, executor_org_id, assigned_by, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime})
(#{item.meetingId}, #{item.executorOrgId}, #{item.assignedBy}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingExecutor">
update biz_meeting_executor
<trim prefix="SET" suffixOverrides=",">
<if test="userId != null">user_id = #{userId},</if>
<if test="executorOrgId != null">executor_org_id = #{executorOrgId},</if>
<if test="assignedBy != null">assigned_by = #{assignedBy},</if>
</trim>
where id = #{id}
@@ -20,7 +20,6 @@
<result property="supervisionBy" column="supervision_by" />
<result property="supervisionTime" column="supervision_time" />
<result property="materialAuditStage" column="material_audit_stage" />
<result property="voucherAuditStage" column="voucher_audit_stage" />
<result property="isExecuted" column="is_executed" />
<result property="executeTime" column="execute_time" />
<result property="isSettled" column="is_settled" />
@@ -30,10 +29,10 @@
<result property="isFrozen" column="is_frozen" />
<result property="freezeTime" column="freeze_time" />
<result property="materialAuditTime" column="material_audit_time" />
<result property="voucherAuditTime" column="voucher_audit_time" />
<result property="materialComplianceApproved" column="material_compliance_approved" />
<result property="voucherComplianceApproved" column="voucher_compliance_approved" />
<result property="submitDeadline" column="submit_deadline" />
<result property="invitationUrl" column="invitation_url" />
<result property="projectInvitationUrl" column="project_invitation_url" />
<result property="scheduleUrl" column="schedule_url" />
<result property="posterUrl" column="poster_url" />
<result property="laborSigned" column="labor_signed" />
@@ -50,10 +49,12 @@
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, address, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, voucher_audit_stage, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, voucher_audit_time, material_compliance_approved, voucher_compliance_approved, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted
meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, address, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, material_compliance_approved, submit_deadline, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long">
select <include refid="selectFields"/>
select
(select o.org_name from biz_project p join biz_org o on o.org_id = p.sponsor_org_id where p.project_id = biz_meeting.project_id limit 1) as org_name,
<include refid="selectFields"/>
from biz_meeting
where meeting_id = #{meetingId} and is_deleted = 0
</select>
@@ -61,6 +62,8 @@
select
<if test="userId != null">(select a.id from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0 limit 1) as attendee_id,</if>
<if test="userId != null">(select a.labor_protocol from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0 limit 1) as attendee_labor_protocol,</if>
(select o.org_name from biz_project p join biz_org o on o.org_id = p.sponsor_org_id where p.project_id = biz_meeting.project_id limit 1) as org_name,
(select p.invitation_url from biz_project p where p.project_id = biz_meeting.project_id limit 1) as project_invitation_url,
<include refid="selectFields"/>
from biz_meeting
<where>
@@ -75,16 +78,24 @@
<if test="endTime != null">and end_time &lt;= #{endTime}</if>
<!-- doctor 角色按 user_id 过滤 (走 biz_meeting_attendee 中间表, 同时 attendee 也需 is_deleted=0) -->
<if test="userId != null">and exists (select 1 from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0)</if>
<!-- sponsor 数据权限: 只看"我的项目"下的会议 (project_id ∈ 我的项目). MAIN 走 sponsor_admin_user_id, SUB 走 sponsor_assign.monitor_user_id.
<!-- sponsor 数据权限: 只看"我的项目"下的会议 (project_id ∈ 我的项目). MAIN 走 sponsor_org_id (经 user_id 反查 org_id), SUB 走 sponsor_assign.monitor_user_id.
刻意不带 biz_publicity_support_intent 关联 (与项目列表 selectSponsorList 的区别点) -->
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_admin_user_id = #{params.sponsorAdminUserId} and is_deleted = 0)</if>
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') and is_deleted = 0)</if>
<if test="params.monitorUserId != null">and project_id in (select distinct project_id from biz_project_sponsor_assign where monitor_user_id = #{params.monitorUserId} and is_deleted = 0)</if>
<!-- executor 数据权限: 只看"我的项目"下的会议. MAIN 走 biz_project_assign (exec_user_id / execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id -->
<!-- 结题且未开通的项目, sponsor 会议不可见 (与项目列表 selectSponsorList 同口径) -->
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
and not exists (
select 1 from biz_project p2
where p2.project_id = biz_meeting.project_id
and p2.is_deleted = 0
and p2.is_finished = '1' and p2.open_status = 'N'
)
</if>
<!-- executor 数据权限: 只看"我的项目"下的会议. MAIN 走 biz_project_assign (execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id -->
<if test="params.executorUserId != null">and project_id in (
select distinct a.project_id from biz_project_assign a
where a.is_deleted = 0
and (a.exec_user_id = #{params.executorUserId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')
)</if>
<if test="params.executorStaffUserId != null">and project_id in (
select distinct a.project_id from biz_project_executor_assign a
@@ -107,14 +118,13 @@
<if test="projectForm != null and projectForm != ''">project_form,</if>
<if test="startTime != null">start_time,</if>
<if test="endTime != null">end_time,</if>
<if test="orgName != null and orgName != ''">org_name,</if>
<if test="address != null and address != ''">address,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion,</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by,</if>
<if test="supervisionTime != null">supervision_time,</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage,</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage,</if>
<if test="submitDeadline != null">submit_deadline,</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if>
<if test="posterUrl != null and posterUrl != ''">poster_url,</if>
@@ -136,14 +146,13 @@
<if test="projectForm != null and projectForm != ''">#{projectForm},</if>
<if test="startTime != null">#{startTime},</if>
<if test="endTime != null">#{endTime},</if>
<if test="orgName != null and orgName != ''">#{orgName},</if>
<if test="address != null and address != ''">#{address},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">#{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">#{supervisionBy},</if>
<if test="supervisionTime != null">#{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">#{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">#{voucherAuditStage},</if>
<if test="submitDeadline != null">#{submitDeadline},</if>
<if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if>
<if test="posterUrl != null and posterUrl != ''">#{posterUrl},</if>
@@ -168,14 +177,12 @@
<!-- Date/Long 字段不能用 != '' (OGNL 会把 Date 和 String 做非法比较), 只判 null -->
<if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="orgName != null and orgName != ''">org_name = #{orgName},</if>
<if test="address != null and address != ''">address = #{address},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if>
<if test="supervisionTime != null">supervision_time = #{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage = #{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage = #{voucherAuditStage},</if>
<if test="isExecuted != null">is_executed = #{isExecuted},</if>
<if test="executeTime != null">execute_time = #{executeTime},</if>
<if test="isSettled != null">is_settled = #{isSettled},</if>
@@ -185,9 +192,8 @@
<if test="isFrozen != null">is_frozen = #{isFrozen},</if>
<if test="freezeTime != null">freeze_time = #{freezeTime},</if>
<if test="materialAuditTime != null">material_audit_time = #{materialAuditTime},</if>
<if test="voucherAuditTime != null">voucher_audit_time = #{voucherAuditTime},</if>
<if test="materialComplianceApproved != null">material_compliance_approved = #{materialComplianceApproved},</if>
<if test="voucherComplianceApproved != null">voucher_compliance_approved = #{voucherComplianceApproved},</if>
<if test="submitDeadline != null">submit_deadline = #{submitDeadline},</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if>
<if test="posterUrl != null and posterUrl != ''">poster_url = #{posterUrl},</if>
@@ -231,21 +237,20 @@
and start_time &lt;= NOW()
and material_audit_stage = 'NOT_SUBMITTED'
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material 未提交 且 end_time + submit_deadline_days 已过 → 冻结 -->
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material 未提交/已退回 且 submit_deadline 已过 → 冻结.
submit_deadline 由建会/退回/解冻 时写入 (end_time 或 now + 项目 submit_deadline_days 天), null = 项目未设天数 → 永不冻结. -->
<update id="markFrozen">
update biz_meeting m
join biz_project p on p.project_id = m.project_id and p.is_deleted = 0
set m.is_frozen = 1,
m.freeze_time = NOW(),
m.current_stage = 'FROZEN'
where m.is_deleted = 0
and m.is_frozen = 0
and m.material_audit_stage = 'NOT_SUBMITTED'
and m.end_time is not null
and p.submit_deadline_days is not null
and date_add(m.end_time, interval p.submit_deadline_days day) &lt;= NOW()
update biz_meeting
set is_frozen = 1,
freeze_time = NOW(),
current_stage = 'FROZEN'
where is_deleted = 0
and is_frozen = 0
and material_audit_stage in ('NOT_SUBMITTED', 'REJECTED')
and submit_deadline is not null
and submit_deadline &lt;= NOW()
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material+voucher 都 APPROVED 且 最晚审核时间已过 1 自然日 → AWAITING_SETTLEMENT (待结算 24h 慢路径) -->
<!-- 自动流转 (MeetingStageScheduler 每分钟调): 材料 APPROVED 且 材料审核时间已过 1 自然日 → AWAITING_SETTLEMENT (待结算 24h 慢路径) -->
<update id="markSettlementReady">
update biz_meeting
set current_stage = 'AWAITING_SETTLEMENT'
@@ -254,10 +259,9 @@
and is_settled = 0
and is_finished = 0
and material_audit_stage = 'APPROVED'
and voucher_audit_stage = 'APPROVED'
and current_stage = 'SUPERVISION_APPROVED'
and greatest(material_audit_time, voucher_audit_time) is not null
and greatest(material_audit_time, voucher_audit_time) &lt;= (NOW() - INTERVAL 1 DAY)
and material_audit_time is not null
and material_audit_time &lt;= (NOW() - INTERVAL 1 DAY)
</update>
<!-- 费用汇总调度器: 查 fee_calc_status=0 且未软删的会议 id -->
<select id="selectPendingFeeCalcIds" resultType="Long">
@@ -33,6 +33,12 @@
order by id asc
</select>
<select id="selectByMeetingAndSubType" resultMap="BizMeetingMaterialResult">
<include refid="selectFields"/>
where meeting_id = #{meetingId} and sub_type = #{subType} and is_deleted = 0
limit 1
</select>
<insert id="insert" parameterType="BizMeetingMaterial" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_material
<trim prefix="(" suffix=")" suffixOverrides=",">
@@ -89,6 +95,13 @@
update biz_meeting_material set fee_status = #{feeStatus} where id = #{id}
</update>
<!-- 会务材料"打包上传"回填: 替换文件 URL/文件名, 清空金额(待重 OCR) + 置 fee_status -->
<update id="updateFile">
update biz_meeting_material
set oss_url = #{ossUrl}, file_name = #{fileName}, amount = null, fee_status = #{feeStatus}
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_material where id = #{id}
</delete>
@@ -5,14 +5,15 @@
<resultMap type="BizMeetingSupervisor" id="BizMeetingSupervisorResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="sponsorOrgId" column="sponsor_org_id" />
<result property="orgName" column="org_name" />
<result property="assignedBy" column="assigned_by" />
<result property="createTime" column="create_time" />
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, user_id, assigned_by, create_time, is_deleted
select id, meeting_id, sponsor_org_id, assigned_by, create_time, is_deleted
from biz_meeting_supervisor
</sql>
@@ -22,15 +23,12 @@
</select>
<select id="selectByMeetingId" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId} and is_deleted = 0
order by id asc
</select>
<select id="selectByUserId" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId} and is_deleted = 0
order by id desc
select s.id, s.meeting_id, s.sponsor_org_id, s.assigned_by, s.create_time, s.is_deleted,
o.org_name
from biz_meeting_supervisor s
left join biz_org o on o.org_id = s.sponsor_org_id
where s.meeting_id = #{meetingId} and s.is_deleted = 0
order by s.id asc
</select>
<select id="selectList" resultMap="BizMeetingSupervisorResult" parameterType="BizMeetingSupervisor">
@@ -38,7 +36,7 @@
<where>
is_deleted = 0
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="userId != null">and user_id = #{userId}</if>
<if test="sponsorOrgId != null">and sponsor_org_id = #{sponsorOrgId}</if>
</where>
order by id asc
</select>
@@ -47,30 +45,30 @@
insert into biz_meeting_supervisor
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="userId != null">user_id,</if>
<if test="sponsorOrgId != null">sponsor_org_id,</if>
<if test="assignedBy != null">assigned_by,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="userId != null">#{userId},</if>
<if test="sponsorOrgId != null">#{sponsorOrgId},</if>
<if test="assignedBy != null">#{assignedBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_supervisor (meeting_id, user_id, assigned_by, create_time)
insert into biz_meeting_supervisor (meeting_id, sponsor_org_id, assigned_by, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime})
(#{item.meetingId}, #{item.sponsorOrgId}, #{item.assignedBy}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingSupervisor">
update biz_meeting_supervisor
<trim prefix="SET" suffixOverrides=",">
<if test="userId != null">user_id = #{userId},</if>
<if test="sponsorOrgId != null">sponsor_org_id = #{sponsorOrgId},</if>
<if test="assignedBy != null">assigned_by = #{assignedBy},</if>
</trim>
where id = #{id}
@@ -103,20 +103,20 @@
<!--
支持方下拉选项: JOIN sys_user 取主账号 user_name (供分配弹窗缓存 biz_project.sponsor_admin_user_name 用)
返回 Map: userId / orgName / userName
返回 Map: orgId / orgName / userName
EXPLAIN 实际命中: idx_org_type_status (org_type,status) (优化器在数据量小时倾向小索引),
org_name LIKE 走 Using where 二次过滤, ORDER BY org_id DESC 走 filesort
过滤条件: orgName 模糊匹配 (主用) / userId 精确匹配 (拉回已选项)
过滤条件: orgName 模糊匹配 (主用) / orgId 精确匹配 (拉回已选项)
-->
<select id="selectSponsorOrgOptions" parameterType="BizOrg" resultType="java.util.LinkedHashMap">
select o.user_id as userId,
select o.org_id as orgId,
o.org_name as orgName,
u.user_name as userName
from biz_org o
join sys_user u on u.user_id = o.user_id
where o.org_type = 'sponsor'
and u.del_flag = '0'
<if test="userId != null">and o.user_id = #{userId}</if>
<if test="orgId != null">and o.org_id = #{orgId}</if>
<if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if>
order by o.org_id desc
LIMIT 5
@@ -124,16 +124,16 @@
<!--
执行方下拉选项: JOIN sys_user 取 MAIN 账号 user_name (parent_user_id IS NULL 限定主账号)
返回 Map: userId / orgName / userName
返回 Map: orgId / orgName / userName
EXPLAIN 实际命中: idx_org_type_status (org_type,status) (优化器在数据量小时倾向小索引),
sys_user 走 PRIMARY eq_ref, parent_user_id IS NULL 走 Using where
关键: 只查 MAIN 账号 (parent_user_id IS NULL), 过滤掉同一公司下的普通员工子账号
value=userId (MAIN 账号 sys_user.user_id, 直接写 biz_project_assign.exec_user_id)
value=orgId (biz_org.org_id, 直接写 biz_project_assign.execution_unit_id)
label=orgName (执行单位名称, 不带 user_name 避免人名/昵称混淆)
过滤条件: orgName 模糊匹配 (主用) / userId 精确匹配 (拉回已选项)
过滤条件: orgName 模糊匹配 (主用) / orgId 精确匹配 (拉回已选项)
-->
<select id="selectExecutorOrgOptions" parameterType="BizOrg" resultType="java.util.LinkedHashMap">
select o.user_id as userId,
select o.org_id as orgId,
o.org_name as orgName,
u.user_name as userName
from biz_org o
@@ -141,12 +141,30 @@
where o.org_type = 'executor'
and u.del_flag = '0'
and u.parent_user_id is null
<if test="userId != null">and o.user_id = #{userId}</if>
<if test="orgId != null">and o.org_id = #{orgId}</if>
<if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if>
order by o.org_id desc
LIMIT 5
</select>
<!--
支持方注册下拉选项 (匿名公开, register-sponsor 选企业注册 SUB 子账号)
与 selectSponsorOrgOptions 的区别: 不 JOIN sys_user → 无主账号 (user_id IS NULL) 的 org 也能被选到
返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null)
mainUserId 用于 registerSponsor 判断: 有主账号 → SUB 绑 parent_user_id; 无主账号 → parent_user_id 留空待 admin/manager 分配
-->
<select id="selectSponsorRegisterOptions" parameterType="BizOrg" resultType="java.util.LinkedHashMap">
select o.org_id as orgId,
o.org_name as orgName,
o.user_id as mainUserId
from biz_org o
where o.org_type = 'sponsor'
and o.status = '0'
<if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if>
order by o.org_id desc
limit 20
</select>
<!--
当前登录 sponsor 的所属公司 (供 /sponsor/account 页面回显 + 主账号改名)
主账号 (sys_user.parent_user_id IS NULL): 用 own (biz_org.user_id = #{userId})
@@ -165,4 +183,17 @@
where u.user_id = #{userId}
limit 1
</select>
<!--
user_id → org_id 单一可信源反查:
MAIN 主账号: biz_org.user_id = #{userId} (企业表直接关联主账号)
SUB 子账号: biz_person.org_id (person 表 user_id = #{userId} 反查企业)
COALESCE 二选一, 都没有返回 NULL
-->
<select id="selectOrgIdByUserId" parameterType="Long" resultType="Long">
select coalesce(
(select org_id from biz_org where user_id = #{userId} limit 1),
(select org_id from biz_person where user_id = #{userId} limit 1)
)
</select>
</mapper>
@@ -10,12 +10,12 @@
<result property="orgType" column="org_type" />
<result property="department" column="department" />
<result property="position" column="position" />
<result property="role" column="role" />
<result property="userId" column="user_id" />
<result property="unitType" column="unit_type" />
<result property="accountType" column="account_type" />
<result property="parentUserId" column="parent_user_id" />
<result property="account" column="user_name" />
<result property="email" column="email" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -23,17 +23,18 @@
</resultMap>
<sql id="selectFields">
select person_id, name, phone, org_id, department, position, role, unit_type, user_id, create_by, create_time, update_by, update_time
select person_id, name, phone, org_id, department, position, unit_type, user_id, create_by, create_time, update_by, update_time
from biz_person
</sql>
<!-- 通用列表: LEFT JOIN biz_org 取公司名/类型, LEFT JOIN sys_user 取账号状态/类型 -->
<sql id="selectFieldsWithAccount">
select p.person_id, p.name, p.phone, p.org_id, o.org_name, o.org_type,
p.department, p.position, p.role, p.unit_type, p.user_id,
p.department, p.position, p.unit_type, p.user_id,
p.create_by, p.create_time, p.update_by, p.update_time,
u.account_type, u.parent_user_id, u.status, u.del_flag as user_del_flag,
u.user_name as user_name
u.user_name as user_name,
u.email as email
from biz_person p
left join biz_org o on p.org_id = o.org_id
left join sys_user u on p.user_id = u.user_id
@@ -51,12 +52,12 @@
<if test="unitType != null and unitType != ''"> and p.unit_type = #{unitType}</if>
<if test="name != null and name != ''"> and p.name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and p.phone = #{phone}</if>
<if test="email != null and email != ''"> and u.email = #{email}</if>
<if test="orgId != null"> and p.org_id = #{orgId}</if>
<if test="orgName != null and orgName != ''"> and o.org_name like concat('%', #{orgName}, '%')</if>
<if test="orgType != null and orgType != ''"> and o.org_type = #{orgType}</if>
<if test="department != null and department != ''"> and p.department = #{department}</if>
<if test="position != null and position != ''"> and p.position = #{position}</if>
<if test="role != null and role != ''"> and p.role = #{role}</if>
<if test="status != null and status != ''"> and u.status = #{status}</if>
<if test="parentUserId != null"> and u.parent_user_id = #{parentUserId}</if>
<if test="userId != null"> and p.user_id = #{userId}</if>
@@ -80,7 +81,6 @@
<if test="orgId != null">org_id,</if>
<if test="department != null">department,</if>
<if test="position != null">position,</if>
<if test="role != null">role,</if>
<if test="unitType != null">unit_type,</if>
<if test="userId != null">user_id,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
@@ -95,7 +95,6 @@
<if test="orgId != null">#{orgId},</if>
<if test="department != null">#{department},</if>
<if test="position != null">#{position},</if>
<if test="role != null">#{role},</if>
<if test="unitType != null">#{unitType},</if>
<if test="userId != null">#{userId},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
@@ -115,7 +114,6 @@
<if test="phone != null">phone = #{phone},</if>
<if test="department != null">department = #{department},</if>
<if test="position != null">position = #{position},</if>
<if test="role != null">role = #{role},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
update_time = sysdate(),
</trim>
@@ -162,7 +160,6 @@
<if test="orgName != null and orgName != ''"> and o.org_name like concat('%', #{orgName}, '%')</if>
<if test="department != null and department != ''"> and p.department = #{department}</if>
<if test="position != null and position != ''"> and p.position = #{position}</if>
<if test="role != null and role != ''"> and p.role = #{role}</if>
<if test="status != null and status != ''"> and u.status = #{status}</if>
</where>
order by p.person_id desc
@@ -183,16 +180,9 @@
<if test="orgName != null and orgName != ''"> and o.org_name like concat('%', #{orgName}, '%')</if>
<if test="department != null and department != ''"> and p.department = #{department}</if>
<if test="position != null and position != ''"> and p.position = #{position}</if>
<if test="role != null and role != ''"> and p.role = #{role}</if>
<if test="status != null and status != ''"> and u.status = #{status}</if>
</where>
order by p.person_id desc
</select>
<!-- 校验某 org 下是否已有 admin 角色 (主账号自带 biz_person.role='admin', 每公司唯一) -->
<select id="countAdminByOrgId" resultType="int">
select count(*) from biz_person p
left join sys_user u on p.user_id = u.user_id
where p.org_id = #{orgId} and p.role = 'admin' and u.del_flag = '0'
</select>
</mapper>
@@ -5,7 +5,6 @@
<id property="assignId" column="assign_id" />
<result property="projectId" column="project_id" />
<result property="executionUnitId" column="execution_unit_id" />
<result property="execUserId" column="exec_user_id" />
<result property="sessions" column="sessions" />
<result property="amount" column="amount" />
<result property="remark" column="remark" />
@@ -18,7 +17,7 @@
</resultMap>
<sql id="selectFields">
select assign_id, project_id, execution_unit_id, exec_user_id,
select assign_id, project_id, execution_unit_id,
sessions, amount, remark, status,
create_by, create_time, update_by, update_time, is_deleted
from biz_project_assign
@@ -41,7 +40,6 @@
is_deleted = 0
<if test="projectId != null"> and project_id = #{projectId}</if>
<if test="executionUnitId != null"> and execution_unit_id = #{executionUnitId}</if>
<if test="execUserId != null"> and exec_user_id = #{execUserId}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by assign_id desc
@@ -57,7 +55,6 @@
<if test="assignId != null and assignId != ''">assign_id,</if>
<if test="projectId != null">project_id,</if>
<if test="executionUnitId != null">execution_unit_id,</if>
<if test="execUserId != null">exec_user_id,</if>
<if test="sessions != null">sessions,</if>
<if test="amount != null">amount,</if>
<if test="remark != null and remark != ''">remark,</if>
@@ -71,7 +68,6 @@
<if test="assignId != null and assignId != ''">#{assignId},</if>
<if test="projectId != null">#{projectId},</if>
<if test="executionUnitId != null">#{executionUnitId},</if>
<if test="execUserId != null">#{execUserId},</if>
<if test="sessions != null">#{sessions},</if>
<if test="amount != null">#{amount},</if>
<if test="remark != null and remark != ''">#{remark},</if>
@@ -87,7 +83,6 @@
update biz_project_assign
<trim prefix="SET" suffixOverrides=",">
<if test="executionUnitId != null">execution_unit_id = #{executionUnitId},</if>
<if test="execUserId != null">exec_user_id = #{execUserId},</if>
<if test="sessions != null">sessions = #{sessions},</if>
<if test="amount != null">amount = #{amount},</if>
<if test="remark != null">remark = #{remark},</if>
@@ -5,7 +5,7 @@
<resultMap id="BaseResultMap" type="BizProjectExecutorAssign">
<id property="id" column="id" />
<result property="projectId" column="project_id" />
<result property="executorUserId" column="executor_user_id" />
<result property="executorOrgId" column="executor_org_id" />
<result property="staffUserId" column="staff_user_id" />
<result property="assignDesc" column="assign_desc" />
<result property="assignPoints" column="assign_points" />
@@ -18,9 +18,9 @@
<insert id="insertAssign" parameterType="BizProjectExecutorAssign" useGeneratedKeys="true" keyProperty="id">
INSERT INTO biz_project_executor_assign
(project_id, executor_user_id, staff_user_id, assign_desc, assign_points, create_by, create_time)
(project_id, executor_org_id, staff_user_id, assign_desc, assign_points, create_by, create_time)
VALUES
(#{projectId}, #{executorUserId}, #{staffUserId}, #{assignDesc}, #{assignPoints}, #{createBy}, sysdate())
(#{projectId}, #{executorOrgId}, #{staffUserId}, #{assignDesc}, #{assignPoints}, #{createBy}, sysdate())
</insert>
<!-- 按 project_id 全删 (执行方分配策略: 先删后插) -->
@@ -38,7 +38,8 @@
e.user_name AS executor_user_name,
s.user_name AS staff_user_name
FROM biz_project_executor_assign a
LEFT JOIN sys_user e ON a.executor_user_id = e.user_id
LEFT JOIN biz_org o ON o.org_id = a.executor_org_id
LEFT JOIN sys_user e ON e.user_id = o.user_id
LEFT JOIN sys_user s ON a.staff_user_id = s.user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC
@@ -18,7 +18,7 @@
<result property="managerScore" column="manager_score" />
<result property="sponsorScore" column="sponsor_score" />
<result property="sponsorAdminUserName" column="sponsor_admin_user_name" />
<result property="sponsorAdminUserId" column="sponsor_admin_user_id" />
<result property="sponsorOrgId" column="sponsor_org_id" />
<result property="leadUserId" column="lead_user_id" />
<result property="leadUserName" column="lead_user_name" />
<result property="isBidProject" column="is_bid_project" />
@@ -43,11 +43,15 @@
<result property="invitationUrl" column="invitation_url" />
<result property="supportLetterUrl" column="support_letter_url" />
<result property="publishUrl" column="publish_url" />
<result property="scheduleUrl" column="schedule_url" />
<result property="openDeadline" column="open_deadline" />
<result property="openStatus" column="open_status" />
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled, p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url, p.is_deleted,
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
@@ -55,7 +59,8 @@
join biz_org o2 on o2.org_id = bpa.execution_unit_id and o2.org_type = 'executor'
where bpa.project_id = p.project_id and bpa.is_deleted = 0) as exec_org_names
from biz_project p
left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor'
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
</sql>
@@ -64,13 +69,14 @@
<sql id="selectFieldsForSponsor">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions,
p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount,
p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled,
p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled,
p.sponsor_org_id, p.lead_user_id, p.is_bid_project,
p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor,
p.start_time, p.end_time, p.submit_deadline_days,
p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url,
p.publish_url, p.is_deleted,
p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
@@ -78,7 +84,8 @@
join biz_org o2 on o2.org_id = bpa.execution_unit_id and o2.org_type = 'executor'
where bpa.project_id = p.project_id and bpa.is_deleted = 0) as exec_org_names
from biz_project p
left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor'
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
</sql>
@@ -91,6 +98,8 @@
<include refid="selectFieldsForSponsor"/>
<where>
p.is_deleted = 0
<!-- 结题(is_finished=1)且未开通(open_status=N)的项目, sponsor 不可见 -->
and not (p.is_finished = '1' and p.open_status = 'N')
<if test="params.projectIds != null and params.projectIds.size() > 0">
and p.project_id in
<foreach collection="params.projectIds" item="id" open="(" separator="," close=")">
@@ -98,12 +107,12 @@
</foreach>
</if>
<!--
sponsor 视角: 可见项目 = MAIN/ADMIN 默认 (sponsor_admin_user_id) OR SUB/监察员 (sponsor_assign.monitor_user_id), 任一路径同时 UNION biz_publicity_support_intent
sponsor 视角: 可见项目 = MAIN/ADMIN 默认 (sponsor_org_id, 经 user_id 反查 org_id) OR SUB/监察员 (sponsor_assign.monitor_user_id), 任一路径同时 UNION biz_publicity_support_intent
业务: sponsor (无论 MAIN/SUB) 提交过支持意向的项目, 同样要在 /sponsor/my-projects 看到, 跟被分配监察员同等地位
分页: PageHelper 加 LIMIT 到外层, 自动生成 COUNT(*) FROM biz_project WHERE ... — 子查询内 DISTINCT 避免重复 count
-->
<if test="params.sponsorAdminUserId != null">and (
p.sponsor_admin_user_id = #{params.sponsorAdminUserId}
p.sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor')
or p.project_id in (
select distinct i.project_id
from biz_publicity_support_intent i
@@ -140,8 +149,9 @@
注: executor 角色无评分/聚合分需求, 复用 selectFields (含 sponsor_score / manager_score)
-->
<select id="selectExecutorList" resultMap="BizProjectResult" parameterType="BizProject">
select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled, p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
@@ -152,22 +162,20 @@
from biz_project_assign bpa3
where bpa3.project_id = p.project_id
and bpa3.is_deleted = 0
and (bpa3.exec_user_id = #{params.executorUserId}
or bpa3.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_sessions,
and bpa3.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as assigned_sessions,
(select coalesce(sum(bpa4.amount), 0)
from biz_project_assign bpa4
where bpa4.project_id = p.project_id
and bpa4.is_deleted = 0
and (bpa4.exec_user_id = #{params.executorUserId}
or bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_amount,
and bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as assigned_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0) as meeting_count
from biz_project p
<!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
join biz_project_assign a on a.project_id = p.project_id
and a.is_deleted = 0
and (a.exec_user_id = #{params.executorUserId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor'
and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
<where>
@@ -188,8 +196,9 @@
场次/金额列 assigned_sessions/assigned_amount 按 params.executorUserId (主账号/本公司) 聚合 — 执行人看到的是公司数据, 不是个人数据
-->
<select id="selectExecutorStaffList" resultMap="BizProjectResult" parameterType="BizProject">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled, p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
@@ -200,17 +209,16 @@
from biz_project_assign bpa3
where bpa3.project_id = p.project_id
and bpa3.is_deleted = 0
and (bpa3.exec_user_id = #{params.executorUserId}
or bpa3.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_sessions,
and bpa3.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as assigned_sessions,
(select coalesce(sum(bpa4.amount), 0)
from biz_project_assign bpa4
where bpa4.project_id = p.project_id
and bpa4.is_deleted = 0
and (bpa4.exec_user_id = #{params.executorUserId}
or bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_amount,
and bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as assigned_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0) as meeting_count
from biz_project p
left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor'
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
<where>
@@ -248,25 +256,26 @@
<if test="projectNo != null and projectNo != ''">and project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''">and project_name like concat('%', #{projectName}, '%')</if>
<if test="projectForm != null and projectForm != ''">and project_form = #{projectForm}</if>
<if test="sponsorAdminUserIds != null and sponsorAdminUserIds.size() > 0">
and sponsor_admin_user_id in
<foreach collection="sponsorAdminUserIds" item="id" open="(" separator="," close=")">
<if test="sponsorOrgIds != null and sponsorOrgIds.size() > 0">
and sponsor_org_id in
<foreach collection="sponsorOrgIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
<if test="execAdminUserIds != null and execAdminUserIds.size() > 0">
<if test="execOrgIds != null and execOrgIds.size() > 0">
and exists (
select 1 from biz_project_assign bpa
where bpa.project_id = project_id
and bpa.exec_user_id in
<foreach collection="execAdminUserIds" item="id" open="(" separator="," close=")">
where bpa.project_id = p.project_id
and bpa.execution_unit_id in
<foreach collection="execOrgIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
)
</if>
<if test="isFinished != null and isFinished != ''">and is_finished = #{isFinished}</if>
<if test="isSettled != null and isSettled != ''">and is_settled = #{isSettled}</if>
<if test="managerScore != null">and manager_score = #{managerScore}</if>
<if test="startTime != null">and start_time &gt;= #{startTime}</if>
<if test="endTime != null">and end_time &lt;= #{endTime}</if>
</where>
order by project_id desc
</select>
@@ -285,11 +294,10 @@
<if test="paidMeetingAmount != null">paid_meeting_amount,</if>
<if test="managerScore != null">manager_score,</if>
<if test="sponsorScore != null">sponsor_score,</if>
<if test="sponsorAdminUserName != null and sponsorAdminUserName != ''">sponsor_admin_user_name,</if>
<if test="projectForm != null and projectForm != ''">project_form,</if>
<if test="isFinished != null and isFinished != ''">is_finished,</if>
<if test="isSettled != null and isSettled != ''">is_settled,</if>
<if test="sponsorAdminUserId != null">sponsor_admin_user_id,</if>
<if test="sponsorOrgId != null">sponsor_org_id,</if>
<if test="leadUserId != null">lead_user_id,</if>
<if test="isBidProject != null and isBidProject != ''">is_bid_project,</if>
<if test="createBy != null">create_by,</if>
@@ -307,6 +315,9 @@
<if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if>
<if test="supportLetterUrl != null and supportLetterUrl != ''">support_letter_url,</if>
<if test="publishUrl != null and publishUrl != ''">publish_url,</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if>
<if test="openDeadline != null">open_deadline,</if>
<if test="openStatus != null and openStatus != ''">open_status,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="projectId != null and projectId != ''">#{projectId},</if>
@@ -321,11 +332,10 @@
<if test="paidMeetingAmount != null">#{paidMeetingAmount},</if>
<if test="managerScore != null">#{managerScore},</if>
<if test="sponsorScore != null">#{sponsorScore},</if>
<if test="sponsorAdminUserName != null and sponsorAdminUserName != ''">#{sponsorAdminUserName},</if>
<if test="projectForm != null and projectForm != ''">#{projectForm},</if>
<if test="isFinished != null and isFinished != ''">#{isFinished},</if>
<if test="isSettled != null and isSettled != ''">#{isSettled},</if>
<if test="sponsorAdminUserId != null">#{sponsorAdminUserId},</if>
<if test="sponsorOrgId != null">#{sponsorOrgId},</if>
<if test="leadUserId != null">#{leadUserId},</if>
<if test="isBidProject != null and isBidProject != ''">#{isBidProject},</if>
<if test="createBy != null">#{createBy},</if>
@@ -343,6 +353,9 @@
<if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if>
<if test="supportLetterUrl != null and supportLetterUrl != ''">#{supportLetterUrl},</if>
<if test="publishUrl != null and publishUrl != ''">#{publishUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if>
<if test="openDeadline != null">#{openDeadline},</if>
<if test="openStatus != null and openStatus != ''">#{openStatus},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizProject">
@@ -358,6 +371,9 @@
<if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if>
<if test="supportLetterUrl != null and supportLetterUrl != ''">support_letter_url = #{supportLetterUrl},</if>
<if test="publishUrl != null and publishUrl != ''">publish_url = #{publishUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if>
<if test="openDeadline != null">open_deadline = #{openDeadline},</if>
<if test="openStatus != null and openStatus != ''">open_status = #{openStatus},</if>
<if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if>
<if test="projectName != null and projectName != ''">project_name = #{projectName},</if>
<if test="totalSessions != null">total_sessions = #{totalSessions},</if>
@@ -369,11 +385,10 @@
<if test="paidMeetingAmount != null">paid_meeting_amount = #{paidMeetingAmount},</if>
<if test="managerScore != null">manager_score = #{managerScore},</if>
<if test="sponsorScore != null">sponsor_score = #{sponsorScore},</if>
<if test="sponsorAdminUserName != null and sponsorAdminUserName != ''">sponsor_admin_user_name = #{sponsorAdminUserName},</if>
<if test="projectForm != null and projectForm != ''">project_form = #{projectForm},</if>
<if test="isFinished != null and isFinished != ''">is_finished = #{isFinished},</if>
<if test="isSettled != null and isSettled != ''">is_settled = #{isSettled},</if>
<if test="sponsorAdminUserId != null">sponsor_admin_user_id = #{sponsorAdminUserId},</if>
<if test="sponsorOrgId != null">sponsor_org_id = #{sponsorOrgId},</if>
<if test="leadUserId != null">lead_user_id = #{leadUserId},</if>
<if test="isBidProject != null and isBidProject != ''">is_bid_project = #{isBidProject},</if>
<if test="createBy != null">create_by = #{createBy},</if>
@@ -384,6 +399,24 @@
</trim>
where project_id = #{projectId}
</update>
<!-- 删除公告: 将 4 个公示 URL 字段全部置 NULL (未发布态由 URL 是否为空判定, 表里无独立 is_published 列) -->
<update id="clearAnnouncement" parameterType="Long">
update biz_project
set invitation_url = null,
support_letter_url = null,
publish_url = null,
schedule_url = null
where project_id = #{projectId}
</update>
<!-- 开通到期回收: 每天 00:05 由 OpenStatusScheduler 调用, 到期(open_deadline <= 今天)的 Y 置回 N -->
<update id="closeExpiredOpenStatus">
update biz_project
set open_status = 'N'
where open_status = 'Y'
and open_deadline is not null
and open_deadline &lt;= CURDATE()
and is_deleted = 0
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_project where project_id = #{projectId}
</delete>
@@ -407,8 +440,7 @@
from biz_project_assign a
where a.project_id = #{projectId}
and a.is_deleted = 0
and (a.exec_user_id = #{executorUserId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{executorUserId} and org_type = 'executor'))
and a.execution_unit_id = (select org_id from biz_org where user_id = #{executorUserId} and org_type = 'executor')
</select>
<!-- 提交权限用: 判断 user 是否该项目的执行方.
MAIN: biz_project_assign (exec_user_id 或 其执行单位 org); SUB(执行人): biz_project_executor_assign.staff_user_id.
@@ -420,8 +452,7 @@
from biz_project_assign a
where a.project_id = #{projectId}
and a.is_deleted = 0
and (a.exec_user_id = #{userId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{userId} and org_type = 'executor'))
and a.execution_unit_id = (select org_id from biz_org where user_id = #{userId} and org_type = 'executor')
union
select 1
from biz_project_executor_assign b
@@ -10,6 +10,7 @@
<result property="planCategory" column="plan_category" />
<result property="projectForm" column="project_form" />
<result property="designFileUrl" column="design_file_url" />
<result property="subjectDirection" column="subject_direction" />
<result property="status" column="status" />
<result property="isSettled" column="is_settled" />
<result property="projectNo" column="project_no" />
@@ -29,7 +30,7 @@
<sql id="selectFields">
select p.plan_id, p.plan_name, p.plan_direction, p.plan_direction_id,
s.title as plan_direction_title,
p.plan_category, p.project_form, p.design_file_url, p.status, p.is_settled,
p.plan_category, p.project_form, p.design_file_url, p.subject_direction, p.status, p.is_settled,
p.project_no, p.remark, p.audit_opinion, p.audit_by, p.audit_time, p.submitter_id,
COALESCE(bp.name, u.user_name) as submitter_name,
proj.project_name as project_name,
@@ -70,6 +71,7 @@
<if test="planCategory != null and planCategory != ''">plan_category,</if>
<if test="projectForm != null and projectForm != ''">project_form,</if>
<if test="designFileUrl != null and designFileUrl != ''">design_file_url,</if>
<if test="subjectDirection != null and subjectDirection != ''">subject_direction,</if>
<if test="status != null">status,</if>
<if test="isSettled != null and isSettled != ''">is_settled,</if>
<if test="projectNo != null and projectNo != ''">project_no,</if>
@@ -84,6 +86,7 @@
<if test="planCategory != null and planCategory != ''">#{planCategory},</if>
<if test="projectForm != null and projectForm != ''">#{projectForm},</if>
<if test="designFileUrl != null and designFileUrl != ''">#{designFileUrl},</if>
<if test="subjectDirection != null and subjectDirection != ''">#{subjectDirection},</if>
<if test="status != null">#{status},</if>
<if test="isSettled != null and isSettled != ''">#{isSettled},</if>
<if test="projectNo != null and projectNo != ''">#{projectNo},</if>
@@ -100,6 +103,7 @@
<if test="planCategory != null and planCategory != ''">plan_category = #{planCategory},</if>
<if test="projectForm != null and projectForm != ''">project_form = #{projectForm},</if>
<if test="designFileUrl != null and designFileUrl != ''">design_file_url = #{designFileUrl},</if>
<if test="subjectDirection != null and subjectDirection != ''">subject_direction = #{subjectDirection},</if>
<if test="isSettled != null and isSettled != ''">is_settled = #{isSettled},</if>
<if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if>
<if test="auditOpinion != null and auditOpinion != ''">audit_opinion = #{auditOpinion},</if>
@@ -5,7 +5,7 @@
<resultMap id="BaseResultMap" type="BizProjectSponsorAssign">
<id property="id" column="id" />
<result property="projectId" column="project_id" />
<result property="sponsorUserId" column="sponsor_user_id" />
<result property="sponsorOrgId" column="sponsor_org_id" />
<result property="monitorUserId" column="monitor_user_id" />
<result property="assignDesc" column="assign_desc" />
<result property="assignPoints" column="assign_points" />
@@ -18,9 +18,9 @@
<insert id="insertAssign" parameterType="BizProjectSponsorAssign" useGeneratedKeys="true" keyProperty="id">
INSERT INTO biz_project_sponsor_assign
(project_id, sponsor_user_id, monitor_user_id, assign_desc, assign_points, create_by, create_time)
(project_id, sponsor_org_id, monitor_user_id, assign_desc, assign_points, create_by, create_time)
VALUES
(#{projectId}, #{sponsorUserId}, #{monitorUserId}, #{assignDesc}, #{assignPoints}, #{createBy}, sysdate())
(#{projectId}, #{sponsorOrgId}, #{monitorUserId}, #{assignDesc}, #{assignPoints}, #{createBy}, sysdate())
</insert>
<!-- 按 project_id 全删 (支持方分配策略: 一个项目只分配一个 sponsor, 先删后插) -->
@@ -38,7 +38,8 @@
s.user_name AS sponsor_user_name,
m.user_name AS monitor_user_name
FROM biz_project_sponsor_assign a
LEFT JOIN sys_user s ON a.sponsor_user_id = s.user_id
LEFT JOIN biz_org o ON o.org_id = a.sponsor_org_id
LEFT JOIN sys_user s ON s.user_id = o.user_id
LEFT JOIN sys_user m ON a.monitor_user_id = m.user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC
@@ -170,6 +170,8 @@ public class RuoYiConfig
private String accessKeySecret;
private String dirPrefix = "ry8080/";
private int expireSeconds = 3600;
/** 阿里云函数计算 (FC) 打 zip 端点 (参考 hwt-serve OssApi.ossDownload, 在 OSS 端打包) */
private String zipFuncUrl;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@@ -185,5 +187,7 @@ public class RuoYiConfig
public void setDirPrefix(String dirPrefix) { this.dirPrefix = dirPrefix; }
public int getExpireSeconds() { return expireSeconds; }
public void setExpireSeconds(int expireSeconds) { this.expireSeconds = expireSeconds; }
public String getZipFuncUrl() { return zipFuncUrl; }
public void setZipFuncUrl(String zipFuncUrl) { this.zipFuncUrl = zipFuncUrl; }
}
}
@@ -1,10 +1,10 @@
package com.ruoyi.common.enums;
/**
* 材料/凭证 审核阶段 (biz_meeting.material_audit_stage / voucher_audit_stage)
* 材料 审核阶段 (biz_meeting.material_audit_stage)
* <p>
* 4 值 (用户拍板「已提交/待审核」合并): 提交后进入 SUBMITTED, 两级审核(合规先-支持方后)
* 用 material_compliance_approved / voucher_compliance_approved 布尔区分「合规审中 vs 支持方审中」.
* 用 material_compliance_approved 布尔区分「合规审中 vs 支持方审中」.
* <pre>
* NOT_SUBMITTED 未提交 执行方还没交
* ↓ (执行方提交)
@@ -148,9 +148,10 @@ public class SysLoginService
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
// 用户名不在指定范围内 错误
if (username.length() < UserConstants.USERNAME_MIN_LENGTH
|| username.length() > UserConstants.USERNAME_MAX_LENGTH)
// 用户名不在指定范围内 错误 (邮箱登录含 @ 不受 2-20 位限制)
if (!username.contains("@")
&& (username.length() < UserConstants.USERNAME_MIN_LENGTH
|| username.length() > UserConstants.USERNAME_MAX_LENGTH))
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
@@ -38,6 +38,11 @@ public class UserDetailsServiceImpl implements UserDetailsService
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
SysUser user = userService.selectUserByUserName(username);
// 用户名查不到且输入含 @ → 视为邮箱登录 (邮箱+密码)
if (StringUtils.isNull(user) && username.contains("@"))
{
user = userService.selectUserByEmail(username);
}
if (StringUtils.isNull(user))
{
log.info("登录用户:{} 不存在.", username);
@@ -54,6 +59,17 @@ public class UserDetailsServiceImpl implements UserDetailsService
throw new ServiceException(MessageUtils.message("user.blocked"));
}
// 子账号专属: 主账号 (parent_user_id) 被禁用 → 拦截登录
if (user.getParentUserId() != null)
{
SysUser parent = userService.selectUserById(user.getParentUserId());
if (parent != null && UserStatus.DISABLE.getCode().equals(parent.getStatus()))
{
log.info("登录用户:{} 所属机构主账号已禁用.", username);
throw new ServiceException("您所属的机构已禁用");
}
}
passwordService.validate(user);
return createLoginUser(user);
@@ -53,6 +53,14 @@ public interface SysUserMapper
*/
public SysUser selectUserByUserName(String userName);
/**
* 通过邮箱查询用户 (登录支持邮箱+密码)
*
* @param email 邮箱
* @return 用户对象信息
*/
public SysUser selectUserByEmail(String email);
/**
* 通过用户ID查询用户
*
@@ -96,6 +104,22 @@ public interface SysUserMapper
public int updateUserStatus(@Param("userId") Long userId, @Param("status") String status);
public int updateRoleType(@Param("userId") Long userId, @Param("roleType") String roleType);
/**
* 更换机构管理员: 改账号类型 + 主账号指向
* parentUserId 传 null = 晋升主账号 (account_type='MAIN', parent_user_id=NULL)
*
* @param userId 目标 sys_user.user_id
* @param accountType 'MAIN' / 'SUB'
* @param parentUserId 子账号指向的主账号 user_id (MAIN 传 null)
*/
public int updateAccountType(@Param("userId") Long userId, @Param("accountType") String accountType, @Param("parentUserId") Long parentUserId);
/**
* 把原管理员的所有子账号 re-point 到新管理员 (更换管理员时, 避免出现三层树)
* 排除新管理员自己 (其 parent_user_id 当前仍指向原管理员)
*/
public int updateParentUserId(@Param("oldParentUserId") Long oldParentUserId, @Param("newParentUserId") Long newParentUserId);
/**
* 更新用户登录信息(IP和登录时间)
*
@@ -52,6 +52,14 @@ public interface ISysUserService
*/
public SysUser selectUserByUserName(String userName);
/**
* 通过邮箱查询用户 (登录支持邮箱+密码)
*
* @param email 邮箱
* @return 用户对象信息
*/
public SysUser selectUserByEmail(String email);
/**
* 通过用户ID查询用户
*
@@ -131,6 +131,15 @@ public class SysUserServiceImpl implements ISysUserService
return userMapper.selectUserByUserName(userName);
}
/**
* 通过邮箱查询用户 (登录支持邮箱+密码)
*/
@Override
public SysUser selectUserByEmail(String email)
{
return userMapper.selectUserByEmail(email);
}
/**
* 通过用户ID查询用户
*
@@ -170,6 +170,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="selectUserVo"/>
where u.user_name = #{userName} and u.del_flag = '0'
</select>
<select id="selectUserByEmail" parameterType="String" resultMap="SysUserResult">
<include refid="selectUserVo"/>
where u.email = #{email} and u.del_flag = '0'
</select>
<select id="selectUserById" parameterType="Long" resultMap="SysUserResult">
<include refid="selectUserVo"/>
@@ -275,6 +280,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
update sys_user set role_type = #{roleType} where user_id = #{userId}
</update>
<!-- 更换管理员: 改 account_type + parent_user_id (parentUserId 为 null 时显式写 NULL = 晋升主账号) -->
<update id="updateAccountType">
update sys_user
set account_type = #{accountType},
<choose>
<when test="parentUserId != null">parent_user_id = #{parentUserId},</when>
<otherwise>parent_user_id = null,</otherwise>
</choose>
update_time = sysdate()
where user_id = #{userId}
</update>
<!-- 更换管理员: 原管理员的所有子账号 re-point 到新管理员 (排除新管理员自己) -->
<update id="updateParentUserId">
update sys_user
set parent_user_id = #{newParentUserId}, update_time = sysdate()
where parent_user_id = #{oldParentUserId}
and user_id != #{newParentUserId}
and del_flag = '0'
</update>
<update id="updateUserStatus" parameterType="SysUser">
update sys_user set status = #{status}, update_time = sysdate() where user_id = #{userId}
</update>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" />
<title>北京整合医学学会 - 项目管理系统</title>
<title>合规系统</title>
</head>
<body>
<div id="app"></div>
+8 -1
View File
@@ -26,6 +26,9 @@ export const registerExpert = (data) => request.post('/business/auth/registerExp
export const registerExecutor = (data) => request.post('/business/auth/registerExecutor', data)
export const registerSponsor = (data) => request.post('/business/auth/registerSponsor', data)
// 支持方注册选企业下拉 (匿名公开): 返回 [{orgId, orgName, mainUserId}]
export const sponsorOrgOptions = (query) => request.get('/business/auth/sponsorOrgOptions', { params: query })
// 业务 CRUDgeneric
// announcement 后端接口未实现, 已改为调 RuoYi 系统通知接口 /system/notice/list
const STUB_ENTITIES = new Set(['announcement'])
@@ -97,10 +100,14 @@ export const bizUpdate = (entity, data, opts) => request.put(`/business/${entity
export const toggleOrgStatus = (orgId, status) => request.put('/business/org/toggleStatus', { orgId, status })
export const bizDelete = (entity, ids) => request.delete(`/business/${entity}/${ids}`)
// 更换机构管理员 (admin/sponsor-people 管理员 switch): 目标人员晋升 MAIN, 原管理员降 SUB
export const changePersonAdmin = (personId) => request.put('/business/person/changeAdmin', { personId })
// 人员批量导入 (unitType: 'sponsor' | 'executor')
export const importPerson = (unitType, file) => {
export const importPerson = (unitType, file, orgId) => {
const form = new FormData()
form.append('file', file)
if (orgId != null) form.append('orgId', orgId)
return request.post(`/business/person/${unitType}Import`, form, {
headers: { 'Content-Type': 'multipart/form-data' }
})
+3 -4
View File
@@ -23,16 +23,15 @@ export const listSupporters = (query) => listByRole({ roleType: 'sponsor', ...qu
// 合规管理员 (sys_user.role_type='manager', 选项目负责人用)
export const listManagers = (query) => listByRole({ roleType: 'manager', ...query })
// 支持方下拉选项 (JOIN biz_org + sys_user): 返回 [{ userId, orgName, userName }, ...]
// 支持方下拉选项 (JOIN biz_org + sys_user): 返回 [{ orgId, orgName, userName }, ...]
// 用于 manager 项目分配弹窗按公司名搜索 + 选公司
export function listSponsorOrgs(query) {
return request({ url: '/business/org/sponsorOptions', method: 'get', params: query })
}
// 执行方下拉选项 (JOIN biz_org + sys_user, 仅 MAIN 账号): 返回 [{ userId, orgName, userName }, ...]
// 执行方下拉选项 (JOIN biz_org + sys_user, 仅 MAIN 账号): 返回 [{ orgId, orgName, userName }, ...]
// 用于 manager 项目分配弹窗按公司名搜索 + 选执行单位
// 关键: 后端 SQL 已限定 parent_user_id IS NULL, 自动排除同公司的普通员工子账号
// 业务上 label=orgName / value=MAIN user_id, 直接写 biz_project_assign.exec_user_id
// 关键: label=orgName / value=org_id, 直接写 biz_project_assign.execution_unit_id
export function listExecutorOrgs(query) {
return request({ url: '/business/org/executorOptions', method: 'get', params: query })
}
+13 -11
View File
@@ -49,11 +49,9 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'change'])
const areaMap = new Map() // value -> {label, parent}
const labelToValue = new Map()
;(function buildMap(list, parent) {
for (const item of list) {
areaMap.set(item.value, { label: item.label, parent })
labelToValue.set(item.label, item.value)
if (item.children) buildMap(item.children, item.value)
}
})(areaData, null)
@@ -64,17 +62,21 @@ function toLabels(values) {
return values.map(v => areaMap.get(v)?.label || '')
}
// 把 label 数组翻译成 value 数组 (用于字符串回填)
function toValues(labels) {
if (!Array.isArray(labels)) return []
return labels.map(l => labelToValue.get(l) || '')
}
// 字符串 "北京市/北京市/东城区" → value 数组
// 字符串 "北京市/市辖区/东城区" → value 数组
// 按层级逐级匹配 label (而非扁平 label→value), 避免 label 重名冲突 (如"市辖区"在北京/乌鲁木齐等 185+ 处重名)
function stringToValues(str) {
if (typeof str !== 'string' || !str) return []
const labels = str.split(props.joinSep)
return toValues(labels)
const labels = str.split(props.joinSep).filter(Boolean)
const result = []
let level = areaData
const cap = Math.min(labels.length, props.maxLevel)
for (let i = 0; i < cap; i++) {
const found = level.find(item => item.label === labels[i])
if (!found) break
result.push(found.value)
level = found.children || []
}
return result
}
// 按 maxLevel 截断级数 (2=省/市, 3=省/市/区)
+13 -2
View File
@@ -4,7 +4,7 @@
<!-- 图片 -->
<img v-if="kind === 'image'" :src="fileUrl" class="preview-img" :alt="title" />
<!-- PDFiframe 预览浏览器内置 viewer -->
<iframe v-else-if="kind === 'pdf'" :src="fileUrl" class="preview-iframe" />
<iframe v-else-if="kind === 'pdf'" :src="proxyUrl(fileUrl)" class="preview-iframe" />
<!-- 其它doc/xls/zip 给下载链接 -->
<div v-else class="preview-fallback">
<el-icon class="fallback-icon"><Document /></el-icon>
@@ -47,9 +47,20 @@ const kind = computed(() => {
return 'other'
})
// OSS bucket 设置了 Content-Disposition: attachment, iframe 直接访问会被强制下载 (空白+下载)
// PDF 走 /common/oss/proxy 后端代理重写为 inline (与 publicity/{id} / doctor/Meetings / manager/Plans 同技术)
// #toolbar=0 隐藏 Chrome PDF viewer 工具栏, #zoom=page-width 强制 PDF 撑满 iframe 宽度
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
}
return url
}
function openNew() {
if (!fileUrl.value) return
window.open(fileUrl.value, '_blank', 'noopener')
window.open(proxyUrl(fileUrl.value), '_blank', 'noopener')
}
async function copyUrl() {
if (!fileUrl.value) return
+4 -5
View File
@@ -167,7 +167,7 @@ const MENU = {
{ path: '/admin/library', title: '资料库管理', icon: Box, children: [
{ path: '/admin/experts', title: '专家管理', icon: UserFilled },
{ path: '/admin/sponsor-orgs', title: '支持单位管理', icon: Star },
{ path: '/admin/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
{ path: '/admin/executor-orgs', title: '执行单位管理', icon: OfficeBuilding },
{ path: '/admin/department', title: '科室管理', icon: Grid },
{ path: '/admin/title', title: '职称管理', icon: Medal },
{ path: '/admin/project-category', title: '项目类别管理', icon: Collection }
@@ -182,12 +182,12 @@ const MENU = {
],
manager: [
{ path: '/manager/workbench', title: '工作台', icon: House },
{ path: '/manager/plans', title: '项目策划方案', icon: EditPen },
{ path: '/manager/plans', title: '策划方案管理', icon: EditPen },
{ path: '/manager/projects', title: '项目管理', icon: Document },
{ path: '/manager/meetings', title: '会议管理', icon: Calendar },
{ path: '/manager/experts', title: '专家审核', icon: User },
{ path: '/manager/sponsor-orgs', title: '支持单位管理', icon: Connection },
{ path: '/manager/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
{ path: '/manager/executor-orgs', title: '执行单位管理', icon: OfficeBuilding },
{ path: '/manager/support-intent', title: '支持意向', icon: Tickets },
{ path: '/manager/exec-intent', title: '执行意向', icon: Tickets },
{ path: '/manager/messages', title: '消息通知', icon: Bell },
@@ -203,10 +203,9 @@ const MENU = {
{ path: '/doctor/account', title: '账号信息', icon: User }
],
executor: [
{ path: '/executor/overview', title: '首页', icon: House },
{ path: '/executor/submissions', title: '我的项目策划方案', icon: EditPen },
{ path: '/executor/meetings', title: '会议执行', icon: Calendar },
{ path: '/executor/projects', title: '项目列表', icon: Document },
{ path: '/executor/meetings', title: '会议列表', icon: Calendar },
{ path: '/executor/people', title: '人员管理', icon: User, requireMain: true },
{ path: '/executor/messages', title: '消息通知', icon: Bell },
{ path: '/executor/account', title: '账号信息', icon: Setting }
+13 -8
View File
@@ -28,6 +28,7 @@ const routes = [
{ path: 'projects', name: 'admin-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } },
{ path: 'meetings', name: 'admin-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议管理' } },
{ path: 'meetings/detail/:meetingId', name: 'admin-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'meetings/view/:meetingId', name: 'admin-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
{ path: 'meetings/new', name: 'admin-meetings-new', component: () => import('@/views/meetings/MeetingNew.vue'), meta: { title: '新建会议' } },
{ path: 'department', name: 'admin-department', component: () => import('@/views/admin/Department.vue'), meta: { title: '科室管理' } },
{ path: 'title', name: 'admin-title', component: () => import('@/views/admin/Title.vue'), meta: { title: '职称管理' } },
@@ -36,12 +37,12 @@ const routes = [
{ path: 'experts/edit/:id', name: 'admin-experts-edit', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '编辑专家' } },
{ path: 'experts/view/:id', name: 'admin-experts-view', component: () => import('@/views/admin/ExpertDetail.vue'), meta: { title: '专家详情' } },
{ path: 'sponsor-orgs', name: 'admin-sponsor-orgs', component: () => import('@/views/sponsor-orgs/SponsorOrgs.vue'), meta: { title: '支持单位管理' } },
{ path: 'executor-orgs', name: 'admin-executor-orgs', component: () => import('@/views/executor-orgs/ExecutorOrgs.vue'), meta: { title: '服务机构管理' } },
{ path: 'executor-orgs', name: 'admin-executor-orgs', component: () => import('@/views/executor-orgs/ExecutorOrgs.vue'), meta: { title: '执行单位管理' } },
{ path: 'sponsor-people', name: 'admin-sponsor-people', component: () => import('@/views/sponsor-people/SponsorPeople.vue'), meta: { title: '支持单位下人员' } },
{ path: 'sponsor-people/new', name: 'admin-sponsor-people-new', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'sponsor-people/edit/:id', name: 'admin-sponsor-people-edit', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'sponsor-people/view/:id', name: 'admin-sponsor-people-view', component: () => import('@/views/sponsor-people/SponsorPersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'executor-people', name: 'admin-executor-people', component: () => import('@/views/executor-people/ExecutorPeople.vue'), meta: { title: '服务机构下人员' } },
{ path: 'executor-people', name: 'admin-executor-people', component: () => import('@/views/executor-people/ExecutorPeople.vue'), meta: { title: '执行单位下人员' } },
{ path: 'executor-people/new', name: 'admin-executor-people-new', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'admin-executor-people-edit', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'executor-people/view/:id', name: 'admin-executor-people-view', component: () => import('@/views/executor-people/ExecutorPersonDetail.vue'), meta: { title: '人员详情' } },
@@ -60,7 +61,8 @@ const routes = [
{ path: '/manager', component: AdminLayout, meta: { role: 'manager' }, children: [
{ path: '', redirect: { name: 'manager-workbench' } },
{ path: 'workbench', name: 'manager-workbench', component: () => import('@/views/manager/Workbench.vue'), meta: { title: '工作台' } },
{ path: 'plans', name: 'manager-plans', component: () => import('@/views/manager/Plans.vue'), meta: { title: '项目策划方案' } },
{ path: 'plans', name: 'manager-plans', component: () => import('@/views/manager/Plans.vue'), meta: { title: '策划方案管理' } },
{ path: 'plans/edit/:planId', name: 'manager-plans-edit', component: () => import('@/views/manager/PlanEdit.vue'), meta: { title: '修改策划方案' } },
{ path: 'projects', name: 'manager-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } },
{ path: 'projects/new', name: 'manager-projects-new', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '新建项目' } },
{ path: 'projects/edit/:projectId', name: 'manager-projects-edit', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '编辑项目' } },
@@ -68,18 +70,19 @@ const routes = [
{ path: 'projects/assign', name: 'manager-projects-assign', component: () => import('@/views/manager/ManagerProjectsAssign.vue'), meta: { title: '项目分配' } },
{ path: 'meetings', name: 'manager-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议管理' } },
{ path: 'meetings/detail/:meetingId', name: 'manager-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'meetings/view/:meetingId', name: 'manager-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
{ path: 'meetings/new', name: 'manager-meetings-new', component: () => import('@/views/meetings/MeetingNew.vue'), meta: { title: '新建会议' } },
{ path: 'experts', name: 'manager-experts', component: () => import('@/views/expert/Experts.vue'), meta: { title: '专家审核' } },
{ path: 'experts/new', name: 'manager-experts-new', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '新建专家' } },
{ path: 'experts/edit/:id', name: 'manager-experts-edit', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '编辑专家' } },
{ path: 'experts/view/:id', name: 'manager-experts-view', component: () => import('@/views/admin/ExpertDetail.vue'), meta: { title: '专家详情' } },
{ path: 'sponsor-orgs', name: 'manager-sponsor-orgs', component: () => import('@/views/sponsor-orgs/SponsorOrgs.vue'), meta: { title: '支持单位管理' } },
{ path: 'executor-orgs', name: 'manager-executor-orgs', component: () => import('@/views/executor-orgs/ExecutorOrgs.vue'), meta: { title: '服务机构管理' } },
{ path: 'executor-orgs', name: 'manager-executor-orgs', component: () => import('@/views/executor-orgs/ExecutorOrgs.vue'), meta: { title: '执行单位管理' } },
{ path: 'sponsor-people', name: 'manager-sponsor-people', component: () => import('@/views/sponsor-people/SponsorPeople.vue'), meta: { title: '支持单位下人员' } },
{ path: 'sponsor-people/new', name: 'manager-sponsor-people-new', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'sponsor-people/edit/:id', name: 'manager-sponsor-people-edit', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'sponsor-people/view/:id', name: 'manager-sponsor-people-view', component: () => import('@/views/sponsor-people/SponsorPersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'executor-people', name: 'manager-executor-people', component: () => import('@/views/executor-people/ExecutorPeople.vue'), meta: { title: '服务机构下人员' } },
{ path: 'executor-people', name: 'manager-executor-people', component: () => import('@/views/executor-people/ExecutorPeople.vue'), meta: { title: '执行单位下人员' } },
{ path: 'executor-people/new', name: 'manager-executor-people-new', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'manager-executor-people-edit', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'executor-people/view/:id', name: 'manager-executor-people-view', component: () => import('@/views/executor-people/ExecutorPersonDetail.vue'), meta: { title: '人员详情' } },
@@ -106,15 +109,16 @@ const routes = [
]
},
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
{ path: '', redirect: { name: 'executor-overview' } },
{ path: '', redirect: { name: 'executor-submissions' } },
{ path: 'overview', name: 'executor-overview', component: () => import('@/views/executor/Overview.vue'), meta: { title: '首页' } },
{ path: 'submissions', name: 'executor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
{ path: 'submission/new', name: 'executor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
{ path: 'submission/detail/:planId', name: 'executor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '策划方案详情' } },
{ path: 'submission/edit/:planId', name: 'executor-submission-edit', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '修改项目策划方案' } },
{ path: 'meetings', name: 'executor-meetings', component: () => import('@/views/executor/Meetings.vue'), meta: { title: '会议执行' } },
{ path: 'meetings', name: 'executor-meetings', component: () => import('@/views/executor/Meetings.vue'), meta: { title: '会议列表' } },
{ path: 'meetings/new', name: 'executor-meetings-new', component: () => import('@/views/meetings/MeetingNew.vue'), meta: { title: '新建会议' } },
{ path: 'meetings/detail/:meetingId', name: 'executor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'meetings/view/:meetingId', name: 'executor-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
{ path: 'projects', name: 'executor-projects', component: () => import('@/views/executor/Projects.vue'), meta: { title: '项目列表' } },
{ path: 'projects/detail/:projectId', name: 'executor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'people', name: 'executor-people', component: () => import('@/views/executor/People.vue'), meta: { title: '人员管理' } },
@@ -138,6 +142,7 @@ const routes = [
{ path: 'projects/detail/:projectId', name: 'sponsor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'meetings', name: 'sponsor-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议列表' } },
{ path: 'meetings/detail/:meetingId', name: 'sponsor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'meetings/view/:meetingId', name: 'sponsor-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
{ path: 'people', name: 'sponsor-people', component: () => import('@/views/sponsor/SponsorPeople.vue'), meta: { title: '人员管理' } },
{ path: 'people/new', name: 'sponsor-people-new', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '新建人员' } },
{ path: 'people/edit/:id', name: 'sponsor-people-edit', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '编辑人员' } },
@@ -155,7 +160,7 @@ const router = createRouter({
})
router.beforeEach((to, from, next) => {
document.title = (to.meta?.title || 'BAHIM') + ' - BAHIM 项目管理系统'
document.title = (to.meta?.title || 'BAHIM') + ' - 合规系统'
// 公开路由 (无 role meta) 不拦截
if (!to.meta?.role) return next()
// 需要登录的路由: 未登录跳登录 (带 redirect)
+6 -8
View File
@@ -2,8 +2,8 @@
* 会议阶段显示 (事实驱动, 与后端 StageDeriver 镜像).
*
* biz_meeting 现在存「事实」: is_executed / is_frozen / is_settled / is_finished
* + material_audit_stage / voucher_audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED)
* + material/voucher_compliance_approved (区分两级审核) + 审核时间.
* + material_audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED)
* + material_compliance_approved (区分两级审核) + 审核时间.
*
* 各角色看到的「阶段名称」由这些事实实时推导, 不再是单一 current_stage 枚举投影.
* current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配.
@@ -15,14 +15,12 @@ function isTrue(v) {
return v === 1 || v === '1' || v === true
}
/** 待结算: 材料+凭证都通过 且 最晚审核时间已超 24h */
/** 待结算: 材料审核通过 且 材料审核时间已超 24h */
function settlementReady(row) {
if (row.voucherAuditStage !== 'APPROVED') return false
if (row.materialAuditStage !== 'APPROVED') return false
const mat = row.materialAuditTime ? new Date(row.materialAuditTime).getTime() : 0
const vch = row.voucherAuditTime ? new Date(row.voucherAuditTime).getTime() : 0
const later = Math.max(mat, vch)
if (!later) return false
return Date.now() - later >= H24
if (!mat) return false
return Date.now() - mat >= H24
}
/**
+6 -3
View File
@@ -36,14 +36,17 @@ request.interceptors.response.use(
const redirect = window.location.hash.slice(1) || ''
window.location.href = import.meta.env.BASE_URL + '#/login' + (redirect ? '?redirect=' + encodeURIComponent(redirect) : '')
}
// __silentError: 调用方已经接管错误 toast (如把红错改成黄警), 拦截器不再重复弹
if (!res.config?.__silentError) {
// 写操作 (POST/PUT/DELETE) 的错误由调用方 catch 自己 toast (避免拦截器 + catch 双 toast);
// 读操作 (GET) 没有统一 catch toast, 由拦截器兜底提示。__silentError: 调用方已接管, 拦截器不弹。
const isMutation = ['post', 'put', 'delete'].includes(String(res.config?.method || '').toLowerCase())
if (!res.config?.__silentError && !isMutation) {
ElMessage.error(data?.msg || '请求失败')
}
return Promise.reject(Object.assign(new Error(data?.msg || '请求失败'), { msg: data?.msg, code: data?.code, silentError: !!res.config?.__silentError }))
},
(err) => {
if (!err.config?.__silentError) {
const isMutation = ['post', 'put', 'delete'].includes(String(err.config?.method || '').toLowerCase())
if (!err.config?.__silentError && !isMutation) {
ElMessage.error(err.message || '网络错误')
}
return Promise.reject(err)
+20 -17
View File
@@ -1,24 +1,27 @@
/**
* biz_person.role enum key → 中文 label 翻译
* 跟 sys_user.role_type 模式一致: DB 存 enum key, UI 显示中文
*
* 适用场景:
* - executor/People.vue 列表/详情显示 role
* - 各处 NewPerson 表单 el-option (label=中文, value=key)
* - 查询条件 role=key
* 角色展示 = f(unitType, accountType) — 单一可信源.
* biz_person.role 列已废弃 (DROP COLUMN), 角色由账号层级 (sys_user.account_type) + 单位类型推导.
*/
export const PERSON_ROLE_LABEL = {
meetingExecutor: '会议执行',
supervisor: '监察员',
admin: '管理员'
/**
* 角色 = f(unitType, accountType) — biz_person.role 与 sys_user.account_type 合并后的单一可信源.
* 不再读 biz_person.role, 直接由账号层级 (MAIN/SUB) + 单位类型推导:
* 支持方 (sponsor): MAIN=管理员, SUB=监察员
* 执行方 (executor): MAIN=管理员, SUB=执行人员
* @param {string} unitType 'sponsor' | 'executor'
* @param {string} accountType 'MAIN' | 'SUB'
* @returns {string} 推导不出时返回 ''
*/
export function accountTypeRoleLabel(unitType, accountType) {
if (accountType === 'MAIN') return '管理员'
if (accountType === 'SUB') return unitType === 'executor' ? '执行人员' : '监察员'
return ''
}
/**
* 翻译 biz_person.role enum key → 中文 label
* @param {string} key
* @returns {string} 没匹配到时返回原 key, 避免显示 'undefined'
* 角色 tag 类型: 主账号高亮 (warning), 子账号普通 (primary)
* @param {string} accountType
*/
export function translatePersonRole(key) {
if (!key) return ''
return PERSON_ROLE_LABEL[key] || key
export function accountTypeRoleTagType(accountType) {
return accountType === 'MAIN' ? 'warning' : 'primary'
}
-4
View File
@@ -26,8 +26,6 @@
</router-link>
</div>
<p class="hint-text">*点击数字,跳转到对应的列表页</p>
<!-- 各角色用户数分布 -->
<section class="section">
<h2 class="section-title">各角色用户数</h2>
@@ -122,8 +120,6 @@ onMounted(load)
color: #fff;
}
.hint-text { font-size: 12px; color: #8c8c8c; margin: 12px 0 20px; text-align: center; }
.section {
background: #fff;
border: 1px solid #f0f0f0;
+4 -4
View File
@@ -34,8 +34,8 @@
<p class="login-subtitle">{{ loginMode === 'password' ? '请输入您的账号信息' : '请输入手机号接收验证码' }}</p>
<div class="login-tabs">
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
</div>
<form v-if="loginMode === 'password'" id="loginForm" @submit.prevent="onSubmit">
@@ -44,7 +44,7 @@
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<input v-model="form.username" type="text" id="username" class="form-input" placeholder="用户名 / 手机号" autocomplete="username">
<input v-model="form.username" type="text" id="username" class="form-input" placeholder="用户名 / 手机号 / 邮箱" autocomplete="username">
</div>
<div class="form-group">
@@ -197,7 +197,7 @@ const showRoleModal = ref(false)
const registerUserType = ref('')
// 登录模式: 'password' | 'sms'
const loginMode = ref('sms')
const loginMode = ref('password')
const smsForm = reactive({ phone: '', smsCode: '', uuid: '' })
const smsSmsCountdown = ref(0)
let smsSmsTimer = null
@@ -356,7 +356,7 @@ const roleHome = {
admin: '/admin/workbench',
manager: '/manager/workbench',
doctor: '/doctor/home',
executor: '/executor/overview',
executor: '/executor/submissions',
sponsor: '/sponsor/home',
}
+32 -17
View File
@@ -16,17 +16,22 @@
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" />
</el-form-item>
<el-form-item label="企业名称" prop="unitName">
<el-input v-model="form.unitName" placeholder="请输入企业全称" maxlength="200" />
</el-form-item>
<el-form-item label="企业性质" prop="businessNature">
<el-select v-model="form.businessNature" placeholder="请选择企业性质" style="width:100%">
<el-option label="私营" value="私营" />
<el-option label="国营" value="国营" />
<el-option label="中外合资" value="中外合资" />
<el-option label="外资" value="外资" />
<el-option label="其他" value="其他" />
<el-form-item label="企业名称" prop="orgId">
<el-select
v-model="form.orgId"
filterable
remote
:remote-method="searchOrg"
:loading="orgLoading"
placeholder="输入企业名称搜索并选择"
style="width:100%"
>
<el-option
v-for="o in orgOptions"
:key="o.orgId"
:label="o.orgName"
:value="o.orgId"
/>
</el-select>
</el-form-item>
@@ -86,10 +91,10 @@
</template>
<script setup>
import { reactive, ref, onUnmounted } from 'vue'
import { reactive, ref, onUnmounted, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { registerSponsor } from '@/api/public'
import { registerSponsor, sponsorOrgOptions } from '@/api/public'
import PortalShell from '@/components/PortalShell.vue'
import { useAsyncLock } from '@/utils/useAsyncLock'
@@ -102,8 +107,7 @@ const agreed = ref(false)
const form = reactive({
username: '',
unitName: '',
businessNature: '',
orgId: null,
phone: '',
smsCode: '',
password: '',
@@ -116,8 +120,7 @@ const rules = {
{ min: 4, max: 20, message: '用户名长度 4-20 位', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_]+$/, message: '只能包含字母/数字/下划线', trigger: 'blur' }
],
unitName: [{ required: true, message: '请输入企业名称', trigger: 'blur' }],
businessNature: [{ required: true, message: '请选择企业性质', trigger: 'change' }],
orgId: [{ required: true, message: '请选择企业', trigger: 'change' }],
phone: [
{ required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
@@ -139,6 +142,18 @@ const rules = {
]
}
// ===== 企业远程搜索 (biz_org sponsor 类型) =====
const orgOptions = ref([])
const orgLoading = ref(false)
function searchOrg(query) {
orgLoading.value = true
sponsorOrgOptions({ orgName: query || '' })
.then(res => { orgOptions.value = res.data || [] })
.catch(() => { orgOptions.value = [] })
.finally(() => { orgLoading.value = false })
}
onMounted(() => searchOrg(''))
// ===== 短信验证码 =====
const smsCountdown = ref(0)
let smsTimer = null
+12 -104
View File
@@ -15,37 +15,25 @@
<!-- 个人信息 -->
<el-tab-pane label="个人信息" name="profile">
<div class="card">
<!-- 头像 + 用户信息 -->
<div class="avatar-block">
<div class="avatar-circle" :class="{ 'has-file': avatarUrl, 'is-uploading': avatarUploading }" @click="onAvatarClick">
<img v-if="avatarUrl" :src="avatarUrl" class="avatar-img" />
<template v-else>{{ avatarChar }}</template>
<span class="avatar-tip">{{ avatarUploading ? '上传中...' : '点击更换' }}</span>
</div>
<div class="avatar-meta">
<div class="name">{{ form.name || '专家账号' }}</div>
<div class="role">专家账号</div>
<div class="phone-row">
<span class="phone-label">联系电话:</span>
<span class="phone-value">{{ form.phone || '未设置' }}</span>
<el-button class="btn-edit-phone" link type="primary" @click="openPhoneDialog">
<el-icon><EditPen /></el-icon> 修改
</el-button>
</div>
</div>
</div>
<!-- 基本资料 (2 ) -->
<div class="section-title">基本资料</div>
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
<el-form-item label="专家姓名" :required="true">
<el-input v-model="form.name" placeholder="请填写姓名" />
</el-form-item>
<el-form-item label="联系电话">
<div class="phone-row">
<span class="phone-value">{{ form.phone || '未设置' }}</span>
<el-button link type="primary" @click="openPhoneDialog">
<el-icon><EditPen /></el-icon> 修改
</el-button>
</div>
</el-form-item>
<el-form-item label="科室" :required="true">
<doctor-dept-select v-model="form.department" value-field="label" placeholder="输入关键词搜索" filterable />
</el-form-item>
<el-form-item label="地区" :required="true">
<area-cascader v-model="form.region" format="string" join-sep="/" placeholder="请选择省/市/区" />
<area-cascader v-model="form.region" format="string" join-sep="/" :max-level="2" placeholder="请选择省/市" />
</el-form-item>
<el-form-item label="证件号码" :required="true">
<el-input v-model="form.idCardNo" placeholder="请填写证件号码" />
@@ -131,9 +119,6 @@
</el-tab-pane>
</el-tabs>
<!-- 隐藏的 avatar 上传 input -->
<input ref="avatarInput" type="file" hidden accept="image/*" @change="onAvatarFileChange" />
<!-- 修改手机号 dialog (独立于其它字段) -->
<el-dialog v-model="phoneDialogVisible" title="修改联系电话" width="420px" :close-on-click-modal="false">
<el-form :model="phoneForm" label-width="100px">
@@ -159,12 +144,11 @@
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { EditPen } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
import { uploadToOss } from '@/utils/oss'
import AreaCascader from '@/components/AreaCascader.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
@@ -209,50 +193,8 @@ const pwdError = ref('')
const saveError = ref('')
const saving = ref(false)
const avatarUrl = ref(store.user?.avatar || '')
const avatarChar = computed(() => (form.name || '专').slice(0, 1))
const avatarUploading = ref(false)
const avatarInput = ref(null)
let snapshot = ref(null)
function onAvatarClick() {
if (avatarUploading.value) return
avatarInput.value?.click()
}
async function onAvatarFileChange(e) {
const file = e.target.files?.[0]
if (!file) return
e.target.value = '' // 重置以便可重选
if (!file.type.startsWith('image/')) {
return ElMessage.warning('请选择图片文件')
}
if (file.size > 5 * 1024 * 1024) {
return ElMessage.warning('图片大小不能超过 5MB')
}
avatarUploading.value = true
try {
const url = await uploadToOss(file, 'ry8080/avatar/')
await request({
url: '/system/user/profile/avatarUrl',
method: 'put',
data: { avatarUrl: url }
})
avatarUrl.value = url
if (store.user) {
store.user.avatar = url
// 同步 localStorage, 否则刷新后头像丢失
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
ElMessage.success('头像更新成功')
} catch (err) {
ElMessage.error(err?.msg || '头像上传失败')
} finally {
avatarUploading.value = false
}
}
function goHome() {
if (window.parent && window.parent.document.getElementById('contentFrame')) {
window.parent.goHome?.()
@@ -296,7 +238,6 @@ async function loadUserProfile() {
store.user.phonenumber = u.phonenumber || store.user.phonenumber
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
avatarUrl.value = u.avatar || avatarUrl.value
} catch (e) {
console.warn('loadUserProfile:', e?.msg)
}
@@ -305,16 +246,10 @@ async function loadUserProfile() {
onMounted(async () => {
form.name = store.user?.userName || ''
form.phone = store.user?.phonenumber || ''
avatarUrl.value = store.user?.avatar || ''
await Promise.all([loadUserProfile(), loadExpertProfile()])
snapshot = ref(JSON.parse(JSON.stringify(form)))
})
function onAvatarChange(url) {
avatarUrl.value = url
if (store.user) store.user.avatar = url
}
async function onSave() {
saveError.value = ''
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankRegion', 'bankAddress']
@@ -499,36 +434,9 @@ onUnmounted(() => {
.form-grid :deep(.el-form-item) { margin-bottom: 14px; }
.form-grid :deep(.el-form-item__label) { color: #606266; }
/* 头像区 (含电话显示) */
.avatar-block { display: flex; align-items: center; gap: 24px; padding: 16px 0 24px; border-bottom: 1px dashed #ebeef5; margin-bottom: 24px; }
.avatar-circle {
width: 80px; height: 80px; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-size: 28px; font-weight: 600;
cursor: pointer; flex-shrink: 0;
overflow: hidden;
position: relative;
transition: transform 0.15s;
}
.avatar-circle:hover { transform: scale(1.04); }
.avatar-circle.is-uploading { cursor: wait; opacity: 0.75; }
.avatar-circle .avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-tip {
font-size: 11px; opacity: 0.85; margin-top: 4px;
position: absolute; bottom: 4px; left: 0; right: 0;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.avatar-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.avatar-meta .name { font-size: 18px; font-weight: 600; color: #262626; }
.avatar-meta .role { font-size: 13px; color: #909399; }
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; margin-top: 4px; }
.phone-label { color: #909399; }
/* 联系电话 (基本资料 form 内显示) */
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; }
.phone-value { color: #262626; font-weight: 500; }
.btn-edit-phone { padding: 0 4px; }
/* 银行信息 + 密码 form 单列 */
.password-form { max-width: 480px; }
+81 -26
View File
@@ -21,10 +21,25 @@
<el-tag :type="stageTag(row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="日程" width="130" align="center">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.scheduleUrl" @click="onPreview(row.scheduleUrl, '日程海报')">查看</el-button>
<el-button link :disabled="!row.scheduleUrl" @click="onDownload(row.scheduleUrl, `${row.meetingName || '会议'}_日程海报`)">下载</el-button>
</template>
</el-table-column>
<el-table-column label="邀请函" width="130" align="center">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.projectInvitationUrl" @click="onPreview(row.projectInvitationUrl, '邀请函')">查看</el-button>
<el-button link :disabled="!row.projectInvitationUrl" @click="onDownload(row.projectInvitationUrl, `${row.meetingName || '会议'}_邀请函`)">下载</el-button>
</template>
</el-table-column>
<el-table-column label="签署状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.attendeeLaborProtocol ? 'success' : 'info'" size="small">{{ row.attendeeLaborProtocol ? '已签署' : '未签署' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" :disabled="!row.invitationUrl" @click="onDownloadFile(row)">下载</el-button>
<el-button v-if="!row.attendeeLaborProtocol" link type="primary" @click="onSign(row)">签署劳务</el-button>
<el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
</template>
@@ -42,6 +57,23 @@
style="margin-top: 12px; text-align: right;"
/>
<!-- 日程 / 邀请函 预览 dialog (PDF iframe / 图片 img, 复用 publicity OSS 代理预览技术) -->
<el-dialog v-model="previewOpen" :title="previewTitle" width="780px" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div v-if="previewUrl" class="file-preview">
<iframe v-if="isPdf(previewUrl)" :src="proxyUrl(previewUrl)" class="file-preview-iframe"></iframe>
<el-image v-else-if="isImg(previewUrl)" :src="previewUrl" :preview-src-list="[previewUrl]" fit="contain" class="file-preview-img" />
<div v-else class="file-preview-fallback">
<p>该文件类型暂不支持页内预览, 请在新窗口打开查看</p>
<el-button type="primary" @click="openPreviewNew">在新窗口打开</el-button>
</div>
</div>
<el-empty v-else description="暂无附件" />
<template #footer>
<el-button @click="previewOpen = false">关闭</el-button>
<el-button v-if="previewUrl" type="primary" @click="onDownload(previewUrl, previewTitle)">下载</el-button>
</template>
</el-dialog>
<!-- 签署劳务 dialog: 二维码 ( token, 手机扫码可直接登录填写) -->
<el-dialog v-model="signOpen" title="签署劳务" width="380px" align-center destroy-on-close>
<div class="sign-qr-wrap">
@@ -57,18 +89,12 @@
<!-- 查看劳务 dialog: 已签状态显示, PDF iframe 预览 -->
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
<el-descriptions :column="1" border>
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="会议名称">{{ detail.meetingName }}</el-descriptions-item>
<el-descriptions-item label="签署状态">已签署</el-descriptions-item>
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="pdf-preview">
<iframe v-if="detail.attendeeLaborProtocol" :src="detail.attendeeLaborProtocol" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
<iframe v-if="detail.attendeeLaborProtocol" :src="proxyUrl(detail.attendeeLaborProtocol)" style="width:100%;height:70vh;border:1px solid #ebeef5"></iframe>
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
</div>
<template #footer>
<el-button v-if="detail.attendeeLaborProtocol" link type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
<el-button v-if="detail.attendeeLaborProtocol" type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
<el-button @click="laborOpen=false">关闭</el-button>
</template>
</el-dialog>
@@ -77,7 +103,7 @@
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList } from '@/api/public'
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
@@ -88,6 +114,9 @@ const rows = ref([])
const loading = ref(false)
const detail = ref({})
const laborOpen = ref(false)
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
const signOpen = ref(false)
const signForm = reactive({ meetingName: '' })
const signLink = ref('')
@@ -119,27 +148,47 @@ function onViewLabor(row) {
laborOpen.value = true
}
function onView(row) {
detail.value = row
// 复用 detail dialog: 复用 meetingInfo 字段直接展示
// 这里直接展示一个只读 dialog, 没有劳务签署按钮
ElMessageBox.alert(
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${stageLabel('doctor', row)}`,
'会议详情',
{ confirmButtonText: '关闭' }
)
// ===== 日程 / 邀请函 预览 (与 publicity 同技术: OSS 代理重写 inline + iframe/img) =====
function isPdf(url) {
if (!url) return false
const path = url.split('?')[0].toLowerCase()
return /\.pdf$/.test(path)
}
function onDownloadFile(row) {
if (!row.invitationUrl) { ElMessage.warning('该会议暂无邀请函附件'); return }
function isImg(url) {
if (!url) return false
const path = url.split('?')[0].toLowerCase()
return /\.(png|jpg|jpeg|gif|webp)$/.test(path)
}
// OSS bucket 设置 Content-Disposition: attachment, iframe 直接访问会被强制下载,
// 走 /common/oss/proxy 后端代理重写为 inline (#toolbar=0 隐藏工具栏, #zoom=page-width 撑满宽度)
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
}
return url
}
function onPreview(url, title) {
if (!url) { ElMessage.warning('暂无附件'); return }
previewUrl.value = url
previewTitle.value = title
previewOpen.value = true
}
function onDownload(url, name) {
if (!url) { ElMessage.warning('暂无附件'); return }
const a = document.createElement('a')
a.href = row.invitationUrl
a.download = `${row.meetingName || '会议'}_邀请函.pdf`
a.href = url
a.download = name
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function openPreviewNew() {
const url = proxyUrl(previewUrl.value)
if (!url) return
window.open(url, '_blank')
}
async function onSign(row) {
signForm.meetingName = row.meetingName || ''
@@ -206,6 +255,12 @@ load()
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
.pdf-preview { margin-top: 12px; }
.file-preview { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
.file-preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
.file-preview-img { width: 100%; }
.file-preview-img :deep(.el-image__inner) { width: 100%; height: auto; max-height: 70vh; object-fit: contain; }
.file-preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
.file-preview-fallback p { margin-bottom: 16px; }
.sign-link-tip { font-size: 13px; color: #606266; margin-bottom: 10px; }
.sign-link-hint { font-size: 12px; color: #909399; margin-top: 10px; }
.sign-qr-wrap { text-align: center; padding: 12px 0; }
-3
View File
@@ -9,8 +9,6 @@
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<div class="filter-tip">*下载项目公示的红头文件</div>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="160" />
<el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip />
@@ -139,7 +137,6 @@ load()
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
.filter-tip { background: #fffbe6; border: 1px solid #ffe58f; color: #ad6800; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-bottom: 12px; }
.status { font-size: 13px; color: #595959; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep); border-color: var(--brand-primary-deep); }
+18 -2
View File
@@ -45,9 +45,12 @@
</el-table-column>
<el-table-column prop="planCategory" label="项目类别" width="120" />
<el-table-column prop="projectForm" label="形式" width="100" />
<el-table-column label="设计文件" width="100">
<el-table-column label="设计文件" width="140">
<template #default="{ row }">
<el-link v-if="row.designFileUrl" type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
<template v-if="row.designFileUrl">
<el-button link type="primary" @click="openPreview(row.designFileUrl, '设计文件')">查看</el-button>
<el-link type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
</template>
<span v-else>-</span>
</template>
</el-table-column>
@@ -77,6 +80,7 @@
style="margin-top: 12px; text-align: right;"
/>
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
</div>
</template>
@@ -88,6 +92,7 @@ import request from '@/utils/request'
import { bizList, bizUpdate } from '@/api/public'
import DictSelect from '@/components/DictSelect.vue'
import AuditStatusTag from '@/components/AuditStatusTag.vue'
import Preview from '@/components/Preview.vue'
const router = useRouter()
const route = useRoute()
@@ -107,6 +112,11 @@ const rows = ref([])
const loading = ref(false)
const selected = ref([])
// 设计文件预览
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('附件预览')
// 项目方向 options (复用 manager 侧接口)
const planDirectionOptions = ref([])
async function loadPlanDirectionOptions() {
@@ -184,6 +194,12 @@ async function onBatch() {
load()
}
function openPreview(url, title) {
previewUrl.value = url
previewTitle.value = title || '附件预览'
previewOpen.value = true
}
onMounted(() => {
loadPlanDirectionOptions()
load()
@@ -1,6 +1,6 @@
<template>
<!--
共享服务机构管理页 (admin/manager 共用, 通过 route.path 区分角色)
共享执行单位管理页 (admin/manager 共用, 通过 route.path 区分角色)
- 筛选: 两角色一致 (orgName / taxNo / status)
- 新增/编辑: admin (表单 + 保存)
- 启用/禁用: 两角色都有 (沿用原两版本行为)
@@ -9,12 +9,12 @@
- sponsor-orgs/SponsorOrgs.vue 高度相似, 但按用户要求不抽公共组件
-->
<div class="page-card executor-orgs">
<div class="breadcrumb">首页 / 服务机构管理</div>
<div class="breadcrumb">首页 / 执行单位管理</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="公司名称">
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable style="width:200px" />
<el-form-item label="单位名称">
<el-input v-model="q.orgName" placeholder="输入单位名称" clearable style="width:200px" />
</el-form-item>
<el-form-item label="税号/统一社会信用代码">
<el-input v-model="q.taxNo" placeholder="输入税号" clearable style="width:220px" />
@@ -33,15 +33,15 @@
<!-- ========== 批量按钮区 ========== -->
<div class="batch-bar">
<!-- admin 可新建服务机构 -->
<el-button v-if="isAdmin" type="primary" @click="openAdd">新增服务机构</el-button>
<!-- admin 可新建执行单位 -->
<el-button v-if="isAdmin" type="primary" @click="openAdd">新增执行单位</el-button>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="address" label="公司地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="orgName" label="单位名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="address" label="单位地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="taxNo" label="税号/统一社会信用代码" min-width="180" />
<el-table-column label="合作状态" width="100" align="center">
<template #default="{ row }">
@@ -82,11 +82,11 @@
</div>
<!-- ========== 详情弹窗 (沿用 manager el-descriptions 风格, 更清晰) ========== -->
<el-dialog v-model="detailOpen" title="服务机构详情" width="640px" v-if="currentRow">
<el-dialog v-model="detailOpen" title="执行单位详情" width="640px" v-if="currentRow">
<el-descriptions :column="1" border>
<el-descriptions-item label="公司类型"><el-tag type="primary">执行方</el-tag></el-descriptions-item>
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
<el-descriptions-item label="公司地址">{{ currentRow.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="单位名称">{{ currentRow.orgName }}</el-descriptions-item>
<el-descriptions-item label="单位地址">{{ currentRow.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="合作状态">
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status === '1' ? '禁用' : '正常' }}</el-tag>
@@ -101,12 +101,12 @@
</el-dialog>
<!-- ========== 新增/编辑弹窗 ( admin) ========== -->
<el-dialog v-if="isAdmin" v-model="dialogVisible" :title="isEdit ? '编辑服务机构' : '新增服务机构'" width="640px">
<el-dialog v-if="isAdmin" v-model="dialogVisible" :title="isEdit ? '编辑执行单位' : '新增执行单位'" width="640px">
<el-form :model="form" label-width="140px" :rules="rules" ref="formRef">
<el-form-item label="公司名称" prop="orgName">
<el-form-item label="单位名称" prop="orgName">
<el-input v-model="form.orgName" />
</el-form-item>
<el-form-item label="公司地址">
<el-form-item label="单位地址">
<el-input v-model="form.address" />
</el-form-item>
<el-form-item label="税号/统一社会信用代码">
@@ -222,7 +222,7 @@ const form = reactive({
contactName: '', contactPhone: '', status: '0'
})
const rules = {
orgName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }]
orgName: [{ required: true, message: '请输入单位名称', trigger: 'blur' }]
}
function openAdd() {
@@ -1,13 +1,13 @@
<template>
<!--
共享服务机构下人员列表页 (admin/manager 共用, 通过 route.path 区分角色)
共享执行单位下人员列表页 (admin/manager 共用, 通过 route.path 区分角色)
- 入口: org 表格行内人员管理按钮 (router.push orgId/orgName)
- 接口: bizList('person', q) / bizUpdate / bizAdd / executorImport / executorImportTemplate
- op : 查看 / 编辑 / 启用/禁用 (3 按钮, 共用, 不分角色)
-->
<div class="page-card executor-people">
<div class="breadcrumb">
首页 / 服务机构管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
首页 / 执行单位管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<!-- ========== 筛选区 ========== -->
@@ -18,6 +18,9 @@
<el-form-item label="手机号">
<el-input v-model="q.phone" placeholder="手机号" clearable style="width:140px" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="q.email" placeholder="邮箱" clearable style="width:180px" />
</el-form-item>
<el-form-item label="企业名称">
<el-input v-model="q.orgName" placeholder="企业名称" clearable style="width:180px" />
</el-form-item>
@@ -37,16 +40,26 @@
<div class="batch-bar">
<el-button type="primary" @click="goNew">新建人员</el-button>
<el-button @click="openImport">批量导入</el-button>
<span class="filter-tip">*批量导入模板与列表表项一致</span>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="orgName" label="企业名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="department" label="部门" min-width="100" />
<el-table-column prop="position" label="职务" min-width="100" />
<!-- 管理员 switch: admin 可见, 指定机构管理员 (MAIN), 逻辑与 sponsor 管理员一致 -->
<el-table-column v-if="isAdmin" label="管理员" width="90" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.accountType === 'MAIN'"
:disabled="switchingId === row.personId"
@change="onChangeAdmin(row)"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
@@ -78,32 +91,42 @@
/>
</div>
<!-- ========== 批量导入 ========== -->
<el-dialog v-model="importOpen" title="批量导入" width="520px" :close-on-click-modal="false">
<el-form label-width="100px">
<el-form-item label="选择文件">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" accept=".xls,.xlsx" :on-change="onFileChange" :on-remove="onFileRemove">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-form-item label="">
<el-button link type="primary" @click="downloadTemplate">批量导入模板下载</el-button>
</el-form-item>
<el-form-item><span class="filter-tip">*导入列: 姓名/手机号/所属公司/部门/职务/角色</span></el-form-item>
</el-form>
<!-- ========== 批量导入 (沿用 sponsor-orgs/Experts.vue 模式: drag + 模板下载在框外 + ImportResultDialog) ========== -->
<el-dialog v-model="importOpen" title="批量导入" width="400px" append-to-body :close-on-click-modal="false">
<el-upload
v-if="importOpen"
ref="uploadRef"
:limit="1"
accept=".xlsx, .xls"
:on-change="onFileChange"
:on-remove="onFileRemove"
:auto-upload="false"
drag
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<!-- 模板下载: 必须放在 el-upload 框外(下面), 否则点击会被 drag 区域吞掉, 触发的是文件选择器 -->
<div style="margin-top:10px;font-size:13px;text-align:center">
<span style="color:#909399;margin-right:8px">仅允许导入 xlsxlsx 格式文件</span>
<el-link type="primary" :underline="false" @click="downloadTemplate">下载模板</el-link>
</div>
<template #footer>
<el-button @click="importOpen = false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImport"> </el-button>
</template>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate } from '@/api/public'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -115,7 +138,7 @@ const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const orgName = computed(() => route.query.orgName || '')
const q = reactive({ name: '', phone: '', orgName: '', department: '', position: '' })
const q = reactive({ name: '', phone: '', email: '', orgName: '', department: '', position: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -145,7 +168,7 @@ async function load() {
finally { loading.value = false }
}
function reset() {
Object.assign(q, { name: '', phone: '', orgName: '', department: '', position: '' })
Object.assign(q, { name: '', phone: '', email: '', orgName: '', department: '', position: '' })
page.pageNum = 1
load()
}
@@ -171,6 +194,29 @@ async function onToggleStatus(row) {
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
// ========== 更换机构管理员 (仅 admin, 逻辑与 sponsor 管理员一致) ==========
const switchingId = ref('')
async function onChangeAdmin(row) {
if (row.accountType === 'MAIN') { ElMessage.info('已是该机构管理员'); return }
try {
await ElMessageBox.confirm(
`确定将「${row.name}」设为该机构管理员吗?`,
'更换管理员',
{ type: 'warning' }
)
} catch { return }
switchingId.value = row.personId
try {
await changePersonAdmin(row.personId)
ElMessage.success('已更换管理员')
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '更换失败')
} finally {
switchingId.value = ''
}
}
// ========== 跳转 (按角色) ==========
function goNew() {
router.push({
@@ -193,8 +239,10 @@ const importOpen = ref(false)
const importing = ref(false)
const uploadRef = ref()
const importFile = ref(null)
const importResultOpen = ref(false)
const importResult = ref(null)
function openImport() { importFile.value = null; importOpen.value = true }
function openImport() { importFile.value = null; importResult.value = null; importOpen.value = true }
function onFileChange(file) { importFile.value = file.raw }
function onFileRemove() { importFile.value = null }
async function downloadTemplate() {
@@ -204,7 +252,7 @@ async function downloadTemplate() {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '服务机构人员导入模板.xlsx'
a.download = '执行单位人员导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('模板下载失败') }
@@ -213,21 +261,18 @@ async function submitImport() {
if (!importFile.value) { ElMessage.warning('请先选择文件'); return }
importing.value = true
try {
const res = await importPerson('executor', importFile.value)
const res = await importPerson('executor', importFile.value, orgId.value)
const payload = res.data || res
const ok = payload.ok ?? 0
const total = payload.total ?? 0
const failed = (payload.results || []).filter(r => !r.ok)
if (failed.length === 0) {
ElMessage.success(`导入成功 ${ok}/${total}`)
} else {
ElMessageBox.alert(
`成功 ${ok} 条, 失败 ${failed.length} 条:\n` +
failed.map(f => `${f.rowNo}${f.name || ''}: ${f.message}`).join('\n'),
'导入结果', { type: 'warning' }
)
// 映射为 ImportResultDialog 期望的形状 { okNum, ngNum, ngList: [{rowNum, message}] }
importResult.value = {
okNum: ok,
ngNum: failed.length,
ngList: failed.map(f => ({ rowNum: f.rowNo, message: (f.name ? f.name + ': ' : '') + (f.message || '') }))
}
importOpen.value = false
importResultOpen.value = true
load()
} catch (e) {
ElMessage.error(e?.msg || '导入失败')
@@ -248,6 +293,5 @@ watch(() => route.fullPath, () => {
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.filter-tip { color: #909399; font-size: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
@@ -1,13 +1,13 @@
<template>
<!--
共享服务机构下人员详情 (只读, admin/manager 共用)
共享执行单位下人员详情 (只读, admin/manager 共用)
通过 route.path 区分角色, 回跳路径不同
1:1 ExecutorPersonNew.vue 风格 (page-card + 面包屑 + form-card nested)
区别: 所有控件禁用, 仅展示
-->
<div class="page-card executor-person-detail">
<div class="breadcrumb">
<a @click="goBack">服务机构下人员</a> &gt; 人员详情
<a @click="goBack">执行单位下人员</a> &gt; 人员详情
</div>
<el-card v-loading="loading" shadow="never" class="form-card nested">
@@ -21,6 +21,9 @@
<el-form-item label="手机号">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="所属公司">
<el-input v-model="form.orgName" />
</el-form-item>
@@ -31,7 +34,9 @@
<el-input v-model="form.position" />
</el-form-item>
<el-form-item label="角色">
<span style="color:#303133">会议执行</span>
<el-tag :type="accountTypeRoleTagType(form.accountType)" disable-transitions>
{{ accountTypeRoleLabel(form.unitType, form.accountType) || '执行人员' }}
</el-tag>
</el-form-item>
<el-form-item label="状态">
<el-tag :type="form.status === '1' ? 'danger' : 'success'" disable-transitions>
@@ -52,6 +57,7 @@ import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { bizGet } from '@/api/public'
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
const router = useRouter()
const route = useRoute()
@@ -67,10 +73,13 @@ const form = reactive({
userName: '',
name: '',
phone: '',
email: '',
orgName: '',
department: '',
position: '',
status: '0',
unitType: '',
accountType: '',
})
function goBack() {
@@ -90,13 +99,16 @@ async function loadDetail() {
const d = res.data
Object.assign(form, {
personId: d.personId,
userName: d.userName || '',
userName: d.account || '',
name: d.name || '',
phone: d.phone || '',
email: d.email || '',
orgName: d.orgName || '',
department: d.department || '',
position: d.position || '',
status: d.status || '0',
unitType: d.unitType || '',
accountType: d.accountType || '',
})
} else {
ElMessage.error(res?.msg || '加载失败')
@@ -1,12 +1,12 @@
<template>
<!--
共享服务机构下人员 新建/编辑独立页 (admin/manager 共用, 通过 route.path 区分角色)
共享执行单位下人员 新建/编辑独立页 (admin/manager 共用, 通过 route.path 区分角色)
结构对齐 expert/ExpertNew.vue
unitType 硬编码 'executor', role 硬编码 'meetingExecutor'
unitType 硬编码 'executor'
-->
<div class="page-card executor-person-new">
<div class="breadcrumb">
<a @click="goBack">服务机构下人员</a> &gt; {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
<a @click="goBack">执行单位下人员</a> &gt; {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<el-card v-loading="loadingDetail" shadow="never" class="form-card nested">
@@ -20,6 +20,9 @@
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="手机号 (与用户名一致时可重复)" maxlength="11" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="邮箱" maxlength="100" />
</el-form-item>
<el-form-item label="所属公司" prop="orgName">
<el-input v-model="form.orgName" placeholder="所属公司" maxlength="200" :disabled="!!orgId" />
</el-form-item>
@@ -29,9 +32,6 @@
<el-form-item label="职务">
<el-input v-model="form.position" placeholder="职务" maxlength="50" />
</el-form-item>
<el-form-item label="角色">
<span style="color:#303133">会议执行 <span style="color:#909399;font-size:12px">(执行方人员固定, 暂唯一子类型)</span></span>
</el-form-item>
</el-form>
</el-card>
@@ -69,6 +69,7 @@ const form = reactive({
userName: '',
name: '',
phone: '',
email: '',
orgName: '',
department: '',
position: ''
@@ -81,6 +82,10 @@ const rules = {
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式错误', trigger: 'blur' }
],
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }]
}
@@ -97,9 +102,10 @@ async function loadDetail() {
const d = res.data
Object.assign(form, {
personId: d.personId,
userName: d.userName || '',
userName: d.account || '',
name: d.name || '',
phone: d.phone || '',
email: d.email || '',
orgName: d.orgName || '',
department: d.department || '',
position: d.position || ''
@@ -128,7 +134,7 @@ async function onSave() {
try { await formRef.value.validate() } catch { return }
saving.value = true
try {
const payload = { ...form, unitType: 'executor', role: 'meetingExecutor' }
const payload = { ...form, unitType: 'executor' }
// 从 org 页面跳来时 route.query 带 orgId, 直接作为 FK
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
+1 -2
View File
@@ -4,7 +4,7 @@
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
@@ -27,7 +27,6 @@ const formRef = ref(null)
const rules = {
nickName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phonenumber: [{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
oldPassword: [{ required: false }],
newPassword: [{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }],
confirmPassword: [{
+8 -22
View File
@@ -1,7 +1,6 @@
<template>
<div class="page-card executor-meetings">
<div class="breadcrumb">首页 / 会议列表</div>
<p class="page-sub">查看执行方参与的会议</p>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
@@ -59,10 +58,11 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="warning" @click="onSupervision(row)">查看监督意见</el-button>
<el-button link type="primary" @click="onUpload(row)">上传材料</el-button>
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
</template>
</el-table-column>
</el-table>
@@ -71,17 +71,6 @@
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
@current-change="load" @size-change="load" />
</div>
<!-- 监督意见 (只读: 由支持方/监察员在审批时填写, 执行方仅可查看) -->
<el-dialog v-model="supOpen" title="监督意见" width="560px">
<el-descriptions :column="1" border>
<el-descriptions-item label="会议名称">{{ supRow.meetingName }}</el-descriptions-item>
<el-descriptions-item label="当前阶段">{{ stageLabel('executor', supRow) }}</el-descriptions-item>
<el-descriptions-item label="监督意见">{{ supRow.supervisionOpinion || '暂无' }}</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="supOpen=false">关闭</el-button></template>
</el-dialog>
</div>
</template>
@@ -97,9 +86,6 @@ const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const selected = ref([])
const supOpen = ref(false)
const supRow = ref({})
async function load() {
loading.value = true
try {
@@ -128,11 +114,12 @@ function reset() {
function onSelect(arr) { selected.value = arr }
function onView(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) }
function onSupervision(row) {
supRow.value = row
supOpen.value = true
function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
function onEdit(row) {
router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '' } })
}
onMounted(() => { readQueryFromRoute(); load() })
@@ -142,7 +129,6 @@ onMounted(() => { readQueryFromRoute(); load() })
/* 整页面板 (与 People.vue 风格一致) */
.executor-meetings { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.page-sub { font-size: 13px; color: #909399; margin-bottom: 16px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
+1 -13
View File
@@ -64,15 +64,6 @@
<el-input v-model="form.position" placeholder="请输入职务" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="角色" prop="role">
<!-- 统一 el-select + 管理员禁用 + 编辑模式置灰 ( sponsor/NewPerson 一致) -->
<el-select v-model="form.role" placeholder="请选择" style="width:100%" :disabled="isEdit">
<el-option label="会议执行" value="meetingExecutor" />
<el-option label="管理员" value="admin" :disabled="true" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
@@ -127,7 +118,6 @@ const form = reactive({
orgName: '',
department: '',
position: '',
role: 'meetingExecutor',
unitType: 'executor',
status: '0',
// 子账号登录信息 (后端会创建 sys_user SUB, parent_user_id=主账号, 字段名跟后端 @JsonProperty 对齐)
@@ -146,7 +136,6 @@ const rules = {
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }],
department:[{ required: true, message: '请输入部门', trigger: 'blur' }],
position: [{ required: true, message: '请输入职务', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
userName: [
{ required: true, message: '请输入子账号登录用户名', trigger: 'blur' },
@@ -196,7 +185,6 @@ async function loadDetail() {
orgName: d.orgName || '',
department: d.department || '',
position: d.position || '',
role: d.role || '',
unitType: d.unitType || 'executor',
status: d.status != null ? String(d.status) : '0',
})
@@ -213,7 +201,7 @@ async function loadDetail() {
}
function confirmCancel() {
const hasContent = form.name || form.phone || form.orgName || form.department || form.position || form.role
const hasContent = form.name || form.phone || form.orgName || form.department || form.position
if (!isEdit.value && !hasContent) { goBack(); return }
ElMessageBox.confirm('确定取消?未保存的内容将丢失', '提示', { type: 'warning' })
.then(() => goBack())
+5 -4
View File
@@ -28,9 +28,10 @@
<el-table-column prop="orgName" label="所属公司" min-width="180" show-overflow-tooltip />
<el-table-column prop="department" label="部门" min-width="100" />
<el-table-column prop="position" label="职务" min-width="100" />
<el-table-column prop="role" label="角色" width="100">
<el-table-column label="角色" width="110">
<template #default="{ row }">
<el-tag size="small" :type="row.role === 'meetingExecutor' ? 'warning' : 'info'">{{ translatePersonRole(row.role) }}</el-tag>
<!-- 角色与账号类型合并: account_type + unitType 推导 (MAIN=管理员, SUB=执行人员) -->
<el-tag size="small" :type="accountTypeRoleTagType(row.accountType)">{{ accountTypeRoleLabel(row.unitType, row.accountType) || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
@@ -46,7 +47,7 @@
<el-button size="small" link type="primary" @click="goEdit(row)">编辑</el-button>
<!-- 本人不显示禁用/恢复按钮 ( sponsor 一样, 避免主账号把自己禁用) -->
<template v-if="row.userId !== store.user?.userId">
<el-button v-if="(row.status || '正常') === '正常'" size="small" link type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-button>
<el-button v-if="row.status === '0'" size="small" link type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-button>
<el-button v-else size="small" link type="success" @click="onToggleStatus(row, '恢复')">恢复</el-button>
</template>
</template>
@@ -110,7 +111,7 @@ import { bizUpdate } from '@/api/public'
import { listExecutorPerson } from '@/api/business/person'
import { useUserStore } from '@/store/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { translatePersonRole } from '@/utils/roleMap'
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
import request from '@/utils/request'
const router = useRouter()

Some files were not shown because too many files have changed in this diff Show More