feat: 工会报销优化

This commit is contained in:
2026-11-06 18:08:21 +08:00
parent 1ddfa7e609
commit d6e3815b3d
17 changed files with 384 additions and 194 deletions
+4 -1
View File
@@ -49,4 +49,7 @@ Thumbs.db
.trae/pr-creator/SKILL.md
.trae/update-docs/SKILL.md
.trae/webapp-testing/SKILL.md
outputs
outputs
semgrep.json
trivy.json
@@ -147,12 +147,9 @@ public class SysLoginController {
// 解密密码
LoginRsaKey rsaKey = LOGIN_RSA_KEY_RING.getPrivateKey(keyId);
if (rsaKey == null) {
throw new BaseException("Login key expired, please refresh the login page");
throw new BaseException("登录凭证已过期,请刷新登录页面。");
}
String decryptPwd = RsaUtils.decrypt(password, rsaKey.privateKey);
if (decryptPwd == null) {
throw new BaseException("用户登录失败");
}
decryptPwd = Base64Encoder.encode(decryptPwd);
// 用户名密码校验
@@ -84,6 +84,7 @@ public class SysUnitController {
// cnd.and("parentId", "is not", null).andEX("unitLevel", "=", unitLevel).asc("unitLevel").asc("unitcode");
cnd.and("parentId", "is not", null);
cnd.andEX("parentId", "=", unitId);
cnd.andEX("id", "!=", unitId);
// cnd.and("unitTypeCode", "=", "1");
cnd.asc("unitcode");
cnd.and(Cnd.likeEX("name", unitName));
@@ -313,15 +314,17 @@ public class SysUnitController {
Cnd cnd = Cnd.NEW();
cnd.asc("unitcode");
List<Sys_unit> list = sysUnitService.query(cnd);
boolean defaultRoot = "0".equals(rootId);
boolean hasDefaultRoot = defaultRoot && list.stream().anyMatch(unit -> rootId.equals(unit.getId()));
List<TreeNode<String>> nodeList = CollUtil.newArrayList();
for (int i = 0; i < list.size(); i++) {
Sys_unit unit = list.get(i);
/*
* 单位根节点存在 id 与 parentId 都为 0 的自引用数据。
* 构建树时把当前查询根挂到虚拟根下,避免“中国地质大学”和 parentId=0 的学院被构造成同级。
*/
String parentId = rootId.equals(unit.getId()) ? virtualRootId : unit.getParentId();
String parentId = unit.getParentId();
if (rootId.equals(unit.getId()) || (defaultRoot && (StrUtil.isBlank(parentId)
|| (!hasDefaultRoot && rootId.equals(parentId))))) {
parentId = virtualRootId;
}
nodeList.add(new TreeNode<>(unit.getId(), parentId, unit.getName(), i)
.setExtra(
Map.of(
@@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.union.model.OutlayManageUnion;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.union.vo.OutlayUnionAllocateImportVo;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
@@ -14,6 +15,8 @@ import org.nutz.lang.util.NutMap;
import java.math.BigDecimal;
import java.time.Year;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
@IocBean(args = {"refer:dao"})
@@ -37,17 +40,54 @@ public class OutlayAnnualAllocateService extends BaseServiceImpl<OutlayManageUni
throw new IllegalArgumentException("经费类型错误");
}
public Sql pageSql(String type, Integer year, String unionId, String projectName) {
public Sql pageSql(String type, Integer year, String unionId, String projectName, String amountMatch) {
String table = table(type);
return sql("SELECT a.*, un.name unionName, creator.username operatorName FROM " + table +
" a LEFT JOIN sys_union un ON un.id=a.unionId LEFT JOIN sys_user creator ON creator.id=a.updatedBy " +
"WHERE a.year=@year AND (@unionId='' OR a.unionId=@unionId)" +
("special".equals(type) ? " AND (@projectName='' OR LOCATE(@projectName,a.projectName)>0)" : "") +
("special".equals(type) ? " AND (@projectName='' OR LOCATE(@projectName,a.projectName)>0)" : " AND a.status<>2" + amountMatchCondition(amountMatch)) +
" ORDER BY un.unionCode,a.createdAt DESC",
"year", year, "unionId", StrUtil.blankToDefault(unionId, ""),
"projectName", StrUtil.blankToDefault(StrUtil.trim(projectName), ""));
}
private String amountMatchCondition(String amountMatch) {
if ("equal".equals(amountMatch)) return " AND a.calculatedMoney=a.allocateMoney";
if ("different".equals(amountMatch)) return " AND a.calculatedMoney<>a.allocateMoney";
return "";
}
public Result importBaseAmounts(Integer year, String unionId, List<OutlayUnionAllocateImportVo> items, String remark) {
if (year == null || items == null || items.isEmpty() || StrUtil.isBlank(remark) || remark.length() > 1000) {
return Result.error("请选择年度和导入文件,并填写1000字以内的调整说明");
}
Map<String, NutMap> records = new TreeMap<>();
Map<String, BigDecimal> amounts = new TreeMap<>();
for (OutlayUnionAllocateImportVo item : items) {
if (item == null || StrUtil.isBlank(item.getUnionCode()) || !moneyValid(item.getAllocateMoney())) {
return Result.error("工会编码和分配额度必填,金额须为有效的非负数且最多两位小数");
}
List<NutMap> matches = listMap(sql("""
SELECT a.id,a.unionId,a.standard,a.version FROM outlay_annual_base a
JOIN sys_union un ON un.id=a.unionId
WHERE a.year=@year AND a.status IN (0,1) AND un.unionCode=@code AND (@unionId='' OR a.unionId=@unionId)
""", "year", year, "code", item.getUnionCode().trim(), "unionId", StrUtil.blankToDefault(unionId, "")));
if (matches.size() != 1) return Result.error("工会编码 " + item.getUnionCode() + " 未匹配到唯一有效基础经费记录");
NutMap row = matches.get(0);
String uid = row.getString("unionId");
if (records.putIfAbsent(uid, row) != null) return Result.error("工会编码 " + item.getUnionCode() + " 重复");
amounts.put(uid, item.getAllocateMoney());
}
for (Map.Entry<String, NutMap> entry : records.entrySet()) {
String uid = entry.getKey();
NutMap row = entry.getValue();
Result result = save("base", row.getString("id"), year, uid, null, null,
decimal(row.get("standard")), amounts.get(uid), remark.trim(), row.getInt("version"));
if (result.getCode() != 0) throw new IllegalArgumentException(result.getMsg());
}
return Result.success("已导入" + records.size() + "条记录");
}
public List<NutMap> summary(Integer year, String unionId) {
return listMap(sql("""
SELECT un.id, un.name unionName, COALESCE(b.money,0) baseMoney,
@@ -61,6 +101,17 @@ public class OutlayAnnualAllocateService extends BaseServiceImpl<OutlayManageUni
""", "year", year, "unionId", StrUtil.blankToDefault(unionId, "")));
}
public List<NutMap> confirmedRecords(Integer year, String unionId) {
return listMap(sql("""
SELECT id,'base' allocateType,'年度基础经费' projectName,allocateMoney
FROM outlay_annual_base WHERE year=@year AND unionId=@unionId AND status=1
UNION ALL
SELECT id,'special' allocateType,projectName,allocateMoney
FROM outlay_annual_special WHERE year=@year AND unionId=@unionId AND status=1
ORDER BY allocateType,projectName,id
""", "year", year, "unionId", unionId));
}
public List<NutMap> historyRecords(Integer year, String unionId) {
return listMap(sql("""
SELECT a.year,un.name unionName,a.quarterly,a.allocateHeadMoney,a.allocateMoney
@@ -82,20 +133,33 @@ public class OutlayAnnualAllocateService extends BaseServiceImpl<OutlayManageUni
for (NutMap union : unions) {
String uid = union.getString("id");
lockUnion(uid);
if (first(sql("SELECT id FROM outlay_annual_base WHERE year=@year AND unionId=@unionId", "year", year, "unionId", uid)) != null) continue;
NutMap old = first(sql("SELECT * FROM outlay_annual_base WHERE year=@year AND unionId=@unionId", "year", year, "unionId", uid));
if (old != null && old.getInt("status") != 2) continue;
NutMap count = first(sql("SELECT COUNT(DISTINCT id) memberCount FROM " + (current ? "vw_user" : "member_history") +
" WHERE member=1 AND unionId=@unionId" + (current ? "" : " AND year=@year"), "unionId", uid, "year", year));
int members = count.getInt("memberCount");
BigDecimal calculated = standard.multiply(BigDecimal.valueOf(members));
if (!moneyValid(calculated)) throw new IllegalArgumentException("计算金额超出允许范围");
String id = UUID.randomUUID().toString().replace("-", "");
execute(sql("""
INSERT INTO outlay_annual_base
(id,year,unionId,memberCount,standard,calculatedMoney,allocateMoney,remark,status,version,createdAt,createdBy,updatedAt,updatedBy)
VALUES (@id,@year,@unionId,@count,@standard,@money,@money,'',0,0,@now,@actor,@now,@actor)
""", "id", id, "year", year, "unionId", uid, "count", members, "standard", standard,
"money", calculated, "now", System.currentTimeMillis(), "actor", StpUtil.getLoginIdAsString()));
audit("base", id, "生成", null, first(sql("SELECT * FROM outlay_annual_base WHERE id=@id", "id", id)), "生成年度人数快照");
syncBudget(year, uid, calculated);
String id = old == null ? UUID.randomUUID().toString().replace("-", "") : old.getString("id");
if (old == null) {
execute(sql("""
INSERT INTO outlay_annual_base
(id,year,unionId,memberCount,standard,calculatedMoney,allocateMoney,remark,status,version,createdAt,createdBy,updatedAt,updatedBy)
VALUES (@id,@year,@unionId,@count,@standard,@money,@money,'',1,0,@now,@actor,@now,@actor)
""", "id", id, "year", year, "unionId", uid, "count", members, "standard", standard,
"money", calculated, "now", System.currentTimeMillis(), "actor", StpUtil.getLoginIdAsString()));
} else {
// 复用已重置记录,保留原操作日志及年度、分工会唯一约束。
execute(sql("""
UPDATE outlay_annual_base SET memberCount=@count,standard=@standard,calculatedMoney=@money,
allocateMoney=@money,remark='',status=1,version=version+1,updatedAt=@now,updatedBy=@actor
WHERE id=@id
""", "count", members, "standard", standard, "money", calculated, "id", id,
"now", System.currentTimeMillis(), "actor", StpUtil.getLoginIdAsString()));
}
audit("base", id, old == null ? "生成" : "重新生成", old,
first(sql("SELECT * FROM outlay_annual_base WHERE id=@id", "id", id)), "生成年度人数快照");
added++;
}
return Result.success("已生成" + added + "条记录,已有记录保持不变");
@@ -130,7 +194,7 @@ public class OutlayAnnualAllocateService extends BaseServiceImpl<OutlayManageUni
boolean confirmed = old != null && old.getInt("status") == 1;
BigDecimal calculated = null;
if ("base".equals(type)) {
if (old == null) return Result.error("请先生成基础经费记录");
if (old == null || old.getInt("status") == 2) return Result.error("请先生成基础经费记录");
memberCount = old.getInt("memberCount");
if (confirmed) {
standard = new BigDecimal(old.get("standard").toString());
@@ -142,85 +206,73 @@ public class OutlayAnnualAllocateService extends BaseServiceImpl<OutlayManageUni
} else if (StrUtil.isBlank(projectName) || projectName.trim().length() > 100 || allocateMoney.signum() <= 0) {
return Result.error("请填写专项名称(最多100字)和大于0的金额");
}
if (confirmed && StrUtil.isBlank(remark)) return Result.error("调整已下拨记录必须填写原因");
if (confirmed) syncBudget(old, allocateMoney.subtract(new BigDecimal(old.get("allocateMoney").toString())));
if (confirmed && StrUtil.isBlank(remark)) return Result.error("调整已计入预算的经费必须填写原因");
BigDecimal budgetDelta = allocateMoney.subtract(confirmed ? decimal(old.get("allocateMoney")) : BigDecimal.ZERO);
if (!confirmed || budgetDelta.signum() != 0) syncBudget(year, unionId, budgetDelta);
long now = System.currentTimeMillis();
String actor = StpUtil.getLoginIdAsString();
if (old == null) {
execute(sql("""
INSERT INTO outlay_annual_special
(id,year,unionId,projectName,allocateMoney,remark,status,version,createdAt,createdBy,updatedAt,updatedBy)
VALUES (@id,@year,@unionId,@name,@money,@remark,0,0,@now,@actor,@now,@actor)
VALUES (@id,@year,@unionId,@name,@money,@remark,1,0,@now,@actor,@now,@actor)
""", "id", id, "year", year, "unionId", unionId, "name", projectName.trim(), "money", allocateMoney,
"remark", remark, "now", now, "actor", actor));
} else {
execute(sql("UPDATE " + table + " SET allocateMoney=@money,remark=@remark,updatedAt=@now,updatedBy=@actor,version=version+1" +
execute(sql("UPDATE " + table + " SET allocateMoney=@money,remark=@remark,status=1,updatedAt=@now,updatedBy=@actor,version=version+1" +
("base".equals(type) ? ",memberCount=@count,standard=@standard,calculatedMoney=@calculated" : ",projectName=@name") + " WHERE id=@id",
"money", allocateMoney, "remark", remark, "now", now, "actor", actor, "count", memberCount,
"standard", standard, "calculated", calculated, "name", StrUtil.trim(projectName), "id", id));
}
audit(type, id, confirmed ? "调整下拨" : "保存", old, first(sql("SELECT * FROM " + table + " WHERE id=@id", "id", id)), remark);
audit(type, id, confirmed ? "调整经费" : "保存", old, first(sql("SELECT * FROM " + table + " WHERE id=@id", "id", id)), remark);
return Result.success();
}
public Result confirm(String type, String id, Integer version) {
String table = table(type);
NutMap snapshot = first(sql("SELECT * FROM " + table + " WHERE id=@id", "id", id));
if (snapshot == null) return Result.error("记录不存在");
lockUnion(snapshot.getString("unionId"));
NutMap row = first(sql("SELECT * FROM " + table + " WHERE id=@id FOR UPDATE", "id", id));
if (row.getInt("status") == 1) return Result.success("该记录已下拨,无需重复操作");
if (version == null || row.getInt("version") != version) return Result.error("记录已更新,请刷新后重试");
confirmRecord(type, table, row);
return Result.success();
}
public Result batchConfirm(String type, Integer year, String unionId, String projectName) {
String table = table(type);
if (year == null || year < 1900 || year > 9999) return Result.error("请选择有效年度");
List<NutMap> pending = listMap(sql("SELECT * FROM " + table +
" WHERE year=@year AND status=0 AND (@unionId='' OR unionId=@unionId)" +
("special".equals(type) ? " AND (@projectName='' OR LOCATE(@projectName,projectName)>0)" : "") +
" ORDER BY unionId,id",
"year", year, "unionId", StrUtil.blankToDefault(unionId, ""),
"projectName", StrUtil.blankToDefault(StrUtil.trim(projectName), "")));
public Result resetBaseRecords(Integer year, String unionId, String amountMatch) {
if (year == null || year != Year.now().getValue()) return Result.error("只能重置当前年度的分配记录");
List<NutMap> candidates = listMap(sql("""
SELECT id,unionId,version FROM outlay_annual_base a
WHERE year=@year AND status IN (0,1) AND (@unionId='' OR unionId=@unionId)
""" + amountMatchCondition(amountMatch) + " ORDER BY unionId,id", "year", year, "unionId", StrUtil.blankToDefault(unionId, "")));
int count = 0;
for (NutMap candidate : pending) {
for (NutMap candidate : candidates) {
String id = candidate.getString("id");
lockUnion(candidate.getString("unionId"));
NutMap row = first(sql("SELECT * FROM " + table + " WHERE id=@id FOR UPDATE", "id", candidate.getString("id")));
if (row == null || row.getInt("status") == 1) continue;
if (row.getInt("version") != candidate.getInt("version"))
throw new IllegalArgumentException("待下拨记录已被修改,本次操作已取消,请刷新后重试");
confirmRecord(type, table, row);
NutMap row = first(sql("SELECT * FROM outlay_annual_base WHERE id=@id FOR UPDATE", "id", id));
if (row == null || row.getInt("status") == 2) continue;
if (row.getInt("version") != candidate.getInt("version")) {
throw new IllegalArgumentException("分配记录发生变更,本次重置已取消,请刷新后重试");
}
BigDecimal amount = row.getInt("status") == 1 ? decimal(row.get("allocateMoney")) : BigDecimal.ZERO;
if (amount.signum() != 0) syncBudget(year, row.getString("unionId"), amount.negate());
execute(sql("""
UPDATE outlay_annual_base
SET status=2,updatedAt=@now,updatedBy=@actor,version=version+1
WHERE id=@id
""", "id", id, "now", System.currentTimeMillis(), "actor", StpUtil.getLoginIdAsString()));
NutMap after = first(sql("SELECT * FROM outlay_annual_base WHERE id=@id", "id", id));
audit("base", id, "重置分配记录", row, after, "扣回年度预算 " + amount.toPlainString() + " 元");
count++;
}
return Result.success(count == 0 ? "当前筛选范围没有待下拨记录" : "成功下拨" + count + "条记录");
return Result.success(count == 0 ? "当前筛选范围没有可重置记录" : "已重置" + count + "条记录");
}
private void confirmRecord(String type, String table, NutMap row) {
String id = row.getString("id");
syncBudget(row, new BigDecimal(row.get("allocateMoney").toString()));
execute(sql("UPDATE " + table + " SET status=1,allocatedAt=@now,updatedAt=@now,updatedBy=@actor,version=version+1 WHERE id=@id",
"id", id, "now", System.currentTimeMillis(), "actor", StpUtil.getLoginIdAsString()));
audit(type, id, "确认下拨", row, first(sql("SELECT * FROM " + table + " WHERE id=@id", "id", id)), "确认下拨");
}
private void syncBudget(NutMap row, BigDecimal delta) {
private void syncBudget(int year, String unionId, BigDecimal delta) {
List<NutMap> budgets = listMap(sql("SELECT * FROM outlay_manage_union WHERE year=@year AND unionId=@unionId FOR UPDATE",
"year", row.getInt("year"), "unionId", row.getString("unionId")));
"year", year, "unionId", unionId));
if (budgets.size() > 1) throw new IllegalArgumentException("该分工会年度预算存在重复记录,不能下拨");
if (budgets.isEmpty()) {
if (delta.signum() < 0) throw new IllegalArgumentException("年度预算不存在,不能扣减");
// 新建预算承接上年结余;已有预算仅增加本次差额,避免重复结转。
NutMap previous = first(sql("SELECT COALESCE(totalQuota,0)-COALESCE(usedQuota,0) surplus FROM outlay_manage_union WHERE year=@year AND unionId=@unionId",
"year", row.getInt("year") - 1, "unionId", row.getString("unionId")));
"year", year - 1, "unionId", unionId));
BigDecimal carry = previous == null ? BigDecimal.ZERO : new BigDecimal(previous.get("surplus").toString());
if (!moneyValid(carry.add(delta))) throw new IllegalArgumentException("下拨后年度总额度超出允许范围");
execute(sql("""
INSERT INTO outlay_manage_union (id,year,unionId,unionName,unionCode,totalQuota,usedQuota,delFlag,createdAt,updatedAt,createdBy,updatedBy)
SELECT @id,@year,id,name,unionCode,@money,0,0,@now,@now,@actor,@actor FROM sys_union WHERE id=@unionId
""", "id", UUID.randomUUID().toString().replace("-", ""), "year", row.getInt("year"),
"unionId", row.getString("unionId"), "money", carry.add(delta), "now", System.currentTimeMillis(), "actor", StpUtil.getLoginIdAsString()));
""", "id", UUID.randomUUID().toString().replace("-", ""), "year", year,
"unionId", unionId, "money", carry.add(delta), "now", System.currentTimeMillis(), "actor", StpUtil.getLoginIdAsString()));
} else {
NutMap budget = budgets.get(0);
BigDecimal total = decimal(budget.get("totalQuota")).add(delta);
@@ -70,20 +70,18 @@ public class OutlayManageUnionStatisticsServiceImpl extends BaseServiceImpl impl
}
private String incomeExpenseQuery() {
// 历史会费并入年度基础经费,历史奖励并入专项经费;分别汇总避免关联后重复累计。
// 年初数由年度总额度扣除当年各类下拨计算,兼容没有历史第一期记录的新流程。
return """
SELECT mu.year, mu.unionId,
COALESCE(mu.unionName,su.name) AS unionName,
COALESCE(mu.unionCode,su.unionCode) AS unionCode,
COALESCE(mu.totalQuota,0) - (COALESCE(b.money,0) + COALESCE(sp.money,0) + COALESCE(au.feeJanApr,0) + COALESCE(au.feeMayAug,0) + COALESCE(au.feeSepDec,0) + COALESCE(au.awardMoney,0)) AS beginMoney,
COALESCE(b.money,0) + COALESCE(au.feeJanApr,0) + COALESCE(au.feeMayAug,0) + COALESCE(au.feeSepDec,0) AS baseMoney,
COALESCE(sp.money,0) + COALESCE(au.awardMoney,0) AS specialMoney,
(COALESCE(b.money,0) + COALESCE(sp.money,0) + COALESCE(au.feeJanApr,0) + COALESCE(au.feeMayAug,0) + COALESCE(au.feeSepDec,0) + COALESCE(au.awardMoney,0)) AS incomeMoney,
COALESCE(au.feeJanApr,0) AS feeJanApr,
COALESCE(au.feeMayAug,0) AS feeMayAug,
COALESCE(au.feeSepDec,0) AS feeSepDec,
COALESCE(au.awardMoney,0) AS awardMoney,
COALESCE(mu.totalQuota,0) - (COALESCE(b.money,0) + COALESCE(sp.money,0)) AS beginMoney,
COALESCE(b.money,0) AS baseMoney,
COALESCE(sp.money,0) AS specialMoney,
COALESCE(b.money,0) + COALESCE(sp.money,0) AS incomeMoney,
0 AS feeJanApr,
0 AS feeMayAug,
0 AS feeSepDec,
0 AS awardMoney,
COALESCE(mu.usedQuota,0) AS usedMoney,
COALESCE(mu.totalQuota,0)-COALESCE(mu.usedQuota,0) AS remainMoney
FROM outlay_manage_union mu
@@ -96,14 +94,6 @@ public class OutlayManageUnionStatisticsServiceImpl extends BaseServiceImpl impl
SELECT year,unionId,SUM(allocateMoney) money FROM outlay_annual_special
WHERE year=@incomeYear AND status=1 GROUP BY year,unionId
) sp ON sp.year=mu.year AND sp.unionId=mu.unionId
LEFT JOIN (
SELECT year,unionId,
SUM(CASE WHEN quarterly=1 THEN allocateMoney ELSE 0 END) feeJanApr,
SUM(CASE WHEN quarterly=2 THEN allocateMoney ELSE 0 END) feeMayAug,
SUM(CASE WHEN quarterly=3 THEN allocateMoney ELSE 0 END) feeSepDec,
SUM(CASE WHEN quarterly=4 THEN allocateMoney ELSE 0 END) awardMoney
FROM outlay_allocate_union WHERE year=@incomeYear AND delFlag=0 GROUP BY year,unionId
) au ON au.year=mu.year AND au.unionId=mu.unionId
$condition
""";
}
@@ -10,6 +10,8 @@ import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.lang.util.NutMap;
import org.nutz.json.Json;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.union.vo.OutlayUnionAllocateImportVo;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.List;
@@ -38,9 +40,9 @@ public class OutlayAnnualAllocateController {
@At
@SaCheckPermission("outlay.outlayManage.union.allocate")
public Result pageData(PageForm pageForm, String type, Integer year, String unionId, String projectName) {
public Result pageData(PageForm pageForm, String type, Integer year, String unionId, String projectName, String amountMatch) {
return Result.success(outlayAnnualAllocateService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(),
outlayAnnualAllocateService.pageSql(type, year, unionId, projectName)));
outlayAnnualAllocateService.pageSql(type, year, unionId, projectName, amountMatch)));
}
@At
@@ -117,16 +119,18 @@ public class OutlayAnnualAllocateController {
@SaCheckPermission("outlay.outlayManage.union.allocate")
@POST
@Aop(TransAop.READ_COMMITTED)
public Result confirm(String type, String id, Integer version) {
return outlayAnnualAllocateService.confirm(type, id, version);
public Result resetBaseRecords(Integer year, String unionId, String amountMatch) {
return outlayAnnualAllocateService.resetBaseRecords(year, unionId, amountMatch);
}
@At
@SaCheckPermission("outlay.outlayManage.union.allocate")
@POST
@Aop(TransAop.READ_COMMITTED)
public Result batchConfirm(String type, Integer year, String unionId, String projectName) {
return outlayAnnualAllocateService.batchConfirm(type, year, unionId, projectName);
@SaCheckPermission("outlay.outlayManage.union.allocate")
public Result importBaseAmounts(Integer year, String unionId, String data, String remark) {
if (data == null || data.isBlank()) return Result.error("请先上传导入文件");
return outlayAnnualAllocateService.importBaseAmounts(year, unionId,
Json.fromJsonAsList(OutlayUnionAllocateImportVo.class, data), remark);
}
@At
@@ -13,6 +13,7 @@ import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service.OutlayUseDetailService;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service.OutlayManageUnionStatisticsService;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service.impl.OutlayAnnualAllocateService;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.union.model.OutLayAllocateUnion;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -49,6 +50,8 @@ public class OutlayManageUnionUseDetailController {
private OutlayUseDetailService outlayUseDetailService;
@Inject
private OutlayManageUnionStatisticsService outlayManageUnionStatisticsService;
@Inject
private OutlayAnnualAllocateService outlayAnnualAllocateService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/outlay/outlayManage/union/useDetail/index.html")
@@ -128,6 +131,13 @@ public class OutlayManageUnionUseDetailController {
return Result.success();
}
@At
@ApiOperation("查询分工会年度已下拨经费明细")
@SaCheckPermission("outlay.outlayManage.union.useDetail")
public Result queryAnnualAllocateList(Integer year, String unionId) {
return Result.success(outlayAnnualAllocateService.confirmedRecords(year, unionId));
}
@At
@ApiOperation("查询分工会今年每个分配项目的分配情况")
@SaCheckPermission("outlay.outlayManage.union.useDetail")
@@ -122,9 +122,9 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 保存、提交及重新提交共用支付信息校验,并在同一事务内保存申请和发票明细。
* 在同一事务内保存申请和发票明细,正式提交及重新提交时校验支付信息。
* @param unionReimburse 申请表单,paymentDetails 为支付方式、金额及对私收款资料组成的明细列表
* @param stateId 1 表示保存草稿,2 表示提交;均要求支付信息完整,发票选填,上传后须通过服务端验真
* @param stateId 1 表示保存草稿,2 表示提交;提交时至少需要一张发票
* @return Result 成功或失败结果,msg 包含失败明细的行号及原因
*/
@Override
@@ -133,23 +133,27 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (unionReimburse == null) {
return Result.error("报销数据不能为空");
}
// 发票选填;保存、提交及重提均对已上传的发票检查完整性。
if (stateId == 2 && Lang.isEmpty(unionReimburse.getInvoiceDetails())) {
return Result.error("提交前请至少上传一张发票");
}
// 保存、提交及重提均对已上传的发票检查完整性。
Result invoiceResult = validateInvoiceDetails(unionReimburse);
if (invoiceResult != null) {
return invoiceResult;
}
Result paymentResult = validatePaymentDetails(unionReimburse);
if (paymentResult != null) {
return paymentResult;
}
// 保存、提交及重提均在写入前检查支付总额,不能只依赖页面的金额限制。
Result amountResult = validatePaymentAmountLimit(unionReimburse);
if (amountResult != null) {
return amountResult;
}
Result activityAmountResult = validateActivityApplyMoney(unionReimburse);
if (activityAmountResult != null) {
return activityAmountResult;
if (stateId == 2) {
Result paymentResult = validatePaymentDetails(unionReimburse);
if (paymentResult != null) {
return paymentResult;
}
Result amountResult = validatePaymentAmountLimit(unionReimburse);
if (amountResult != null) {
return amountResult;
}
Result activityAmountResult = validateActivityApplyMoney(unionReimburse);
if (activityAmountResult != null) {
return activityAmountResult;
}
}
UnionReimburse oldRecord = null;
if (StrUtil.isNotBlank(unionReimburse.getId())) {
@@ -760,15 +764,18 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
invoiceFileCount++;
}
}
// 无发票申请从服务端已校验的支付明细计算金额,不能信任前端传入的报销总额。
if (Lang.isEmpty(unionReimburse.getInvoiceDetails())) {
// 无发票时由支付明细汇总,草稿中未填完的金额暂不计入。
if (Lang.isEmpty(unionReimburse.getInvoiceDetails()) && Lang.isNotEmpty(unionReimburse.getPaymentDetails())) {
BigDecimal paymentTotal = BigDecimal.ZERO;
for (JSONObject detail : unionReimburse.getPaymentDetails()) {
paymentTotal = paymentTotal.add(new BigDecimal(detail.get("money").toString()));
String money = detail == null ? "" : detail.getStr("money", "");
if (money.matches("[0-9]+(\\.[0-9]{1,2})?")) {
paymentTotal = paymentTotal.add(new BigDecimal(money));
}
}
totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue();
}
// 三类活动使用已校验的手填申请金额,不能被发票或支付合计覆盖。
// 三类活动保留手填申请金额。
if (!isManualReimburseAmount(unionReimburse)) {
unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
@@ -783,8 +790,8 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 校验支付明细,防止绕过 PC/H5 校验直接保存。对公只需方式和金额,对私必须补齐人员及银行资料。
* @param form 本次保存的申请,paymentDetails 必须至少包含一条完整支付明细
* 校验提交的支付明细。对公只需方式和金额,对私必须补齐人员及银行资料。
* @param form 本次提交的申请,paymentDetails 必须至少包含一条完整支付明细
* @return 校验通过返回 null,失败返回带行号和原因的 Result
*/
private Result validatePaymentDetails(UnionReimburse form) {
@@ -916,7 +923,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 发票列表允许为空;已有明细必须且只能包含一张可定位的发票文件。
* 校验已填写的发票明细,每条明细必须且只能包含一张可定位的发票文件。
*/
private Result validateInvoiceDetails(UnionReimburse unionReimburse) {
if (Lang.isEmpty(unionReimburse.getInvoiceDetails())) {
+7
View File
@@ -80,3 +80,10 @@ custom:
encrypted-secret-key: ''
connect-timeout: 5000
read-timeout: 10000
invoice:
tencent:
# 发票 OCR 使用腾讯云 SecretId/SecretKey;为空时会回退读取 secret-file 指向的 csv。
secret-id: AKID1473y253saFsMelgS4TjnfLofMrAHDyX
secret-key: TlrlTzIRNjtIj296riPuISbmqplnhQDy
secret-file: C:/Users/Administrator/Desktop
@@ -10,9 +10,8 @@ CREATE TABLE IF NOT EXISTS `outlay_annual_base` (
`calculatedMoney` decimal(10,2) NOT NULL,
`allocateMoney` decimal(10,2) NOT NULL,
`remark` varchar(1000) DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待下拨 1已下拨',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待下拨 1已下拨 2已重置',
`version` int NOT NULL DEFAULT 0,
`allocatedAt` bigint DEFAULT NULL,
`createdAt` bigint NOT NULL,
`createdBy` varchar(32) NOT NULL,
`updatedAt` bigint NOT NULL,
@@ -30,7 +29,6 @@ CREATE TABLE IF NOT EXISTS `outlay_annual_special` (
`remark` varchar(1000) DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待下拨 1已下拨',
`version` int NOT NULL DEFAULT 0,
`allocatedAt` bigint DEFAULT NULL,
`createdAt` bigint NOT NULL,
`createdBy` varchar(32) NOT NULL,
`updatedAt` bigint NOT NULL,
@@ -53,6 +51,23 @@ CREATE TABLE IF NOT EXISTS `outlay_annual_allocate_log` (
KEY `idx_annual_log_record_time` (`allocateType`,`allocateId`,`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='经费下拨及调整记录';
-- 兼容已执行旧版建表脚本的数据库。
SET @drop_annual_allocated_at = IF(EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'outlay_annual_base' AND COLUMN_NAME = 'allocatedAt'
), 'ALTER TABLE `outlay_annual_base` DROP COLUMN `allocatedAt`', 'DO 0');
PREPARE drop_annual_allocated_at FROM @drop_annual_allocated_at;
EXECUTE drop_annual_allocated_at;
DEALLOCATE PREPARE drop_annual_allocated_at;
SET @drop_annual_allocated_at = IF(EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'outlay_annual_special' AND COLUMN_NAME = 'allocatedAt'
), 'ALTER TABLE `outlay_annual_special` DROP COLUMN `allocatedAt`', 'DO 0');
PREPARE drop_annual_allocated_at FROM @drop_annual_allocated_at;
EXECUTE drop_annual_allocated_at;
DEALLOCATE PREPARE drop_annual_allocated_at;
-- 沿用现有权限与入口,仅调整菜单名称。
UPDATE sys_menu SET name='年度经费下拨', aliasName='年度经费下拨'
WHERE permission='outlay.outlayManage.union.allocate';
@@ -177,7 +177,7 @@ layout("/layouts/platform.html"){
getTreeData() {
this.$set(this, "treeLoading", true)
// tree 返回单位树数组;失败时保留现有内容,成功后按 ID 恢复当前单位。
return this.$axios.post(loc() + "/tree").then((res) => {
return this.$axios.post("/platform/sys/unit/tree").then((res) => {
if (res.code !== 0 || !Array.isArray(res.data)) return
const selectedId = this.currentTreeData?.id
this.$set(this, "treeData", res.data)
@@ -6,6 +6,14 @@ layout("/layouts/platform.html"){
.fund-log-dialog .el-dialog { width: 94%; max-width: 1800px; height: 86vh; display: flex; flex-direction: column; }
.fund-log-dialog .el-dialog__body { padding: 16px 24px 24px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.fund-log-dialog .el-pagination { margin-top: 16px; text-align: right; flex: 0 0 auto; }
.fund-import-dialog .el-dialog { max-width: calc(100vw - 32px); }
.fund-import-dialog .el-dialog__body { padding: 16px 24px; max-height: 68vh; overflow-y: auto; }
.fund-import-dialog .el-dialog__footer { border-top: 1px solid #ebeef5; }
.fund-import-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 16px 0 8px; }
.fund-import-upload .el-upload,
.fund-import-upload .el-upload-dragger { display: block; width: 100%; }
.fund-import-upload .el-upload-dragger { height: 140px; }
.fund-import-upload .el-icon-upload { margin: 16px 0 8px; }
.fund-edit-dialog .el-dialog { max-width: calc(100vw - 32px); }
.fund-edit-dialog .el-dialog__body { padding: 20px 28px; max-height: 65vh; overflow-y: auto; }
.fund-edit-dialog .el-dialog__footer { padding: 16px 28px; border-top: 1px solid #ebeef5; }
@@ -23,6 +31,14 @@ layout("/layouts/platform.html"){
.fund-edit-amount { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.fund-edit-amount .el-input-number { flex: 1; min-width: 200px; }
.fund-edit-calculated { display: block; padding: 0 15px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; font-size: 20px; font-weight: 600; color: #303133; }
.fund-adjust-dialog .el-dialog__header { padding-bottom: 8px; }
.fund-adjust-dialog .el-dialog__body { padding: 12px 24px; }
.fund-adjust-dialog .el-dialog__footer { padding: 12px 24px; }
.fund-adjust-dialog .el-alert { margin-bottom: 12px; }
.fund-adjust-dialog .fund-edit-form { gap: 10px 20px; }
.fund-adjust-dialog .fund-edit-section { padding-bottom: 6px; }
.fund-adjust-dialog .el-form-item__label { padding-bottom: 4px; line-height: 20px; }
.fund-adjust-dialog .fund-edit-difference { padding: 6px 12px; }
@media (max-width: 600px) {
.fund-edit-form { grid-template-columns: minmax(0, 1fr); }
}
@@ -35,6 +51,13 @@ layout("/layouts/platform.html"){
<search-item label="年度"><el-date-picker v-model="query.year" type="year" value-format="yyyy" :clearable="false" @change="doSearch"></el-date-picker></search-item>
<search-item label="分工会"><el-select v-model="query.unionId" clearable filterable @change="doSearch"><el-option v-for="u in unionList" :key="u.id" :value="u.id" :label="u.name"></el-option></el-select></search-item>
<search-item v-if="tab==='special'" label="专项名称"><el-input v-model="query.projectName" clearable placeholder="请输入专项名称" @keyup.enter.native="doSearch" @clear="doSearch"></el-input></search-item>
<search-item v-if="tab==='base'" label="金额一致性">
<el-select v-model="query.amountMatch" @change="doSearch">
<el-option label="全部" value=""></el-option>
<el-option label="一致" value="equal"></el-option>
<el-option label="不一致" value="different"></el-option>
</el-select>
</search-item>
</search>
</el-card>
<el-card class="outlay-union-fixed-list-card mt10" shadow="never">
@@ -47,8 +70,9 @@ layout("/layouts/platform.html"){
<table-tool label="年度经费下拨">
<el-button v-if="tab==='base'" size="small" type="primary" @click="generateVisible=true" :disabled="busy">生成年度基础经费</el-button>
<el-button v-if="tab==='special'" size="small" type="primary" @click="addSpecial" :disabled="busy">新增专项经费</el-button>
<el-button v-if="tab==='base' || tab==='special'" size="small" type="success" @click="batchConfirm" :loading="busy">一键下拨</el-button>
<el-button v-if="tab==='base' && isCurrentYear" size="small" type="danger" plain @click="resetBaseRecords" :disabled="busy || loading">重置分配记录</el-button>
<el-button v-if="tab==='summary' || tab==='history'" size="small" type="primary" @click="doExport" :disabled="loading || busy">导出Excel</el-button>
<el-button v-if="tab==='base'" size="small" type="primary" plain @click="openBaseImport" :disabled="busy || loading">Excel导入金额</el-button>
</table-tool>
<el-table :key="tab" ref="tableRef" :data="rows" :height="tableHeight" v-loading="loading" border row-key="id">
<el-table-column v-if="tab!=='summary'" prop="year" label="年度" width="85"></el-table-column>
@@ -71,14 +95,11 @@ layout("/layouts/platform.html"){
</template>
<template v-else>
<el-table-column prop="allocateMoney" label="实际下拨金额(元)" width="160"></el-table-column>
<el-table-column label="状态" width="100"><template slot-scope="{row}">{{Number(row.status)===1?'已下拨':'待下拨'}}</template></el-table-column>
<el-table-column prop="remark" label="说明" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column label="下拨时间" width="165"><template slot-scope="{row}">{{time(row.allocatedAt)}}</template></el-table-column>
<el-table-column prop="operatorName" label="操作人" width="100"></el-table-column>
<el-table-column label="最后操作时间" width="165"><template slot-scope="{row}">{{time(row.updatedAt)}}</template></el-table-column>
<el-table-column label="操作" fixed="right" width="220"><template slot-scope="{row}">
<el-button type="text" :disabled="busy" @click="edit(row)">{{Number(row.status)===1?'调整':'编辑'}}</el-button>
<el-button v-if="Number(row.status)===0" type="text" :disabled="busy" @click="confirm(row)">确认下拨</el-button>
<el-button type="text" @click="showLogs(row)">操作记录</el-button>
</template></el-table-column>
</template>
@@ -89,7 +110,7 @@ layout("/layouts/platform.html"){
</el-card>
</div>
<el-dialog title="生成年度基础经费" :visible.sync="generateVisible" width="550px" :close-on-click-modal="false">
<el-alert title="按所选年度、分工会生成;已有记录不覆盖。人数沿用会员统计口径并保存快照,确认下拨前请核对。" type="info" :closable="false"></el-alert>
<el-alert title="按所选年度、分工会生成并计入年度预算。已有记录不覆盖,人数沿用会员统计口径并保存快照。" type="info" :closable="false"></el-alert>
<el-form label-width="130px" class="mt10">
<el-form-item label="年度">{{query.year}}</el-form-item>
<el-form-item label="分工会">{{selectedUnionName}}</el-form-item>
@@ -97,8 +118,40 @@ layout("/layouts/platform.html"){
</el-form>
<span slot="footer"><el-button @click="generateVisible=false" :disabled="busy">取消</el-button><el-button type="primary" :loading="busy" @click="generate">生成</el-button></span>
</el-dialog>
<el-dialog :title="form.status===1?'调整已下拨经费':'编辑经费'" :visible.sync="editVisible" width="860px" class="fund-edit-dialog" top="6vh" :close-on-click-modal="false" :show-close="!busy">
<el-alert v-if="form.status===1" title="当前经费已下拨,保存后将按新旧金额差额调整年度预算,请填写调整原因。" type="warning" show-icon :closable="false"></el-alert>
<el-dialog title="Excel导入年度基础经费金额" :visible.sync="baseImportVisible" width="780px" top="6vh" class="fund-import-dialog" :close-on-click-modal="false" :show-close="!busy" destroy-on-close>
<el-alert :title="query.year+'年 / '+selectedUnionName" type="info" show-icon :closable="false">
请在模板中仅保留需调整的分工会并填写分配额度。导入会更新已有基础经费记录并同步年度预算。任一行未匹配到唯一有效记录时,整批导入失败。
</el-alert>
<div class="fund-import-toolbar">
<strong>导入文件</strong>
<el-button size="small" @click="downloadBaseImportTemplate">下载模板</el-button>
</div>
<el-upload class="fund-import-upload" action="/platform/outlay/outlayManage/unionAllocate/readImportExcel" drag accept=".xls,.xlsx" :limit="1"
:before-upload="validateBaseImportFile" :on-exceed="onBaseImportExceed"
:on-success="onBaseImportPreview" :on-error="onBaseImportError" :on-remove="clearBaseImportPreview" :disabled="busy">
<i class="el-icon-upload"></i>
<div class="el-upload__text">将 Excel 文件拖到此处,或<em>点击上传</em></div>
<div slot="tip" class="el-upload__tip">支持 .xls、.xlsx,每次上传一个文件</div>
</el-upload>
<el-form label-position="top" class="mt10">
<el-form-item label="调整说明" required>
<el-input v-model="baseImportRemark" type="textarea" :rows="2" maxlength="1000" show-word-limit></el-input>
</el-form-item>
</el-form>
<el-table v-if="baseImportRows.length" :data="baseImportRows" max-height="300" border>
<el-table-column prop="unionCode" label="工会编码" width="120"></el-table-column>
<el-table-column prop="unionName" label="分工会" min-width="180"></el-table-column>
<el-table-column prop="allocateMoney" label="实际下拨金额(元)" min-width="150"></el-table-column>
<el-table-column label="校验结果" min-width="150"><template slot-scope="{row}">{{row.errMsg || '通过'}}</template></el-table-column>
</el-table>
<span slot="footer">
<el-button @click="baseImportVisible=false" :disabled="busy">取消</el-button>
<el-button type="primary" @click="saveBaseImport" :loading="busy" :disabled="!baseImportRows.length">确认导入</el-button>
</span>
</el-dialog>
<el-dialog :title="form.status===1?'调整经费':'编辑经费'" :visible.sync="editVisible" width="860px" class="fund-edit-dialog" :class="{'fund-adjust-dialog': form.status===1}" top="6vh" :close-on-click-modal="false" :show-close="!busy">
<el-alert v-if="form.status===1" title="保存后将按新旧金额差额调整年度预算,请填写调整原因。" type="warning" show-icon :closable="false"></el-alert>
<el-alert v-else title="保存后金额将计入年度预算。" type="info" show-icon :closable="false"></el-alert>
<el-form class="fund-edit-form" label-position="top">
<div class="fund-edit-section">基础信息 · {{tab==='base'?'年度基础经费':'专项经费'}}</div>
<el-form-item label="年度"><el-date-picker v-model="form.year" type="year" value-format="yyyy" :clearable="false" :disabled="!isNew"></el-date-picker></el-form-item>
@@ -139,12 +192,14 @@ layout("/layouts/platform.html"){
mixins: [OUTLAY_UNION_FIXED_LIST_MIXIN],
dicts: ['OUTLAY_QUARTERLY'],
data() { return {
tab: 'base', query: {year: this.$moment().format('YYYY'), unionId: '', projectName: '', pageNumber: 1, pageSize: 20},
tab: 'base', query: {year: this.$moment().format('YYYY'), unionId: '', projectName: '', amountMatch: '', pageNumber: 1, pageSize: 20},
unionList: [], rows: [], total: 0, loading: false, busy: false, requestSequence: 0,
standard: 800, generateVisible: false, editVisible: false, isNew: false, form: {}, originalAllocateMoney: 0,
baseImportVisible: false, baseImportRows: [], baseImportRemark: '',
logsVisible: false, logsLoading: false, logs: [], logPage: 1, logTotal: 0, logRecord: null
}},
computed: {
isCurrentYear() { return this.query.year===this.$moment().format('YYYY') },
calculated() { return ((this.form.memberCount || 0) * (this.form.standard || 0)).toFixed(2) },
amountDifference() {
const amount=Number(this.form.allocateMoney)
@@ -153,13 +208,17 @@ layout("/layouts/platform.html"){
return Number((amount-reference).toFixed(2))
},
remarkRequired() { return this.form.status===1 || (this.tab==='base' && this.amountDifference!==0) },
amountMatchScope() {
if (!this.query.amountMatch) return ''
return this.query.amountMatch==='equal'?' / 金额一致':' / 金额不一致'
},
selectedUnionName() { const u=this.unionList.find(u=>u.id===this.query.unionId); return u?u.name:'全部分工会' }
},
methods: {
time(value) { return value?this.$moment(Number(value)).format('YYYY-MM-DD HH:mm:ss'):'—' },
snapshot(value) {
if (!value) return '—'
try { const v=JSON.parse(value); return v.year+'年 / '+(v.memberCount==null?(v.projectName || ''):v.memberCount+'人 × '+v.standard+'元,计算 '+v.calculatedMoney+'元')+' / 实际 '+v.allocateMoney+'元 / '+(Number(v.status)===1?'已下拨':'待下拨') }
try { const v=JSON.parse(value); return v.year+'年 / '+(v.memberCount==null?(v.projectName || ''):v.memberCount+'人 × '+v.standard+'元,计算 '+v.calculatedMoney+'元')+' / 实际 '+v.allocateMoney+'元 / '+(Number(v.status)===2?'已重置':Number(v.status)===1?'已下拨':'待下拨') }
catch(e) { return value }
},
doExport() {
@@ -221,27 +280,59 @@ layout("/layouts/platform.html"){
try { await this.request('save',payload); this.$message.success('保存成功'); this.editVisible=false; this.doSearch() }
catch(e) {} finally { this.busy=false }
},
async batchConfirm() {
if (this.busy) return
const params={type:this.tab,year:this.query.year,unionId:this.query.unionId || '',projectName:this.tab==='special'?(this.query.projectName || '').trim():''}
const typeName=this.tab==='base'?'年度基础经费':'专项经费'
const scope=this.selectedUnionName+(params.projectName?' / 专项名称包含:'+params.projectName:'')
if (!params.year) { this.$message.warning('请选择年度'); return }
openBaseImport() {
this.clearBaseImportPreview()
this.baseImportRemark=''
this.baseImportVisible=true
},
downloadBaseImportTemplate() {
window.open('/platform/outlay/outlayManage/unionAllocate/downloadImportTemplate')
},
clearBaseImportPreview() { this.baseImportRows=[] },
validateBaseImportFile(file) {
if (/\.(xls|xlsx)$/i.test(file.name)) return true
this.$message.warning('请上传 .xls 或 .xlsx 文件')
return false
},
onBaseImportExceed() {
this.$message.warning('每次只能上传一个 Excel 文件,更换时请先移除已选文件')
},
onBaseImportPreview(resp) {
this.clearBaseImportPreview()
if (resp.code===0) this.baseImportRows=resp.data || []
else this.$message.error(resp.msg || 'Excel读取失败')
},
onBaseImportError() {
this.clearBaseImportPreview()
this.$message.error('Excel上传失败')
},
async saveBaseImport() {
if (this.busy || !this.baseImportRows.length) return
if (this.baseImportRows.some(row=>row.errMsg)) { this.$message.warning('请修正校验未通过的记录后重新上传'); return }
if (!this.baseImportRemark.trim()) { this.$message.warning('请填写调整说明'); return }
this.busy=true
try {
await this.$confirm('确认一键下拨【'+params.year+'年 / '+scope+'】的全部待下拨'+typeName+'?范围包含所有分页,已下拨记录自动跳过,请先核对金额及历史下拨记录。','一键下拨',{type:'warning'})
const resp=await this.request('batchConfirm',params)
const resp=await this.request('importBaseAmounts',{
year:this.query.year,unionId:this.query.unionId || '',
data:JSON.stringify(this.baseImportRows),remark:this.baseImportRemark.trim()
})
this.$message.success(resp.msg)
this.baseImportVisible=false
this.doSearch()
} catch(e) {} finally { this.busy=false }
},
async resetBaseRecords() {
if (this.busy || !this.isCurrentYear) return
const params={year:this.query.year,unionId:this.query.unionId || '',amountMatch:this.query.amountMatch}
const scope=params.year+'年 / '+this.selectedUnionName+this.amountMatchScope
this.busy=true
try {
await this.$confirm('确认重置【'+scope+'】全部分页中的基础经费分配记录?已计入预算的金额将扣回,重置后可重新生成。','重置分配记录',{type:'warning',confirmButtonText:'确认重置'})
const resp=await this.request('resetBaseRecords',params)
this.$message.success(resp.msg)
this.load()
} catch(e) {} finally { this.busy=false }
},
async confirm(row) {
try { await this.$confirm('确认向【'+row.unionName+'】下拨 '+row.allocateMoney+' 元?此操作将计入年度预算,请核对历史下拨记录。','确认下拨',{type:'warning'}) }
catch(e) { return }
this.busy=true
try { await this.request('confirm',{type:this.tab,id:row.id,version:row.version}); this.$message.success('下拨成功'); this.load() }
catch(e) {} finally { this.busy=false }
},
showLogs(row) { this.logRecord={id:row.id,type:this.tab}; this.logPage=1; this.logsVisible=true; this.loadLogs() },
async loadLogs() {
this.logsLoading=true
@@ -4,22 +4,15 @@ let OUTLAY_MANAGE_UNION_QUARTERLY_ALLOCATE = {
`
<div>
<template>
<table-tool label="分配记录"></table-tool>
<table-tool label="年度已下拨记录"></table-tool>
<el-table :data="tableData" style="width: 100%" row-key="id" class="vi-table">
<el-table-column type="index" label="序号" width="80px"></el-table-column>
<el-table-column label="分配项目" prop="quarterly">
<template v-slot="{row}">
<dict-tag :options="dict.type.OUTLAY_QUARTERLY"
:value="row.quarterly"></dict-tag>
</template>
</el-table-column>
<el-table-column label="年初数" prop="allocateHeadMoney"></el-table-column>
<el-table-column label="经费额度" prop="allocateMoney"></el-table-column>
<el-table-column label="分配项目" prop="projectName"></el-table-column>
<el-table-column label="实际下拨金额(元)" prop="allocateMoney"></el-table-column>
</el-table>
</template>
</div>
`,
dicts: ["OUTLAY_QUARTERLY"],
data() {
return {
rowData: {},
@@ -29,11 +22,12 @@ let OUTLAY_MANAGE_UNION_QUARTERLY_ALLOCATE = {
methods: {
open(row) {
this.rowData = row
this.$axios.post("/platform/outlay/outlayManage/unionUseDetail/queryQuarterlyList", {
this.tableData = []
this.$axios.post("/platform/outlay/outlayManage/unionUseDetail/queryAnnualAllocateList", {
year: row.year,
unionId: row.unionId
}).then(resp => {
if (resp.code === 0) {
if (this.rowData === row && resp.code === 0) {
this.tableData = resp.data
}
})
@@ -52,6 +52,21 @@ layout("/layouts/platform.html"){
.payment-info-table .el-form-item {
margin-bottom: 0;
}
.invoice-detail-dialog {
max-width: calc(100vw - 32px);
}
.invoice-detail-dialog .el-dialog__body {
max-height: 70vh;
overflow-y: auto;
padding: 12px 24px;
}
.invoice-detail-dialog .el-form-item__label {
line-height: 22px;
padding-bottom: 6px;
}
</style>
<div id="app" v-cloak>
@@ -266,7 +281,7 @@ layout("/layouts/platform.html"){
</el-descriptions-item>
<el-descriptions-item label="报销金额" v-if="isManualReimburseAmount">
<el-form-item label="报销金额" prop="money">
<el-input v-model="formData.money" placeholder="请输入报销金额,最多两位小数"></el-input>
<el-input :value="formData.money" @input="updateReimburseAmount" placeholder="请输入报销金额,最多两位小数"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="所属协会"
@@ -605,11 +620,11 @@ layout("/layouts/platform.html"){
<div>
<div class="invoice-toolbar-title">发票明细列表</div>
<div class="invoice-summary-tip">
{{ isManualReimburseAmount ? '发票选填;有发票时报销金额不得超过发票总额,无发票仍可提交。' : '发票选填;无发票时按支付信息汇总报销金额。' }}上传后须通过验真,每条明细对应一张发票图片或单页 PDF。
提交时至少上传一张发票,上传后须通过验真。每条明细对应一张发票图片或单页 PDF。
</div>
</div>
<div>
<el-tag type="info">发票选填,上传须验真</el-tag>
<el-tag type="info">提交时必填</el-tag>
<el-button @click="openInvoiceDialog('add')" plain type="primary"
style="margin-left: 12px">新增发票
</el-button>
@@ -677,10 +692,11 @@ layout("/layouts/platform.html"){
:title="invoiceDialogMode === 'edit' ? '编辑发票明细' : '新增发票明细'"
:visible.sync="invoiceDialogVisible"
append-to-body
width="820px">
<el-form :model="detailForm" :rules="detailRules" label-position="right"
label-suffix=":" label-width="120px" ref="invoiceDetailForm">
<el-row :gutter="30">
custom-class="invoice-detail-dialog"
top="5vh"
width="1000px">
<el-form :model="detailForm" :rules="detailRules" label-position="top" ref="invoiceDetailForm">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="发票文件" prop="invoiceFiles">
<file-upload
@@ -700,41 +716,41 @@ layout("/layouts/platform.html"){
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="发票号码" prop="invoiceNo">
<el-input placeholder="验真后自动回填" v-model="detailForm.invoiceNo" readonly></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="开票日期">
<el-input placeholder="识别后自动回填" v-model="detailForm.invoiceDate" readonly></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="8">
<el-form-item label="价税合计" prop="invoiceAmount">
<el-input-number :controls="false" :min="0" :precision="2"
placeholder="验真后自动回填" style="width: 100%"
v-model="detailForm.invoiceAmount" disabled></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="8">
<el-form-item label="税前金额">
<el-input-number :controls="false" :precision="2" style="width: 100%"
v-model="detailForm.amountWithoutTax" disabled></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="8">
<el-form-item label="税额">
<el-input-number :controls="false" :precision="2" style="width: 100%"
v-model="detailForm.taxAmount" disabled></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="销售方信息名称" prop="sellerName">
<el-input placeholder="验真后自动回填" v-model="detailForm.sellerName" readonly></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="项目名称" prop="itemName">
<el-input placeholder="验真后自动回填" v-model="detailForm.itemName" readonly></el-input>
</el-form-item>
@@ -1647,7 +1663,10 @@ layout("/layouts/platform.html"){
return false
}
const invoiceDetails = this.formData.invoiceDetails || []
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
if (!invoiceDetails.length) {
this.$message.warning("提交前请至少上传一张发票")
return false
}
for (let i = 0; i < invoiceDetails.length; i++) {
const currentDetail = invoiceDetails[i]
if (!currentDetail.invoiceFiles || currentDetail.invoiceFiles.length === 0) {
@@ -1710,6 +1729,11 @@ layout("/layouts/platform.html"){
this.syncLegacyPaymentFields(submitData.paymentDetails, submitData)
return submitData
},
updateReimburseAmount(value) {
this.$set(this.formData, "money", value)
const details = this.formData.paymentDetails || []
if (details.length === 1) this.$set(details[0], "money", value)
},
// 金额输入允许编辑中的空值和末尾小数点;非法字符或超出两位小数时恢复原值,避免改变金额含义。
inputPaymentMoney(row, value) {
const previous = row.money
@@ -1831,18 +1855,8 @@ layout("/layouts/platform.html"){
// 保存
onSave() {
if (this.formLoading || this.recognizingInvoice) return
if (!this.validateInvoiceDetailsBeforeSubmit()) {
this.$set(this, "activeTabName", "invoice")
return
}
this.rebuildInvoiceSummary()
this.normalizeFundBalance()
const paymentDetails = this.preparePaymentDetailsForSubmit()
// 保存草稿也必须填写完整支付信息,避免非法金额和空明细入库。
if (!this.validatePaymentDetailsBeforeSubmit(paymentDetails)) {
this.$set(this, "activeTabName", "invoice")
return
}
this.$confirm("您确定保存吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
@@ -1850,7 +1864,7 @@ layout("/layouts/platform.html"){
}).then(() => {
if (this.formLoading) return
this.$set(this, "formLoading", true)
this.$axios.post("/platform/unionReimburse/apply/save", {data: JSON.stringify(this.buildSubmitFormData(paymentDetails))}).then(res => {
this.$axios.post("/platform/unionReimburse/apply/save", {data: JSON.stringify(this.buildSubmitFormData())}).then(res => {
if (res.code === 0) {
this.$message.success(res.msg)
commonUtil.pjaxPush("/platform/unionReimburse/mine/index")
@@ -1,8 +1,8 @@
const unionReimburseInfo = {
template: /*language=HTML*/ `
<div>
<div class="process-title">
申请信息
<div class="process-title" style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
<span>申请信息</span>
<el-link type="primary" @click="openChart">点击查看流程图</el-link>
</div>
@@ -682,7 +682,8 @@ layout("/layouts/platform_h5.html"){
></van-field>
<van-field v-if="isManualReimburseAmount"
label="报销金额" name="money"
v-model="formData.money"
:model-value="formData.money"
@update:model-value="updateReimburseAmount"
placeholder="请输入报销金额,最多两位小数"
:rules="[{ required: true, message: '请填写报销金额' }, { validator: () => !getReimburseAmountMessage(), message: getReimburseAmountMessage }]"
required></van-field>
@@ -773,8 +774,8 @@ layout("/layouts/platform_h5.html"){
</van-action-sheet>
</van-cell-group>
<van-cell-group class="form-section" v-if="formData.reimburseProject">
<div class="form-section__title">电子发票(选填)</div>
<div class="invoice-tip">{{ isManualReimburseAmount ? '有发票时报销金额不得超过发票总额,无发票仍可提交;上传的发票仍须通过验真。' : '无发票时按支付信息汇总报销金额;上传的发票仍须通过验真。' }}</div>
<div class="form-section__title">电子发票(提交时必填)</div>
<div class="invoice-tip">提交时至少上传一张发票,上传后须通过验真。</div>
<van-field label="报销金额"
v-model="formData.money"
:placeholder="isManualReimburseAmount ? '请在报销信息填写报销金额' : '有发票按发票汇总,无发票按支付信息汇总'"
@@ -1942,7 +1943,10 @@ layout("/layouts/platform_h5.html"){
return false
}
const invoiceDetails = this.formData.invoiceDetails || []
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
if (!invoiceDetails.length) {
this.$toast.fail("提交前请至少上传一张发票")
return false
}
for (let i = 0; i < invoiceDetails.length; i++) {
const currentDetail = invoiceDetails[i]
if (!currentDetail.invoiceFiles || currentDetail.invoiceFiles.length === 0) {
@@ -2190,6 +2194,11 @@ layout("/layouts/platform_h5.html"){
this.syncLegacyPaymentFields(submitData.paymentDetails, submitData)
return submitData
},
updateReimburseAmount(value) {
this.$set(this.formData, "money", value)
const details = this.formData.paymentDetails || []
if (details.length === 1) this.$set(details[0], "money", value)
},
// 金额输入允许编辑中的空值和末尾小数点;非法字符或超出两位小数时恢复原值,避免改变金额含义。
inputPaymentMoney(row, value) {
const previous = row.money
@@ -2323,17 +2332,11 @@ layout("/layouts/platform_h5.html"){
},
onSave() {
if (this.formLoading || this.recognizingInvoice) return
if (!this.validateInvoiceDetailsBeforeSubmit()) return
this.normalizeContactFields()
this.normalizeCondolenceDateFields()
this.rebuildInvoiceSummary()
const paymentDetails = this.preparePaymentDetailsForSubmit()
// 保存草稿也必须填写完整支付信息,避免非法金额和空明细入库。
if (!this.validatePaymentDetailsBeforeSubmit(paymentDetails)) {
return
}
this.$set(this, "formLoading", true)
this.$axios.post("/platform/unionReimburse/apply/save", { data: JSON.stringify(this.buildSubmitFormData(paymentDetails)) })
this.$axios.post("/platform/unionReimburse/apply/save", { data: JSON.stringify(this.buildSubmitFormData()) })
.then((res) => {
this.$toast.clear()
if (res.code === 0) {