This commit is contained in:
2026-07-14 16:24:26 +08:00
parent fe250baef8
commit c7503cd4cc
32 changed files with 890 additions and 63 deletions
@@ -209,7 +209,7 @@ public class TourLineController {
return Result.error("创建年度不能为空");
}
if (StrUtil.isBlank(line.getTravelAgencyId())) {
return Result.error("旅行社名称不能为空");
return Result.error("服务单位名称不能为空");
}
if (StrUtil.isBlank(line.getLineName())) {
return Result.error("线路名称不能为空");
@@ -466,7 +466,13 @@ public class TourMySignupController {
ledger.setSignupTime(defaultIfBlank(oldLedger.getSignupTime(), ledger.getSignupTime()));
fillStaffInfo(ledger);
tourLedgerService.updateIgnoreNull(ledger);
// 修改报名也重新锁定并校验当前分工会名额,兼容人员所属分工会发生变化的场景。
String quotaMessage = tourLedgerService.saveWithUnionSignupQuota(
ledger, matter.getSettingId(), matter.getId(), SecurityUtil.getUserId(), true);
if (StrUtil.isNotBlank(quotaMessage)) {
return Result.error(quotaMessage);
}
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId()));
tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId()));
if (Lang.isNotEmpty(familyList)) {
@@ -69,6 +69,8 @@ public class TourSettingController {
Cnd lotCnd = Cnd.NEW();
lotCnd.desc(TourSettingLot::getLotValue);
tourSettingService.dao().fetchLinks(tourSetting, "lots", lotCnd);
// 编辑时同时返回分工会人员数量,确保页面按当前组织和人员总数完整回显。
tourSetting.setUnionQuotas(tourSettingService.listUnionQuotaRows(id));
return Result.success(tourSetting);
}
@@ -89,15 +91,30 @@ public class TourSettingController {
Cnd lotCnd = Cnd.NEW();
lotCnd.desc(TourSettingLot::getLotValue);
tourSettingService.dao().fetchLinks(tourSetting, "lots", lotCnd);
// 延用上一年配置时带出原分配数量,前端会清除子表ID后作为新配置提交。
tourSetting.setUnionQuotas(tourSettingService.listUnionQuotaRows(tourSetting.getId()));
return Result.success(tourSetting);
}
/**
* 查询人员分配页签所需的全部分工会、当前人员总数和已保存人员数量。
*
* @param settingId 疗休养配置ID,新增时为空
* @return 分工会人员分配行
*/
@At
@SaCheckPermission("tour.setting")
public Result unionQuotaRows(String settingId) {
return Result.success(tourSettingService.listUnionQuotaRows(settingId));
}
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("tour.setting")
@SLog(type = "tour", tag = "疗休养设置", msg = "保存疗休养配置")
public Result doSubmit(TourSetting tourSetting,
@Param(value = "lots") String lots,
@Param(value = "unionQuotas") String unionQuotas,
@Param(value = "lotDeleteList") String[] lotDeleteList) {
Result checkResult = check(tourSetting);
if (checkResult != null) {
@@ -113,6 +130,14 @@ public class TourSettingController {
return Result.error("同年度下配置名称已存在");
}
// 后端再次校验分配总量,避免绕过页面直接提交超出出行人数指标的数据。
int travelPeopleQuota = tourSetting.getTravelPeopleQuota() == null
? 0 : tourSetting.getTravelPeopleQuota();
String quotaLimitMessage = tourSettingService.checkUnionQuotaLimit(unionQuotas, travelPeopleQuota);
if (StrUtil.isNotBlank(quotaLimitMessage)) {
return Result.error(quotaLimitMessage);
}
// 布尔值给默认值,避免前端未传时出现空状态。
if (tourSetting.getEnabled() == null) {
tourSetting.setEnabled(true);
@@ -148,6 +173,7 @@ public class TourSettingController {
} else {
tourSettingService.insertWith(tourSetting, "lots");
}
tourSettingService.saveUnionQuotas(tourSetting.getId(), unionQuotas);
} else {
// 编辑时先处理页面删除的标段,再保存配置和当前标段行。
if (Lang.isNotEmpty(lotDeleteList)) {
@@ -155,6 +181,7 @@ public class TourSettingController {
}
tourSettingService.updateIgnoreNull(tourSetting);
saveLots(tourSetting);
tourSettingService.saveUnionQuotas(tourSetting.getId(), unionQuotas);
}
return Result.success();
}
@@ -168,6 +195,8 @@ public class TourSettingController {
return Result.error("参数错误");
}
tourSettingService.dao().clear(TourSettingLot.class, Cnd.where(TourSettingLot::getSettingId, "=", id));
// 配置删除时同步清理人员分配,避免遗留不可达的子表数据。
tourSettingService.clearUnionQuotas(id);
tourSettingService.delete(id);
return Result.success();
}
@@ -205,6 +234,9 @@ public class TourSettingController {
if (StrUtil.isBlank(tourSetting.getConfigName())) {
return Result.error("配置名称不能为空");
}
if (tourSetting.getTravelPeopleQuota() == null || tourSetting.getTravelPeopleQuota() < 0) {
return Result.error("出行人数指标必须为非负整数");
}
if (tourSetting.getMinGroupPeople() != null && tourSetting.getMaxGroupPeople() != null
&& tourSetting.getMinGroupPeople() > tourSetting.getMaxGroupPeople()) {
return Result.error("最少成团人数不能大于最多成团人数");
@@ -699,12 +699,16 @@ public class TourSignupController {
ledger.setOverCostReimbursed(Boolean.TRUE.equals(ledger.getOverCostReimbursed()));
fillStaffInfo(ledger);
// PC和移动端共用本接口;在写入台账前锁定分工会名额,防止并发报名超过人员分配数量。
String quotaMessage = tourLedgerService.saveWithUnionSignupQuota(
ledger, matter.getSettingId(), matter.getId(), SecurityUtil.getUserId(), update);
if (StrUtil.isNotBlank(quotaMessage)) {
return Result.error(quotaMessage);
}
if (update) {
tourLedgerService.updateIgnoreNull(ledger);
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId()));
tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId()));
} else {
tourLedgerService.insert(ledger);
}
if (Lang.isNotEmpty(familyList)) {
familyList.forEach(item -> {
@@ -58,13 +58,13 @@ public class TourTravelAgencyController {
return Result.error("参数错误");
}
TourTravelAgency agency = travelAgencyService.fetch(id);
return agency == null ? Result.error("旅行社不存在") : Result.success(agency);
return agency == null ? Result.error("服务单位不存在") : Result.success(agency);
}
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("tour.travelAgency")
@SLog(type = "tour", tag = "旅行社管理", msg = "保存旅行社信息")
@SLog(type = "tour", tag = "服务单位管理", msg = "保存服务单位信息")
public Result doSubmit(TourTravelAgency agency) {
Result checkResult = check(agency);
if (checkResult != null) {
@@ -77,7 +77,7 @@ public class TourTravelAgencyController {
sameCodeCnd.and(TourTravelAgency::getId, "<>", agency.getId());
}
if (travelAgencyService.count(sameCodeCnd) > 0) {
return Result.error("同年度下旅行社编号已存在");
return Result.error("同年度下服务单位编号已存在");
}
if (agency.getEnabled() == null) {
agency.setEnabled(true);
@@ -93,7 +93,7 @@ public class TourTravelAgencyController {
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("tour.travelAgency")
@SLog(type = "tour", tag = "旅行社管理", msg = "删除旅行社信息")
@SLog(type = "tour", tag = "服务单位管理", msg = "删除服务单位信息")
public Result doDelete(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("参数错误");
@@ -110,10 +110,10 @@ public class TourTravelAgencyController {
return Result.error("年度不能为空");
}
if (StrUtil.isBlank(agency.getAgencyName())) {
return Result.error("旅行社名称不能为空");
return Result.error("服务单位名称不能为空");
}
if (StrUtil.isBlank(agency.getAgencyCode())) {
return Result.error("旅行社编号不能为空");
return Result.error("服务单位编号不能为空");
}
if (StrUtil.isBlank(agency.getContactName())) {
return Result.error("联系人不能为空");
@@ -102,12 +102,12 @@ public class TourLedger extends BaseModel implements Serializable {
private String hotelName;
@Column
@Comment("旅行社ID")
@Comment("服务单位ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String travelAgencyId;
@Column
@Comment("旅行社")
@Comment("服务单位")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String travelAgencyName;
@@ -42,7 +42,7 @@ public class TourLine extends BaseModel implements Serializable {
private String lineName;
@Column
@Comment("旅行社ID")
@Comment("服务单位ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String travelAgencyId;
@@ -43,6 +43,12 @@ public class TourSetting extends BaseModel implements Serializable {
@ColDefine(type = ColType.VARCHAR, width = 50)
private String tourType;
@Column
@Comment("出行人数指标")
@ColDefine(type = ColType.INT)
@Default("0")
private Integer travelPeopleQuota;
@Column
@Comment("可参加人员范围ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -122,11 +128,16 @@ public class TourSetting extends BaseModel implements Serializable {
private Boolean enabled;
/**
* 标段管理沿用老疗休养配置的子表设计,后续线路、旅行社等模块可通过标段ID继续关联。
* 标段管理沿用老疗休养配置的子表设计,后续线路、服务单位等模块可通过标段ID继续关联。
*/
@Many(field = "settingId")
private List<TourSettingLot> lots;
/**
* 分工会人员分配,仅用于配置弹窗回显与提交,不作为 tour_setting 表字段保存。
*/
private List<TourSettingUnionQuota> unionQuotas;
@Column
@Comment("服务须知")
@ColDefine(type = ColType.TEXT)
@@ -0,0 +1,60 @@
package com.budwk.app.zhgh.dayofficework.tour.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Default;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableMeta;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.io.Serializable;
/**
* 疗休养配置下的分工会人员数量分配。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Table("tour_setting_union_quota")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("疗休养配置分工会人员分配")
public class TourSettingUnionQuota extends BaseModel implements Serializable {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属疗休养配置ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String settingId;
@Column
@Comment("分工会ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String unionId;
@Column
@Comment("分工会名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String unionName;
@Column
@Comment("人员数量")
@ColDefine(type = ColType.INT)
@Default("0")
private Integer peopleQuota;
/**
* 当前分工会人员总数,仅用于页面分配参考,不写入数据库。
*/
private Integer personCount;
}
@@ -9,14 +9,14 @@ import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.io.Serializable;
/**
* 旅行社管理。
* 先维护疗休养线路创建会复用的旅行社基础信息,后续线路模块可通过旅行社ID关联。
* 服务单位管理。
* 先维护疗休养线路创建会复用的服务单位基础信息,后续线路模块可通过服务单位ID关联。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Table("tour_travel_agency")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("普惠疗休养旅行社")
@Comment("普惠疗休养服务单位")
public class TourTravelAgency extends BaseModel implements Serializable {
@Column
@@ -32,12 +32,12 @@ public class TourTravelAgency extends BaseModel implements Serializable {
private Integer year;
@Column
@Comment("旅行社编号")
@Comment("服务单位编号")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String agencyCode;
@Column
@Comment("旅行社名称")
@Comment("服务单位名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String agencyName;
@@ -3,5 +3,24 @@ package com.budwk.app.zhgh.dayofficework.tour.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
/**
* 疗休养报名台账服务,统一处理报名记录及并发名额校验。
*/
public interface TourLedgerService extends BaseService<TourLedger> {
/**
* 在同一事务中锁定分工会名额、校验并发容量并写入报名台账。
*
* @param ledger 待新增或修改的报名台账
* @param settingId 疗休养配置ID
* @param matterId 当前报名事项ID
* @param userId 当前登录用户ID
* @param update true 表示修改已有报名,false 表示新增报名
* @return 保存成功返回空字符串,否则返回业务提示
*/
String saveWithUnionSignupQuota(TourLedger ledger,
String settingId,
String matterId,
String userId,
boolean update);
}
@@ -2,6 +2,44 @@ package com.budwk.app.zhgh.dayofficework.tour.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSettingUnionQuota;
import java.util.List;
/**
* 疗休养配置服务,统一处理配置及分工会人员数量分配业务。
*/
public interface TourSettingService extends BaseService<TourSetting> {
/**
* 查询全部分工会在指定配置下的人员数量,并实时补充分工会人员总数。
*
* @param settingId 疗休养配置ID,新增配置时允许为空
* @return 按分工会编码排序的人员分配行
*/
List<TourSettingUnionQuota> listUnionQuotaRows(String settingId);
/**
* 保存指定配置的分工会人员数量,提交内容为 JSON 数组字符串。
*
* @param settingId 疗休养配置ID
* @param unionQuotas 分工会人员分配 JSON
*/
void saveUnionQuotas(String settingId, String unionQuotas);
/**
* 校验各分工会人员数量是否均为非负整数且合计不超过出行人数指标。
*
* @param unionQuotas 分工会人员分配 JSON
* @param travelPeopleQuota 出行人数指标
* @return 校验通过返回空字符串,否则返回业务提示
*/
String checkUnionQuotaLimit(String unionQuotas, int travelPeopleQuota);
/**
* 清除指定配置下的全部分工会人员分配。
*
* @param settingId 疗休养配置ID
*/
void clearUnionQuotas(String settingId);
}
@@ -1,15 +1,136 @@
package com.budwk.app.zhgh.dayofficework.tour.service.impl;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
/**
* 疗休养报名台账服务实现。
*/
@IocBean(args = {"refer:dao"})
public class TourLedgerServiceImpl extends BaseServiceImpl<TourLedger> implements TourLedgerService {
public TourLedgerServiceImpl(Dao dao) {
super(dao);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public String saveWithUnionSignupQuota(TourLedger ledger,
String settingId,
String matterId,
String userId,
boolean update) {
if (ledger == null) {
return "报名数据不能为空";
}
String quotaMessage = lockAndCheckUnionSignupQuota(
settingId, matterId, userId, update ? ledger.getId() : null);
if (StrUtil.isNotBlank(quotaMessage)) {
return quotaMessage;
}
// 名额校验通过后立即在同一事务内写入台账,事务提交前始终持有分工会名额行锁。
if (update) {
updateIgnoreNull(ledger);
} else {
insert(ledger);
}
return "";
}
/**
* 锁定当前配置和登录人所属分工会的名额行,并校验报名后是否超过人员数量。
*/
private String lockAndCheckUnionSignupQuota(String settingId,
String matterId,
String userId,
String excludeLedgerId) {
if (StrUtil.isBlank(settingId) || StrUtil.isBlank(matterId)) {
return "疗休养配置或报名事项不存在";
}
// 分工会信息从用户视图读取,不接受报名表单传入的工会ID,防止跨工会占用名额。
View_user user = dao().fetch(View_user.class, Cnd.where(View_user::getId, "=", userId));
if (user == null || StrUtil.isBlank(user.getUnionId()) || StrUtil.isBlank(user.getLoginname())) {
return "未查询到您所属的分工会,不能报名";
}
// FOR UPDATE 串行化同一配置、同一分工会的抢名额请求,不同分工会之间互不阻塞。
Sql quotaSql = Sqls.create("""
SELECT peopleQuota
FROM tour_setting_union_quota
WHERE settingId = @settingId
AND unionId = @unionId
AND delFlag = 0
LIMIT 1
FOR UPDATE
""");
quotaSql.setParam("settingId", settingId);
quotaSql.setParam("unionId", user.getUnionId());
quotaSql.setCallback(Sqls.callback.map());
dao().execute(quotaSql);
NutMap quotaRow = quotaSql.getObject(NutMap.class);
if (quotaRow == null || quotaRow.isEmpty()) {
return "您所在分工会未分配报名名额";
}
int peopleQuota = quotaRow.getInt("peopleQuota", 0);
if (peopleQuota <= 0) {
return "您所在分工会的报名名额已满";
}
// 取得名额锁后再次检查同事项重复报名,关闭双击或并发请求绕过前置查询的时间窗口。
String excludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND id <> @excludeLedgerId";
Sql duplicateSql = Sqls.create("""
SELECT COUNT(1)
FROM tour_ledger
WHERE delFlag = 0
AND matterId = @matterId
AND jobNo = @jobNo
""" + excludeSql);
duplicateSql.setParam("matterId", matterId);
duplicateSql.setParam("jobNo", user.getLoginname());
if (StrUtil.isNotBlank(excludeLedgerId)) {
duplicateSql.setParam("excludeLedgerId", excludeLedgerId);
}
duplicateSql.setCallback(Sqls.callback.integer());
dao().execute(duplicateSql);
if (duplicateSql.getInt() > 0) {
return "您已报名当前出行时段,请勿重复提交";
}
// 名额按配置统计,覆盖该配置下的全部事项;待审核报名也占用名额,取消后因台账删除自动释放。
String countExcludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND ledger.id <> @excludeLedgerId";
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM tour_ledger ledger
INNER JOIN tour_matter matter
ON matter.id = ledger.matterId
AND matter.delFlag = 0
WHERE ledger.delFlag = 0
AND matter.settingId = @settingId
AND ledger.unionId = @unionId
""" + countExcludeSql);
countSql.setParam("settingId", settingId);
countSql.setParam("unionId", user.getUnionId());
if (StrUtil.isNotBlank(excludeLedgerId)) {
countSql.setParam("excludeLedgerId", excludeLedgerId);
}
countSql.setCallback(Sqls.callback.integer());
dao().execute(countSql);
int signupCount = countSql.getInt();
if (signupCount + 1 > peopleQuota) {
return "您所在分工会的报名名额已满(已报名 " + signupCount
+ " 人,分配 " + peopleQuota + " 人)";
}
return "";
}
}
@@ -1,15 +1,172 @@
package com.budwk.app.zhgh.dayofficework.tour.service.impl;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSettingUnionQuota;
import com.budwk.app.zhgh.dayofficework.tour.service.TourSettingService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Lang;
import org.nutz.lang.util.NutMap;
import java.util.List;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 疗休养配置服务实现,负责分工会人员分配的查询、校验和持久化。
*/
@IocBean(args = {"refer:dao"})
public class TourSettingServiceImpl extends BaseServiceImpl<TourSetting> implements TourSettingService {
public TourSettingServiceImpl(Dao dao) {
super(dao);
}
@Override
public List<TourSettingUnionQuota> listUnionQuotaRows(String settingId) {
// 一次查询分工会、已保存数量和实时人员总数,避免逐个工会统计产生 N+1 查询。
Sql sql = Sqls.create("""
SELECT
quota.id AS id,
@settingId AS settingId,
un.id AS unionId,
un.name AS unionName,
COALESCE(quota.peopleQuota, 0) AS peopleQuota,
COALESCE(user_count.personCount, 0) AS personCount
FROM sys_union un
LEFT JOIN tour_setting_union_quota quota
ON quota.unionId = un.id
AND quota.settingId = @settingId
AND COALESCE(quota.delFlag, 0) = 0
LEFT JOIN (
SELECT unionId, COUNT(1) AS personCount
FROM vw_user
GROUP BY unionId
) user_count ON user_count.unionId = un.id
WHERE COALESCE(un.delFlag, 0) = 0
ORDER BY un.unionCode ASC
""");
sql.setParam("settingId", StrUtil.blankToDefault(settingId, ""));
List<NutMap> rows = listMap(sql);
return rows.stream().map(row -> {
TourSettingUnionQuota quota = new TourSettingUnionQuota();
quota.setId(row.getString("id"));
quota.setSettingId(row.getString("settingId"));
quota.setUnionId(row.getString("unionId"));
quota.setUnionName(row.getString("unionName"));
quota.setPeopleQuota(toNonNegativeInteger(row.getInt("peopleQuota")));
quota.setPersonCount(toNonNegativeInteger(row.getInt("personCount")));
return quota;
}).collect(Collectors.toList());
}
@Override
public void saveUnionQuotas(String settingId, String unionQuotas) {
if (StrUtil.isBlank(settingId)) {
return;
}
clearUnionQuotas(settingId);
if (StrUtil.isBlank(unionQuotas)) {
return;
}
List<TourSettingUnionQuota> quotaList = Json.fromJsonAsList(TourSettingUnionQuota.class, unionQuotas);
if (Lang.isEmpty(quotaList)) {
return;
}
// 工会名称以当前组织表为准,防止前端篡改或历史名称继续写入。
Map<String, Sys_union> unionMap = dao().query(Sys_union.class,
Cnd.where(Sys_union::getDelFlag, "=", false)).stream()
.collect(Collectors.toMap(Sys_union::getId, item -> item, (first, second) -> first));
List<TourSettingUnionQuota> saveList = quotaList.stream()
.filter(item -> item != null
&& StrUtil.isNotBlank(item.getUnionId())
&& unionMap.containsKey(item.getUnionId()))
.map(item -> normalizeUnionQuota(settingId, item, unionMap))
.filter(item -> item.getPeopleQuota() > 0)
.collect(Collectors.toList());
if (Lang.isNotEmpty(saveList)) {
dao().insert(saveList);
}
}
@Override
public String checkUnionQuotaLimit(String unionQuotas, int travelPeopleQuota) {
if (StrUtil.isBlank(unionQuotas)) {
return "";
}
final List<TourSettingUnionQuota> quotaList;
try {
quotaList = Json.fromJsonAsList(TourSettingUnionQuota.class, unionQuotas);
} catch (Exception e) {
return "人员分配数据格式不正确";
}
if (Lang.isEmpty(quotaList)) {
return "";
}
long total = 0L;
Set<String> unionIds = new HashSet<>();
Set<String> validUnionIds = dao().query(Sys_union.class,
Cnd.where(Sys_union::getDelFlag, "=", false)).stream()
.map(Sys_union::getId)
.collect(Collectors.toSet());
for (int index = 0; index < quotaList.size(); index++) {
TourSettingUnionQuota quota = quotaList.get(index);
if (quota == null || quota.getPeopleQuota() == null) {
continue;
}
// 工会ID必须来自当前组织表且不能重复,防止伪造行绕过总量和组织范围校验。
if (StrUtil.isBlank(quota.getUnionId())
|| !validUnionIds.contains(quota.getUnionId())
|| !unionIds.add(quota.getUnionId())) {
return "" + (index + 1) + "行分工会信息无效或重复";
}
if (quota.getPeopleQuota() < 0) {
return "" + (index + 1) + "行人员数量必须为非负整数";
}
total += quota.getPeopleQuota();
}
if (total > travelPeopleQuota) {
return "当前人员数量合计 " + total + ",不能超过出行人数指标 " + travelPeopleQuota;
}
return "";
}
@Override
public void clearUnionQuotas(String settingId) {
if (StrUtil.isNotBlank(settingId)) {
dao().clear(TourSettingUnionQuota.class,
Cnd.where(TourSettingUnionQuota::getSettingId, "=", settingId));
}
}
/**
* 将前端分配行转换为可信的持久化对象,只接受当前组织表中的工会名称。
*/
private TourSettingUnionQuota normalizeUnionQuota(String settingId,
TourSettingUnionQuota item,
Map<String, Sys_union> unionMap) {
Sys_union union = unionMap.get(item.getUnionId());
TourSettingUnionQuota quota = new TourSettingUnionQuota();
quota.setSettingId(settingId);
quota.setUnionId(item.getUnionId());
quota.setUnionName(union.getName());
quota.setPeopleQuota(toNonNegativeInteger(item.getPeopleQuota()));
return quota;
}
/**
* 将空值或负数统一转换为零,保证页面统计和数据库保存使用相同口径。
*/
private Integer toNonNegativeInteger(Integer value) {
return value == null || value < 0 ? 0 : value;
}
}
@@ -40,7 +40,7 @@ WHERE p.permission = 'tour'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'tour.setting') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'b7c63f0c73a44a9a8c4f3d3bb1a10003', p.id, CONCAT(p.path, '0002'), '旅行社管理', 'Travel Agency', 'menu', '/platform/tour/travelAgency', 'data-pjax', '', 1, 0, 'tour.travelAgency', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'l', 0, 0
SELECT 'b7c63f0c73a44a9a8c4f3d3bb1a10003', p.id, CONCAT(p.path, '0002'), '服务单位管理', 'Service Unit', 'menu', '/platform/tour/travelAgency', 'data-pjax', '', 1, 0, 'tour.travelAgency', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'f', 0, 0
FROM sys_menu p
WHERE p.permission = 'tour'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'tour.travelAgency') t);
@@ -0,0 +1,3 @@
-- 疗休养配置增加出行人数指标,历史配置默认按 0 处理。
ALTER TABLE `tour_setting`
ADD COLUMN `travelPeopleQuota` int DEFAULT 0 COMMENT '出行人数指标' AFTER `tourType`;
+25 -6
View File
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS `tour_setting` (
`year` int DEFAULT NULL COMMENT '年度',
`configName` varchar(100) DEFAULT NULL COMMENT '疗休养配置名称',
`tourType` varchar(50) DEFAULT NULL COMMENT '疗休养类型',
`travelPeopleQuota` int DEFAULT 0 COMMENT '出行人数指标',
`activityGroupId` varchar(32) DEFAULT NULL COMMENT '可参加人员范围ID',
`sortNo` int DEFAULT NULL COMMENT '排序编号',
`minGroupPeople` int DEFAULT NULL COMMENT '最少成团人数',
@@ -33,7 +34,7 @@ CREATE TABLE IF NOT EXISTS `tour_setting` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养配置';
-- 疗休养配置标段表。
-- 标段先挂在配置上维护,后续线路、旅行社、报名等模块可继续通过 lotId 做业务关联。
-- 标段先挂在配置上维护,后续线路、服务单位、报名等模块可继续通过 lotId 做业务关联。
CREATE TABLE IF NOT EXISTS `tour_setting_lot` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`settingId` varchar(32) DEFAULT NULL COMMENT '所属配置ID',
@@ -51,12 +52,30 @@ CREATE TABLE IF NOT EXISTS `tour_setting_lot` (
KEY `idx_tour_setting_lot_value` (`lotValue`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养配置标段';
-- 旅行社管理表。
-- 疗休养配置分工会人员分配表。
CREATE TABLE IF NOT EXISTS `tour_setting_union_quota` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`settingId` varchar(32) DEFAULT NULL COMMENT '所属疗休养配置ID',
`unionId` varchar(32) DEFAULT NULL COMMENT '分工会ID',
`unionName` varchar(100) DEFAULT NULL COMMENT '分工会名称',
`peopleQuota` int DEFAULT 0 COMMENT '人员数量',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
`updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人',
`updatedAt` bigint DEFAULT NULL COMMENT '修改时间',
`delFlag` tinyint(1) DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tour_setting_union_quota_setting_union` (`settingId`, `unionId`),
KEY `idx_tour_setting_union_quota_setting` (`settingId`),
KEY `idx_tour_setting_union_quota_union` (`unionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疗休养配置分工会人员分配';
-- 服务单位管理表。
CREATE TABLE IF NOT EXISTS `tour_travel_agency` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`year` int DEFAULT NULL COMMENT '年度',
`agencyCode` varchar(50) DEFAULT NULL COMMENT '旅行社编号',
`agencyName` varchar(100) DEFAULT NULL COMMENT '旅行社名称',
`agencyCode` varchar(50) DEFAULT NULL COMMENT '服务单位编号',
`agencyName` varchar(100) DEFAULT NULL COMMENT '服务单位名称',
`contactName` varchar(30) DEFAULT NULL COMMENT '联系人',
`contactPhone` varchar(30) DEFAULT NULL COMMENT '联系人手机',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
@@ -72,7 +91,7 @@ CREATE TABLE IF NOT EXISTS `tour_travel_agency` (
UNIQUE KEY `uk_tour_travel_agency_year_code` (`year`, `agencyCode`),
KEY `idx_tour_travel_agency_name` (`agencyName`),
KEY `idx_tour_travel_agency_contact` (`contactName`, `contactPhone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养旅行社';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养服务单位';
-- 线路管理表。
CREATE TABLE IF NOT EXISTS `tour_line` (
@@ -80,7 +99,7 @@ CREATE TABLE IF NOT EXISTS `tour_line` (
`year` int DEFAULT NULL COMMENT '创建年度',
`lineCode` varchar(50) DEFAULT NULL COMMENT '线路编号',
`lineName` varchar(100) DEFAULT NULL COMMENT '线路名称',
`travelAgencyId` varchar(32) DEFAULT NULL COMMENT '旅行社ID',
`travelAgencyId` varchar(32) DEFAULT NULL COMMENT '服务单位ID',
`creatorUserId` varchar(32) DEFAULT NULL COMMENT '创建人ID',
`creatorName` varchar(100) DEFAULT NULL COMMENT '创建人',
`unitId` varchar(32) DEFAULT NULL COMMENT '所在单位ID',
@@ -0,0 +1,17 @@
-- 疗休养配置分工会人员分配表,人员数量合计受配置的出行人数指标约束。
CREATE TABLE IF NOT EXISTS `tour_setting_union_quota` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`settingId` varchar(32) DEFAULT NULL COMMENT '所属疗休养配置ID',
`unionId` varchar(32) DEFAULT NULL COMMENT '分工会ID',
`unionName` varchar(100) DEFAULT NULL COMMENT '分工会名称',
`peopleQuota` int DEFAULT 0 COMMENT '人员数量',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
`updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人',
`updatedAt` bigint DEFAULT NULL COMMENT '修改时间',
`delFlag` tinyint(1) DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tour_setting_union_quota_setting_union` (`settingId`, `unionId`),
KEY `idx_tour_setting_union_quota_setting` (`settingId`),
KEY `idx_tour_setting_union_quota_union` (`unionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疗休养配置分工会人员分配';
@@ -174,7 +174,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="报名线路">{{ detail.lineName || '' }}</el-descriptions-item>
<el-descriptions-item label="线路类型">{{ detail.lineType || '' }}</el-descriptions-item>
<el-descriptions-item label="报名酒店">{{ detail.hotelName || '' }}</el-descriptions-item>
<el-descriptions-item label="报名旅行社">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="报名服务单位">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="出行时间">{{ detail.travelPeriod || '' }}</el-descriptions-item>
<el-descriptions-item v-if="fillBedInfo" label="床型">{{ detail.bedType || '' }}</el-descriptions-item>
<el-descriptions-item v-if="fillBedInfo" label="床位信息">{{ detail.bedInfo || '' }}</el-descriptions-item>
@@ -114,7 +114,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="报名线路">{{ detail.lineName || '' }}</el-descriptions-item>
<el-descriptions-item label="线路类型">{{ detail.lineType || '' }}</el-descriptions-item>
<el-descriptions-item label="报名酒店">{{ detail.hotelName || '' }}</el-descriptions-item>
<el-descriptions-item label="报名旅行社">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="报名服务单位">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="出行时间">{{ detail.travelPeriod || '' }}</el-descriptions-item>
<el-descriptions-item v-if="detailFillBedInfo" label="床型">{{ detail.bedType || '' }}</el-descriptions-item>
<el-descriptions-item v-if="detailFillBedInfo" label="床位信息">{{ detail.bedInfo || '' }}</el-descriptions-item>
@@ -255,7 +255,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="报名旅行社">
<el-form-item label="报名服务单位">
<el-input v-model="signupForm.travelAgencyName" :disabled="isDirectFamilyLine(signupForm)" readonly></el-input>
</el-form-item>
</el-col>
@@ -112,8 +112,8 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="旅行社名称" prop="travelAgencyId">
<el-select v-model="formData.travelAgencyId" clearable filterable placeholder="请选择旅行社" style="width: 100%">
<el-form-item label="服务单位名称" prop="travelAgencyId">
<el-select v-model="formData.travelAgencyId" clearable filterable placeholder="请选择服务单位" style="width: 100%">
<el-option v-for="item in travelAgencyOptions" :key="item.id" :label="item.agencyName" :value="item.id"></el-option>
</el-select>
</el-form-item>
@@ -187,7 +187,7 @@ layout("/layouts/platform.html"){
<text-editor v-model="formData.lineContent"></text-editor>
</el-form-item>
<el-form-item label="移动端缩略图" prop="mobileThumb">
<!--旅行社管理保持一致:移动端缩略图使用单图上传并保存URL。 -->
<!--服务单位管理保持一致:移动端缩略图使用单图上传并保存URL。 -->
<file-upload
style="--upload-width: 200px;--upload-height:108px"
:upload_number="1"
@@ -191,7 +191,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="出行时段">{{ detail.travelPeriod || '' }}</el-descriptions-item>
<el-descriptions-item label="线路类型">{{ detail.lineType || '' }}</el-descriptions-item>
<el-descriptions-item label="报名酒店">{{ detail.hotelName || '' }}</el-descriptions-item>
<el-descriptions-item label="旅行社">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="服务单位">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="是否携带家属">
{{ familyText() }}
</el-descriptions-item>
@@ -83,6 +83,7 @@ layout("/layouts/platform.html"){
:visible.sync="dialogVisible"
:close-on-click-modal="false"
width="64.8%"
custom-class="tour-setting-dialog"
@closed="destroyEditor">
<el-form :model="formData" :rules="formRules" label-width="120px" ref="form">
<div v-if="title === '新建疗休养配置'" class="tour-setting-inherit">
@@ -145,6 +146,11 @@ layout("/layouts/platform.html"){
<el-input-number v-model="formData.maxGroupPeople" :controls="false" :min="0" :precision="0" placeholder="请输入最多成团人数" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="出行人数指标" prop="travelPeopleQuota">
<el-input-number v-model="formData.travelPeopleQuota" :controls="false" :min="0" :precision="0" placeholder="请输入出行人数指标" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="tour-setting-basic-divider"></div>
</el-col>
@@ -267,6 +273,47 @@ layout("/layouts/platform.html"){
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="人员分配" name="unionQuota">
<div class="tour-tab-fill">
<div class="tour-quota-toolbar">
<div class="tour-quota-stat">
<span>出行人数指标</span>
<strong>{{ travelPeopleQuota }}</strong>
</div>
<div class="tour-quota-stat">
<span>分工会总人数</span>
<strong>{{ branchTotalPersonCount }}</strong>
</div>
<el-input-number
v-model="quotaAllocate.ratio"
:controls="false"
:min="0"
:precision="2"
placeholder="比例"
class="tour-quota-input">
</el-input-number>
<el-button type="primary" size="medium" @click="allocateQuotaByRatio">按比例分配</el-button>
</div>
<el-table
:data="formData.unionQuotas"
border
class="vi-table tour-quota-table tour-tab-table"
empty-text="暂无分工会"
height="100%"
show-summary
:summary-method="unionQuotaSummary"
style="width: 100%">
<el-table-column label="序号" type="index" width="70" align="center" header-align="center"></el-table-column>
<el-table-column label="分工会" prop="unionName" min-width="220" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="当前人数" prop="personCount" width="140" align="center" header-align="center"></el-table-column>
<el-table-column label="人员数量" width="180" align="center" header-align="center">
<template slot-scope="{row}">
<el-input-number v-model="row.peopleQuota" :controls="false" :min="0" :precision="0" style="width: 100%"></el-input-number>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="服务须知" name="notice">
<el-form-item prop="serviceNotice" label-width="0">
<text-editor v-model="formData.serviceNotice"></text-editor>
@@ -331,10 +378,14 @@ layout("/layouts/platform.html"){
activityGroupList: [],
formData: {},
lotDeleteList: [],
quotaAllocate: {
ratio: null
},
inheritLoading: false,
formRules: {
year: [{required: true, message: "必填", trigger: ["blur", "change"]}],
configName: [{required: true, message: "必填", trigger: ["blur", "change"]}],
travelPeopleQuota: [{required: true, message: "必填", trigger: ["blur", "change"]}],
minGroupPeople: [{validator: checkGroupPeople, trigger: ["blur", "change"]}],
maxGroupPeople: [{validator: checkGroupPeople, trigger: ["blur", "change"]}],
outProvinceRatioType: [{required: true, message: "必填", trigger: ["blur", "change"]}],
@@ -347,12 +398,117 @@ layout("/layouts/platform.html"){
components: {
"drawer-user-scope": httpVueLoader("/components/module/activity/DrawerUserScope.vue")
},
computed: {
// 出行人数指标统一按非负整数参与页面统计和分配上限校验。
travelPeopleQuota() {
return this.toNonNegativeInteger(this.formData.travelPeopleQuota)
},
// 人员总数使用后端实时统计结果,仅作为按比例分配的计算基数。
branchTotalPersonCount() {
const rows = this.formData.unionQuotas || []
return rows.reduce((sum, row) => sum + this.toNonNegativeInteger(row.personCount), 0)
}
},
watch: {
"formData.travelPeopleQuota": function() {
this.refreshQuotaRatio()
}
},
methods: {
async getActivityGroup() {
const {data} = await this.$axios.post("/platform/activity/basic/scope/getActivityUserScopeGroup")
this.activityGroupList = (data || []).map((item) => {
// 加载分工会人员分配行;新增时传空配置ID,编辑时合并已保存数量。
loadUnionQuotaRows(settingId) {
this.$axios.post(loc() + "/unionQuotaRows", {settingId: settingId || ""}).then((res) => {
if (res.code === 0) {
this.$set(this.formData, "unionQuotas", this.normalizeUnionQuotaRows(res.data || []))
this.refreshQuotaRatio()
} else {
this.$message.warning(res.msg || "查询人员分配失败")
}
})
},
// 将接口和历史数据中的空值规范为非负整数,避免合计结果出现 NaN。
normalizeUnionQuotaRows(rows) {
return (rows || []).map(item => {
return Object.assign({}, item, {
groupId: item.groupId === null || item.groupId === undefined ? "" : String(item.groupId)
peopleQuota: this.toNonNegativeInteger(item.peopleQuota),
personCount: this.toNonNegativeInteger(item.personCount)
})
})
},
toNonNegativeInteger(value) {
const numberValue = parseInt(value, 10)
if (isNaN(numberValue) || numberValue < 0) {
return 0
}
return numberValue
},
toNumber(value) {
const numberValue = parseFloat(value)
return isNaN(numberValue) ? null : numberValue
},
truncateDecimal(value, precision) {
const times = Math.pow(10, precision)
return Math.floor(value * times) / times
},
// 默认比例为出行人数指标除以分工会总人数,并按参考项目保留两位后直接截断。
refreshQuotaRatio() {
if (this.branchTotalPersonCount <= 0 || this.travelPeopleQuota <= 0) {
this.quotaAllocate.ratio = null
return
}
this.quotaAllocate.ratio = this.truncateDecimal(this.travelPeopleQuota / this.branchTotalPersonCount, 2)
},
unionQuotaSummary(param) {
const columns = param.columns || []
const rows = this.formData.unionQuotas || []
return columns.map((column, index) => {
if (index === 0) {
return "合计"
}
if (column.property === "personCount") {
return rows.reduce((sum, row) => sum + this.toNonNegativeInteger(row.personCount), 0)
}
if (column.label === "人员数量") {
return rows.reduce((sum, row) => sum + this.toNonNegativeInteger(row.peopleQuota), 0)
}
return ""
})
},
allocateQuotaByRatio() {
const rows = this.formData.unionQuotas || []
const ratio = this.toNumber(this.quotaAllocate.ratio)
if (!rows.length) {
this.$message.warning("暂无分工会数据")
return
}
if (ratio === null || this.quotaAllocate.ratio === "" || this.quotaAllocate.ratio === undefined) {
this.$message.warning("比例为空,未分配")
return
}
if (ratio < 0) {
this.$message.warning("比例不能为负数")
return
}
// 按比例分配时小数直接舍去,不补余数,保持与参考项目一致。
const nextQuotas = rows.map(row => {
return Math.floor(ratio * this.toNonNegativeInteger(row.personCount))
})
const quotaTotal = nextQuotas.reduce((sum, value) => sum + value, 0)
if (quotaTotal > this.travelPeopleQuota) {
this.$message.warning("当前人员数量合计 " + quotaTotal + ",不能超过出行人数指标 " + this.travelPeopleQuota)
return
}
nextQuotas.forEach((peopleQuota, index) => {
this.$set(rows[index], "peopleQuota", peopleQuota)
})
},
getActivityGroup() {
return this.$axios.post("/platform/activity/basic/scope/getActivityUserScopeGroup").then((res) => {
const data = res.data
this.activityGroupList = (data || []).map((item) => {
return Object.assign({}, item, {
groupId: item.groupId === null || item.groupId === undefined ? "" : String(item.groupId)
})
})
})
},
@@ -378,6 +534,7 @@ layout("/layouts/platform.html"){
year: moment().format("YYYY"),
configName: "",
tourType: "",
travelPeopleQuota: 0,
activityGroupId: "",
sortNo: 0,
minGroupPeople: 0,
@@ -394,6 +551,7 @@ layout("/layouts/platform.html"){
fillBedInfo: true,
enabled: true,
lots: [],
unionQuotas: [],
serviceNotice: ""
}
},
@@ -404,6 +562,7 @@ layout("/layouts/platform.html"){
// 新建时给出默认年度和开关值,减少校工会管理员录入成本。
this.formData = this.defaultFormData()
this.dialogVisible = true
this.loadUnionQuotaRows("")
this.$nextTick(() => this.$refs.form && this.$refs.form.clearValidate())
},
inheritPreviousYearInfo() {
@@ -428,11 +587,19 @@ layout("/layouts/platform.html"){
allowOverReimbursement: !!item.allowOverReimbursement
}
})
// 延用上一年分配数量,但清除原配置和子表ID,保存时生成当前年度数据。
const inheritedUnionQuotas = this.normalizeUnionQuotaRows(previous.unionQuotas || []).map(item => {
return Object.assign({}, item, {
id: "",
settingId: ""
})
})
this.formData = Object.assign(this.defaultFormData(), previous, {
year: String(currentYear),
cycleStartYear: previous.cycleStartYear ? String(previous.cycleStartYear) : "",
cycleEndYear: previous.cycleEndYear ? String(previous.cycleEndYear) : "",
lots: inheritedLots
lots: inheritedLots,
unionQuotas: inheritedUnionQuotas
})
delete this.formData.id
delete this.formData.createdAt
@@ -463,6 +630,7 @@ layout("/layouts/platform.html"){
this.$axios.post(loc() + "/detail", {id: row.id}).then((res) => {
if (res.code === 0) {
this.formData = Object.assign({
travelPeopleQuota: 0,
activityGroupId: "",
outProvinceRatioType: "当年报名人数",
outProvinceFixedPeople: 0,
@@ -474,6 +642,7 @@ layout("/layouts/platform.html"){
fillBedInfo: true,
enabled: true,
lots: [],
unionQuotas: [],
serviceNotice: ""
}, res.data || {})
this.formData.year = this.formData.year ? String(this.formData.year) : ""
@@ -491,6 +660,7 @@ layout("/layouts/platform.html"){
this.formData.lots = (this.formData.lots || []).map(item => Object.assign({}, item, {
allowOverReimbursement: !!item.allowOverReimbursement
}))
this.formData.unionQuotas = this.normalizeUnionQuotaRows(this.formData.unionQuotas || [])
this.dialogVisible = true
this.$nextTick(() => this.$refs.form && this.$refs.form.clearValidate())
} else {
@@ -502,6 +672,7 @@ layout("/layouts/platform.html"){
this.$refs.form.validate((valid) => {
if (!valid) return
if (!this.validateLots()) return
if (!this.validateUnionQuotas()) return
this.submitLoading = true
// Nutz 对子表集合按字符串化 JSON 绑定更稳定,和体检项目维护的提交方式保持一致。
const submitData = JSON.parse(JSON.stringify(this.formData))
@@ -509,6 +680,7 @@ layout("/layouts/platform.html"){
submitData.outProvinceFixedPeople = 0
}
submitData.lots = JSON.stringify(this.formData.lots || [])
submitData.unionQuotas = JSON.stringify(this.formData.unionQuotas || [])
submitData.lotDeleteList = JSON.stringify(this.lotDeleteList)
this.$axios.post(loc() + "/doSubmit", submitData).then((res) => {
this.submitLoading = false
@@ -562,6 +734,29 @@ layout("/layouts/platform.html"){
}
return true
},
// 提交前统一人员数量格式,并校验分配合计不超过出行人数指标。
validateUnionQuotas() {
const rows = this.formData.unionQuotas || []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (row.peopleQuota === null || row.peopleQuota === undefined || row.peopleQuota === "") {
row.peopleQuota = 0
}
if (!/^\d+$/.test(String(row.peopleQuota))) {
this.$message.warning("第" + (i + 1) + "行人员数量必须为非负整数")
return false
}
this.$set(row, "peopleQuota", this.toNonNegativeInteger(row.peopleQuota))
}
const quotaTotal = rows.reduce((sum, row) => {
return sum + this.toNonNegativeInteger(row.peopleQuota)
}, 0)
if (quotaTotal > this.travelPeopleQuota) {
this.$message.warning("当前人员数量合计 " + quotaTotal + ",不能超过出行人数指标 " + this.travelPeopleQuota)
return false
}
return true
},
deleteLot(index, row) {
this.$confirm("确定删除该标段吗?", "提示", {
confirmButtonText: "确定",
@@ -593,6 +788,9 @@ layout("/layouts/platform.html"){
destroyEditor() {
this.formData = {}
this.lotDeleteList = []
this.quotaAllocate = {
ratio: null
}
this.inheritLoading = false
this.activeTab = "basic"
}
@@ -627,6 +825,94 @@ layout("/layouts/platform.html"){
border-top: 1px dashed #dcdfe6;
margin: 2px 0 18px;
}
.tour-quota-toolbar {
align-items: center;
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.tour-quota-input {
width: 160px;
}
.tour-quota-stat {
align-items: center;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: inline-flex;
gap: 8px;
height: 36px;
padding: 0 10px;
}
.tour-quota-stat span {
color: #606266;
}
.tour-quota-stat strong {
color: #303133;
font-weight: 600;
}
.tour-tab-fill {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.tour-tab-table {
flex: 1;
min-height: 0;
}
.tour-setting-dialog {
display: flex;
flex-direction: column;
height: 85vh;
max-height: 85vh;
}
.tour-setting-dialog .el-dialog__header,
.tour-setting-dialog .el-dialog__footer,
.tour-setting-dialog .el-tabs__header {
flex-shrink: 0;
}
.tour-setting-dialog .el-dialog__body {
flex: 1;
min-height: 0;
overflow: hidden;
padding-bottom: 0;
}
.tour-setting-dialog .el-form,
.tour-setting-dialog .el-tabs {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.tour-setting-dialog .el-tabs {
flex: 1;
}
.tour-setting-dialog .el-tabs__content {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 0 6px 18px 0;
}
.tour-setting-dialog .el-tab-pane {
height: 100%;
}
</style>
<!--#
@@ -154,7 +154,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="报名旅行社">
<el-form-item label="报名服务单位">
<el-input v-model="signupForm.travelAgencyName" :disabled="isDirectFamilyLine(signupForm)" readonly></el-input>
</el-form-item>
</el-col>
@@ -360,7 +360,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="时间标段">{{ lineDetail.lotName || '' }}</el-descriptions-item>
<el-descriptions-item label="线路类型">{{ lineDetail.lineType || '' }}</el-descriptions-item>
<el-descriptions-item label="直系亲属线路">{{ isDirectFamilyLine(lineDetail) ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="旅行社">{{ lineDetail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="服务单位">{{ lineDetail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ lineDetail.creatorName || '' }}</el-descriptions-item>
<el-descriptions-item label="所在单位">{{ lineDetail.unitName || '' }}</el-descriptions-item>
<el-descriptions-item label="是否对外开放">{{ lineDetail.openFlag ? '是' : '否' }}</el-descriptions-item>
@@ -15,11 +15,11 @@ layout("/layouts/platform.html"){
style="width: 100%">
</el-date-picker>
</search-item>
<search-item label="旅行社名称">
<search-item label="服务单位名称">
<el-input
v-model="pageForm.agencyName"
clearable
placeholder="请输入旅行社名称"
placeholder="请输入服务单位名称"
style="width: 100%"
@keyup.enter.native="doSearch">
</el-input>
@@ -50,7 +50,7 @@ layout("/layouts/platform.html"){
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="旅行社列表">
<table-tool :app="this" label="服务单位列表">
<el-button @click="openAdd" size="medium" type="primary">
<i class="el-icon-plus"></i>
新增
@@ -65,8 +65,8 @@ layout("/layouts/platform.html"){
@sort-change="pageOrder">
<el-table-column label="序号" type="index" :index="indexMethod" width="80" align="center" header-align="center"></el-table-column>
<el-table-column label="年度" prop="year" width="110" sortable="custom" align="center" header-align="center"></el-table-column>
<el-table-column label="旅行社编号" prop="agencyCode" width="120" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="旅行社名称" prop="agencyName" min-width="220" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="服务单位编号" prop="agencyCode" width="120" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="服务单位名称" prop="agencyName" min-width="220" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="联系人" prop="contactName" width="130" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="联系电话" prop="contactPhone" width="150" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="激活状态" prop="enabled" width="160" align="center" header-align="center">
@@ -111,15 +111,15 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="旅行社名称" prop="agencyName">
<el-input v-model="formData.agencyName" maxlength="30" show-word-limit placeholder="请输入旅行社名称"></el-input>
<el-form-item label="服务单位名称" prop="agencyName">
<el-input v-model="formData.agencyName" maxlength="30" show-word-limit placeholder="请输入服务单位名称"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="旅行社编号" prop="agencyCode">
<el-input v-model="formData.agencyCode" maxlength="50" placeholder="请输入旅行社编号"></el-input>
<el-form-item label="服务单位编号" prop="agencyCode">
<el-input v-model="formData.agencyCode" maxlength="50" placeholder="请输入服务单位编号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -248,19 +248,19 @@ layout("/layouts/platform.html"){
}
},
openAdd() {
this.title = "新增旅行社信息"
this.title = "新增服务单位信息"
this.viewMode = false
this.formData = this.emptyForm()
this.dialogVisible = true
this.$nextTick(() => this.$refs.form && this.$refs.form.clearValidate())
},
openEdit(row) {
this.title = "编辑旅行社信息"
this.title = "编辑服务单位信息"
this.viewMode = false
this.loadDetail(row.id)
},
openView(row) {
this.title = "查看旅行社信息"
this.title = "查看服务单位信息"
this.viewMode = true
this.loadDetail(row.id)
},
@@ -191,7 +191,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="出行时段">{{ detail.travelPeriod || '' }}</el-descriptions-item>
<el-descriptions-item label="线路类型">{{ detail.lineType || '' }}</el-descriptions-item>
<el-descriptions-item label="报名酒店">{{ detail.hotelName || '' }}</el-descriptions-item>
<el-descriptions-item label="旅行社">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="服务单位">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="是否携带家属">
{{ familyText() }}
</el-descriptions-item>
@@ -164,7 +164,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="报名线路">{{ detail.lineName || '' }}</el-descriptions-item>
<el-descriptions-item label="线路类型">{{ detail.lineType || '' }}</el-descriptions-item>
<el-descriptions-item label="报名酒店">{{ detail.hotelName || '' }}</el-descriptions-item>
<el-descriptions-item label="报名旅行社">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="报名服务单位">{{ detail.travelAgencyName || '' }}</el-descriptions-item>
<el-descriptions-item label="出行时间">{{ detail.travelPeriod || '' }}</el-descriptions-item>
<el-descriptions-item v-if="fillBedInfo" label="床型">{{ detail.bedType || '' }}</el-descriptions-item>
<el-descriptions-item v-if="fillBedInfo" label="床位信息">{{ detail.bedInfo || '' }}</el-descriptions-item>
@@ -425,7 +425,7 @@ layout("/layouts/platform_h5.html"){
<van-cell title="线路类型" :value="detail.lineType || ''"></van-cell>
<van-cell title="出行时间" :value="detail.travelPeriod || ''"></van-cell>
<van-cell title="报名酒店" :value="detail.hotelName || ''"></van-cell>
<van-cell title="旅行社" :value="detail.travelAgencyName || ''"></van-cell>
<van-cell title="服务单位" :value="detail.travelAgencyName || ''"></van-cell>
<van-cell v-if="detailFillBedInfo" title="床型" :value="detail.bedType || ''"></van-cell>
<van-cell v-if="detailFillBedInfo" title="床位信息" :value="detail.bedInfo || ''"></van-cell>
<van-cell v-if="detailFillBedInfo" title="意向拼床人" :value="detail.intendedRoommate || ''"></van-cell>
@@ -216,7 +216,7 @@ layout("/layouts/platform_h5.html"){
<van-cell title="出行时段" :value="detail.travelPeriod || ''"></van-cell>
<van-cell title="报名时间" :value="detail.signupTime || ''"></van-cell>
<van-cell title="报名酒店" :value="detail.hotelName || ''"></van-cell>
<van-cell title="旅行社" :value="detail.travelAgencyName || ''"></van-cell>
<van-cell title="服务单位" :value="detail.travelAgencyName || ''"></van-cell>
<van-cell v-if="fillBedInfo" title="床型" :value="detail.bedType || ''"></van-cell>
<van-cell v-if="fillBedInfo" title="床位信息" :value="detail.bedInfo || ''"></van-cell>
<van-cell v-if="fillBedInfo" title="意向拼床人" :value="detail.intendedRoommate || ''"></van-cell>
@@ -3,10 +3,11 @@ layout("/layouts/platform_tour_signup_h5.html"){
#-->
<style scoped>
.tour-signup-h5 {
#app.tour-signup-h5 {
/* 覆盖公共 #app 固定高度,确保长须知内容和底部占位共同参与页面滚动。 */
height: auto;
min-height: 100vh;
background: #f5f7fb;
padding-bottom: 80px;
box-sizing: border-box;
}
@@ -143,15 +144,24 @@ layout("/layouts/platform_tour_signup_h5.html"){
padding: 34px 0 26px;
}
.tour-signup-footer-spacer {
/* 占位元素位于正文流末尾,保证最后一行可完整滚动到固定按钮栏上方。 */
height: 110px;
height: calc(110px + env(safe-area-inset-bottom));
}
.tour-signup-footer {
position: fixed;
right: 0;
bottom: 0;
left: 0;
/* 不支持安全区变量时使用固定内边距,避免整条 padding 声明失效。 */
padding: 10px 12px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.08);
box-sizing: border-box;
z-index: 20;
}
</style>
@@ -196,8 +206,18 @@ layout("/layouts/platform_tour_signup_h5.html"){
</van-skeleton>
</div>
<div class="tour-signup-footer-spacer" aria-hidden="true"></div>
<div class="tour-signup-footer">
<van-button block type="info" color="#0f74bc" round @click="confirmRead">我已阅读</van-button>
<van-button
block
type="info"
color="#0f74bc"
round
:disabled="readCountdown > 0"
@click="confirmRead">
{{ readButtonText }}
</van-button>
</div>
</div>
@@ -217,6 +237,8 @@ layout("/layouts/platform_tour_signup_h5.html"){
pdfPageCount: 0,
pdfRenderedPages: 0,
pdfRenderToken: 0,
readCountdown: 10,
readCountdownTimer: null,
bannerList: [
"/assets/platform/images/tour/tour-h5-banner-1.jpg",
"/assets/platform/images/tour/tour-h5-banner-2.jpg"
@@ -228,7 +250,33 @@ layout("/layouts/platform_tour_signup_h5.html"){
}
}
},
computed: {
// 倒计时期间在按钮中展示剩余秒数,结束后恢复原确认文案。
readButtonText() {
return this.readCountdown > 0 ? "我已阅读(" + this.readCountdown + "秒)" : "我已阅读"
}
},
methods: {
// 启动十秒阅读倒计时,重复调用前先清理旧计时器,避免并发计时。
startReadCountdown() {
this.clearReadCountdown()
this.readCountdown = 10
this.readCountdownTimer = window.setInterval(() => {
if (this.readCountdown <= 1) {
this.readCountdown = 0
this.clearReadCountdown()
return
}
this.readCountdown -= 1
}, 1000)
},
// 页面离开或倒计时结束时释放计时器,避免页面销毁后继续更新状态。
clearReadCountdown() {
if (this.readCountdownTimer !== null) {
window.clearInterval(this.readCountdownTimer)
this.readCountdownTimer = null
}
},
parseContentHref(content) {
if (!content) {
return ""
@@ -386,13 +434,19 @@ layout("/layouts/platform_tour_signup_h5.html"){
})
},
confirmRead() {
// 方法内再次校验倒计时,防止通过脚本触发禁用按钮的点击事件。
if (this.readCountdown > 0) {
return
}
window.location.href = "/platform/tour/signup/h5/signup"
}
},
created() {
this.startReadCountdown()
this.loadServiceNotice()
},
beforeDestroy() {
this.clearReadCountdown()
this.clearPdfPreview()
}
})
@@ -470,8 +470,8 @@ layout("/layouts/platform_tour_signup_h5.html"){
<div class="tour-line-info">
<div><span>出行开始时间:</span>{{ row.travelStartTime || '暂无' }}</div>
<div><span>出行结束时间:</span>{{ row.travelEndTime || '暂无' }}</div>
<!-- 临时屏蔽旅行社信息,保留字段和接口便于后续恢复。
<div><span>旅行社</span>{{ row.travelAgencyName || '暂无' }}</div>
<!-- 临时屏蔽服务单位信息,保留字段和接口便于后续恢复。
<div><span>服务单位</span>{{ row.travelAgencyName || '暂无' }}</div>
-->
</div>
<div class="tour-line-actions">
@@ -214,7 +214,7 @@ layout("/layouts/platform_h5.html"){
<van-cell title="出行时段" :value="detail.travelPeriod || ''"></van-cell>
<van-cell title="报名时间" :value="detail.signupTime || ''"></van-cell>
<van-cell title="报名酒店" :value="detail.hotelName || ''"></van-cell>
<van-cell title="旅行社" :value="detail.travelAgencyName || ''"></van-cell>
<van-cell title="服务单位" :value="detail.travelAgencyName || ''"></van-cell>
<van-cell v-if="fillBedInfo" title="床型" :value="detail.bedType || ''"></van-cell>
<van-cell v-if="fillBedInfo" title="床位信息" :value="detail.bedInfo || ''"></van-cell>
<van-cell v-if="fillBedInfo" title="意向拼床人" :value="detail.intendedRoommate || ''"></van-cell>