This commit is contained in:
=
2026-06-04 17:13:00 +08:00
parent aca79a571a
commit 797430bbcd
4 changed files with 280 additions and 51 deletions
@@ -205,6 +205,20 @@ public class TheRapyRecuperationBaseManagerController {
return dao.fetchLinks(management, "travelAgency");
}
/**
* 查询酒店每日名额占用和剩余情况。
*
* @param id 酒店/目的地Id
* @return 每日名额列表
*/
@At("/listDailyQuota")
@ViReturn
@RequiresAuthentication
public Object listDailyQuota(@Param("id") String id) {
Assert.notBlank(id);
return theRapyRecuperationBaseManagerService.listDailyQuota(id);
}
/**
* 新增/修改
@@ -2,6 +2,8 @@ package io.v.nutz.zhgh.therapyRecuperation.service.baseManage;
import org.nutz.lang.util.NutMap;
import java.util.List;
/**
* @Author JyuHsin
* @Date 2023/6/7
@@ -11,6 +13,14 @@ public interface TheRapyRecuperationBaseManagerService {
NutMap selectBaseAllInfo(String id);
/**
* 查询酒店每日名额占用和剩余情况。
*
* @param id 酒店/目的地Id
* @return 每日名额列表
*/
List<NutMap> listDailyQuota(String id);
/**
* 复制上一年度目的地到目标年度,只复制目的地基础信息,不复制报名数据。
*
@@ -4,9 +4,13 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.service.impl.BaseServiceImpl;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.sys.models.SysHoliday;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.therapyRecuperation.constant.TheRapyRecuperationState;
import io.v.nutz.zhgh.therapyRecuperation.model.TheRapyRecuperationBaseDailyQuota;
import io.v.nutz.zhgh.therapyRecuperation.model.TheRapyRecuperationBaseManagement;
import io.v.nutz.zhgh.therapyRecuperation.model.TheRapyRecuperationConfig;
import io.v.nutz.zhgh.therapyRecuperation.model.TheRapyRecuperationEnroll;
import io.v.nutz.zhgh.therapyRecuperation.model.TheRapyRecuperationLot;
import io.v.nutz.zhgh.therapyRecuperation.service.TheRapyRecuperationConfigService;
import io.v.nutz.zhgh.therapyRecuperation.service.baseManage.TheRapyRecuperationBaseManagerService;
@@ -18,11 +22,21 @@ import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.util.NutMap;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @Author JyuHsin
@@ -63,6 +77,150 @@ public class TheRapyRecuperationBaseManagerServiceImpl extends BaseServiceImpl i
return nutMap;
}
/**
* 查询酒店每日名额占用和剩余情况,优先使用每日名额占用表,未初始化的历史日期按报名表兜底统计。
*
* @param id 酒店/目的地Id
* @return 每日名额列表
*/
@Override
public List<NutMap> listDailyQuota(String id) {
TheRapyRecuperationBaseManagement management = dao().fetch(TheRapyRecuperationBaseManagement.class, id);
if (management == null) {
return Collections.emptyList();
}
TheRapyRecuperationConfig config = configService.requireByYear(management.getYear());
if (config == null || config.getFragmentPlayStartTime() == null || config.getFragmentPlayEndTime() == null) {
return Collections.emptyList();
}
Map<String, TheRapyRecuperationBaseDailyQuota> quotaMap = queryDailyQuotaMap(id);
Map<String, Integer> enrollCountMap = countDailyQuotaByEnroll(management);
Set<String> holidays = queryHolidaySet(management.getYear());
List<Integer> weeks = management.getWeekCheckIn();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate startDate = LocalDate.parse(DateUtil.formatDate(config.getFragmentPlayStartTime()), formatter);
LocalDate endDate = LocalDate.parse(DateUtil.formatDate(config.getFragmentPlayEndTime()), formatter);
List<NutMap> rows = new ArrayList<>();
LocalDate currentDate = startDate;
while (!currentDate.isAfter(endDate)) {
String currentDateString = currentDate.format(formatter);
if (isDisabledQuotaDate(currentDate, currentDateString, holidays, weeks)) {
currentDate = currentDate.plusDays(1);
continue;
}
TheRapyRecuperationBaseDailyQuota quota = quotaMap.get(currentDateString);
int maxSignCount = quota != null && quota.getMaxSignCount() != null ? quota.getMaxSignCount() : Objects.requireNonNullElse(management.getMaxSignCount(), 0);
int usedSignCount = quota != null && quota.getUsedSignCount() != null ? quota.getUsedSignCount() : enrollCountMap.getOrDefault(currentDateString, 0);
rows.add(buildDailyQuotaRow(currentDateString, maxSignCount, usedSignCount));
currentDate = currentDate.plusDays(1);
}
return rows;
}
private Map<String, TheRapyRecuperationBaseDailyQuota> queryDailyQuotaMap(String id) {
List<TheRapyRecuperationBaseDailyQuota> quotas = dao().query(TheRapyRecuperationBaseDailyQuota.class,
Cnd.where("baseManagementId", "=", id).asc("quotaDate"));
return quotas.stream().collect(Collectors.toMap(TheRapyRecuperationBaseDailyQuota::getQuotaDate, quota -> quota, (oldValue, newValue) -> newValue));
}
/**
* 报名表兜底统计只用于未生成每日占用表的日期,保证历史数据在查看页仍然可见。
*
* @param management 酒店/目的地
* @return key为日期,value为覆盖该日期的报名人数
*/
private Map<String, Integer> countDailyQuotaByEnroll(TheRapyRecuperationBaseManagement management) {
Cnd cnd = Cnd.where("takePartInBaseManagementId", "=", management.getId())
.and("specificTime", "is not", null)
.and("isNormal", "=", true)
.and("stateId", "in", Lang.array(TheRapyRecuperationState.UNIT, TheRapyRecuperationState.LINEUNIT, TheRapyRecuperationState.SCHOOL, TheRapyRecuperationState.PASS))
.and("YEAR(signingUptime)", "=", management.getYear());
List<TheRapyRecuperationEnroll> enrolls = dao().query(TheRapyRecuperationEnroll.class, cnd);
Map<String, Integer> countMap = new HashMap<>();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (TheRapyRecuperationEnroll enroll : enrolls) {
for (LocalDate day : parseSpecificTimeDays(enroll.getSpecificTime(), management.getYear())) {
String dayString = day.format(formatter);
countMap.put(dayString, countMap.getOrDefault(dayString, 0) + 1);
}
}
return countMap;
}
private Set<String> queryHolidaySet(Integer year) {
List<SysHoliday> holidayList = dao().query(SysHoliday.class, Cnd.where("year(day)", "=", year).asc("day"));
Set<String> holidays = holidayList.stream().map(SysHoliday::getDay).collect(Collectors.toCollection(HashSet::new));
Map<Integer, List<SysHoliday>> listMap = holidayList.stream().collect(Collectors.groupingBy(o -> DateUtil.month(DateUtil.parse(o.getDay()))));
listMap.forEach((k, v) -> {
SysHoliday sysHoliday = v.get(0);
Date date = DateUtil.parse(sysHoliday.getDay());
holidays.add(DateUtil.formatDate(date));
});
return holidays;
}
private boolean isDisabledQuotaDate(LocalDate currentDate, String currentDateString, Set<String> holidays, List<Integer> weeks) {
if (holidays.contains(currentDateString)) {
return true;
}
int dayOfWeek = currentDate.getDayOfWeek().getValue() % 7;
return Lang.isNotEmpty(weeks) && !weeks.contains(dayOfWeek);
}
private NutMap buildDailyQuotaRow(String quotaDate, int maxSignCount, int usedSignCount) {
int remainingSignCount = maxSignCount <= 0 ? 0 : Math.max(maxSignCount - usedSignCount, 0);
boolean full = maxSignCount > 0 && usedSignCount >= maxSignCount;
String statusText = maxSignCount <= 0 ? "不限制" : full ? "已满" : "未满";
return new NutMap()
.setv("quotaDate", quotaDate)
.setv("maxSignCount", maxSignCount)
.setv("usedSignCount", usedSignCount)
.setv("remainingSignCount", remainingSignCount)
.setv("remainingText", maxSignCount <= 0 ? "不限制" : String.valueOf(remainingSignCount))
.setv("statusText", statusText)
.setv("full", full);
}
/**
* 把“06月01日-06月03日”格式的报名时间拆成指定年度的逐日日期。
*
* @param specificTime 报名时间段
* @param year 年度
* @return 报名时间段包含的所有日期
*/
private List<LocalDate> parseSpecificTimeDays(String specificTime, Integer year) {
if (StrUtil.isBlank(specificTime) || !specificTime.contains("-")) {
return Collections.emptyList();
}
String[] times = specificTime.split("-");
if (times.length != 2 || StrUtil.isBlank(times[0]) || StrUtil.isBlank(times[1])) {
return Collections.emptyList();
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
LocalDate start;
LocalDate end;
try {
Integer targetYear = year == null ? DateUtil.thisYear() : year;
start = LocalDate.parse(targetYear + "" + times[0].trim(), formatter);
end = LocalDate.parse(targetYear + "" + times[1].trim(), formatter);
} catch (Exception e) {
return Collections.emptyList();
}
if (end.isBefore(start)) {
end = end.plusYears(1);
}
List<LocalDate> days = new ArrayList<>();
LocalDate current = start;
while (!current.isAfter(end)) {
days.add(current);
current = current.plusDays(1);
}
return days;
}
/**
* 复制上一年度目的地到目标年度,排序号从当前最大排序号后递增,避免复用历史目的地排序号。
* 复制时只生成目的地基础数据,不复制报名记录,防止目标年度业务挂到旧年度报名数据。
@@ -1,56 +1,74 @@
<template>
<div>
<div class="panel panel-default mt20" style="border: none">
<div class="panel-heading" style="background: #fafafa;border:1px solid #ebeef5;border-bottom: none">
<h3 class="panel-title">
基地信息
</h3>
</div>
<el-descriptions :column="2" border class="viewTable">
<el-descriptions-item label="年度">{{ viewData.year }}</el-descriptions-item>
<el-descriptions-item label="排序编号">{{ viewData.sortNumber }}</el-descriptions-item>
<el-descriptions-item label="酒店名称">{{ viewData.baseName }}</el-descriptions-item>
<el-descriptions-item label="区域性质">{{ viewData.regionalNature }}</el-descriptions-item>
<!-- 校工会创建AND自由模式 OR 分工会创建 -->
<!-- <template v-if="(viewData.createMode === 2 && viewData.signUpMode === 2 ) || viewData.createMode === 1">-->
<!--<el-descriptions-item label="出行天数">{{ viewData.regionalNature=5?"五天四夜":"三天两夜" }}</el-descriptions-item>-->
<el-descriptions-item label="酒店联系人">{{ viewData.baseContactPerson }}</el-descriptions-item>
<el-descriptions-item label="联系人电话">{{ viewData.baseContactNumber }}</el-descriptions-item>
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="基地信息" name="baseInfo">
<div class="panel panel-default mt20" style="border: none">
<div class="panel-heading" style="background: #fafafa;border:1px solid #ebeef5;border-bottom: none">
<h3 class="panel-title">
基地信息
</h3>
</div>
<el-descriptions :column="2" border class="viewTable">
<el-descriptions-item label="年度">{{ viewData.year }}</el-descriptions-item>
<el-descriptions-item label="排序编号">{{ viewData.sortNumber }}</el-descriptions-item>
<el-descriptions-item label="酒店名称">{{ viewData.baseName }}</el-descriptions-item>
<el-descriptions-item label="区域性质">{{ viewData.regionalNature }}</el-descriptions-item>
<!-- 校工会创建AND自由模式 OR 分工会创建 -->
<!-- <template v-if="(viewData.createMode === 2 && viewData.signUpMode === 2 ) || viewData.createMode === 1">-->
<!--<el-descriptions-item label="出行天数">{{ viewData.regionalNature=5?"五天四夜":"三天两夜" }}</el-descriptions-item>-->
<el-descriptions-item label="酒店联系人">{{ viewData.baseContactPerson }}</el-descriptions-item>
<el-descriptions-item label="联系人电话">{{ viewData.baseContactNumber }}</el-descriptions-item>
<!--</template>-->
<!--</template>-->
</el-descriptions>
</div>
<div class="panel panel-default mt20" style="border: none">
<div class="panel-heading" style="background: #fafafa;border:1px solid #ebeef5;border-bottom: none">
<h3 class="panel-title">
旅行社信息
</h3>
</div>
<el-descriptions :column="2" border class="viewTable">
<el-descriptions-item :span="2" label="旅行社名称">
{{ viewData.travelAgency.travelAgencyName }}
</el-descriptions-item>
<el-descriptions-item label="联系人">{{ viewData.travelAgency.contact }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ viewData.travelAgency.contactMobileNumber }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ viewData.travelAgency.email }}</el-descriptions-item>
<el-descriptions-item label="官网">
<a :href="viewData.travelAgency.officialWebsite" class="text-primary" target="_blank">
{{ viewData.travelAgency.officialWebsite }}
</a>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="panel panel-default mt20" style="border-color: #ebeef5">
<div class="panel-heading" style="background: #fafafa;border-color: #ebeef5">
<h3 class="panel-title">
详细信息
</h3>
</div>
<div class="panel-body">
<div v-html="viewData.content"></div>
</div>
</div>
</el-descriptions>
</div>
<div class="panel panel-default mt20" style="border: none">
<div class="panel-heading" style="background: #fafafa;border:1px solid #ebeef5;border-bottom: none">
<h3 class="panel-title">
旅行社信息
</h3>
</div>
<el-descriptions :column="2" border class="viewTable">
<el-descriptions-item :span="2" label="旅行社名称">
{{ viewData.travelAgency.travelAgencyName }}
</el-descriptions-item>
<el-descriptions-item label="联系人">{{ viewData.travelAgency.contact }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ viewData.travelAgency.contactMobileNumber }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ viewData.travelAgency.email }}</el-descriptions-item>
<el-descriptions-item label="官网">
<a :href="viewData.travelAgency.officialWebsite" class="text-primary" target="_blank">
{{ viewData.travelAgency.officialWebsite }}
</a>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="panel panel-default mt20" style="border-color: #ebeef5">
<div class="panel-heading" style="background: #fafafa;border-color: #ebeef5">
<h3 class="panel-title">
详细信息
</h3>
</div>
<div class="panel-body">
<div v-html="viewData.content"></div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="房间剩余情况" name="dailyQuota">
<el-table :data="quotaRows" v-loading="quotaLoading" border class="mt20" size="small" style="width: 100%">
<el-table-column align="center" label="可报名日期" prop="quotaDate"></el-table-column>
<el-table-column align="center" label="每日最高人数" prop="maxSignCount"></el-table-column>
<el-table-column align="center" label="已占用人数" prop="usedSignCount"></el-table-column>
<el-table-column align="center" label="剩余人数" prop="remainingText"></el-table-column>
<el-table-column align="center" label="状态">
<template slot-scope="scope">
<el-tag :type="scope.row.full ? 'danger' : 'success'" size="small">{{ scope.row.statusText }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<slot></slot>
</div>
</template>
@@ -63,17 +81,46 @@ module.exports = {
travelAgency: {}
},
modifyBdList: [],
activeTab: 'baseInfo',
currentBaseId: '',
quotaLoading: false,
quotaLoaded: false,
quotaRows: [],
}
},
methods: {
openView(id, obj) {
this.modifyBdList = obj
this.$set(this, 'modifyBdList', obj)
this.$set(this, 'currentBaseId', id)
this.$set(this, 'activeTab', 'baseInfo')
this.$set(this, 'quotaLoaded', false)
this.$set(this, 'quotaRows', [])
this.findOne(id)
},
async findOne(id) {
const res = await $.get('/platform/theRapyRecuperation/baseManagement/selectBaseManageById/' + id)
if (res.code === 0) {
this.viewData = res.data
this.$set(this, 'viewData', res.data)
}
},
handleTabClick(tab) {
if (tab.name === 'dailyQuota' && !this.quotaLoaded) {
this.findDailyQuota()
}
},
async findDailyQuota() {
if (!this.currentBaseId) {
return
}
this.$set(this, 'quotaLoading', true)
try {
const res = await $.get('/platform/theRapyRecuperation/baseManagement/listDailyQuota', {id: this.currentBaseId})
if (res.code === 0) {
this.$set(this, 'quotaRows', res.data || [])
this.$set(this, 'quotaLoaded', true)
}
} finally {
this.$set(this, 'quotaLoading', false)
}
},
},