16 Commits
152 changed files with 18380 additions and 3412 deletions
+90
View File
@@ -0,0 +1,90 @@
# 登山活动 1300 人并发报名压测
本目录用于本地或已明确授权的测试环境。JMeter 会真实调用登录和报名接口,并向报名表写入数据,禁止直接对生产环境执行。
## 文件说明
- `prepare-brand-activity-users.ps1`:从当前项目数据库导出活动范围内、未禁用且尚未报名的人员。
- `brand-activity-mountain-signup-1300.jmx`:每线程独立登录,所有线程到达报名步骤后同时提交。
- `data/brand-activity-users.csv`:运行准备脚本后生成,不包含密码、手机号、身份证号等敏感数据。
当前登山活动参数:
- 活动 ID:`11619eee67574256ad8ccc8a16630023`
- 子活动 ID:`986bf4d48a5948e1810857483f9e5af2`
- 容量:1500 人
## 1. 准备人员数据
在项目根目录执行:
```powershell
powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1300 -PermissionMode AllScope
```
`AllScope` 会导出1300名活动范围人员。根据当前数据库,约290名人员没有报名菜单权限,其报名请求会被权限校验拒绝。
如果只测试具备权限的人员:
```powershell
powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1010 -PermissionMode Authorized
```
当前数据库只有1010名“有报名权限且尚未报名”的人员,因此 `Authorized` 模式不能生成1300名不同人员。
## 2. 先进行10人试跑
启动本地项目,确认80端口和 Redis 6378 可访问,然后执行:
```powershell
jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx `
-Jthreads=10 `
-JloginRampSeconds=10 `
-Jhost=127.0.0.1 `
-Jport=80 `
-JusersFile=jmeter/data/brand-activity-users.csv `
-l .\jmeter\result-smoke.jtl `
-j .\jmeter\jmeter-smoke.log
```
试跑会真实产生报名记录。再次运行前必须重新生成 CSV,以排除已经报名的人员。
## 3. 执行1300人并发
确认测试数据库已备份、应用和 Redis 正常、活动仍在报名时间内后执行:
```powershell
jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx `
-Jthreads=1300 `
-JloginRampSeconds=60 `
-JsyncTimeoutMs=300000 `
-Jhost=127.0.0.1 `
-Jport=80 `
-JusersFile=jmeter/data/brand-activity-users.csv `
-l .\jmeter\result-1300.jtl `
-j .\jmeter\jmeter-1300.log
```
登录在60秒内逐步完成;所有仍在运行的线程会在“07-集合后并发提交登山活动报名”前等待,然后同时发送报名请求。
## 4. 生成结果报告
```powershell
jmeter -g .\jmeter\result-1300.jtl -o .\jmeter\report-1300
```
重点查看:
- `05-人员账号登录`:独立账号登录是否成功。
- `06-报名资格预校验`:活动范围、时间、重复报名和名额检查。
- `07-集合后并发提交登山活动报名`:真正的并发报名结果。
报名结果只有返回 `code=0` 且 `msg=报名成功` 才计为成功,避免后端异常分支返回成功状态码但消息为“报名失败”时被误判。
## 注意事项
- 登录验证码由应用写入本机 Redis,JMX 仅在授权测试环境中从 Redis 读取对应验证码答案。
- 当前 `cas.enable=false`。现有登录实现只在 CAS 模式开启时拒绝密码不匹配,因此 JMX 使用测试占位密码;该行为不应复制到生产环境。
- Controller 使用单 JVM `ReentrantLock`,单节点下报名请求会排队;多节点下该锁无法跨节点保证一致性。
- JMeter 1300线程会消耗较多内存。若出现堆内存不足,可在启动前提高 `HEAP`,例如 `-Xms2g -Xmx4g`。
- 不提供自动删除报名数据的脚本,避免误删真实报名记录。
@@ -0,0 +1,291 @@
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="品牌活动-登山活动1300人并发报名" enabled="true">
<stringProp name="TestPlan.comments">每个线程使用独立用户和 Cookie 登录,登录完成后在报名接口前集合并发。仅限本地或经授权的测试环境。</stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="业务参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="activityId" elementType="Argument">
<stringProp name="Argument.name">activityId</stringProp>
<stringProp name="Argument.value">${__P(activityId,11619eee67574256ad8ccc8a16630023)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="courseId" elementType="Argument">
<stringProp name="Argument.name">courseId</stringProp>
<stringProp name="Argument.value">${__P(courseId,986bf4d48a5948e1810857483f9e5af2)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="redisHost" elementType="Argument">
<stringProp name="Argument.name">redisHost</stringProp>
<stringProp name="Argument.value">${__P(redisHost,127.0.0.1)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="redisPort" elementType="Argument">
<stringProp name="Argument.name">redisPort</stringProp>
<stringProp name="Argument.value">${__P(redisPort,6378)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="testPassword" elementType="Argument">
<stringProp name="Argument.name">testPassword</stringProp>
<stringProp name="Argument.value">${__P(testPassword,LoadTestOnly@123)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</TestPlan>
<hashTree>
<CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet" testname="读取数据库人员CSV" enabled="true">
<stringProp name="delimiter">,</stringProp>
<stringProp name="fileEncoding">UTF-8</stringProp>
<stringProp name="filename">${__P(usersFile,jmeter/data/brand-activity-users.csv)}</stringProp>
<boolProp name="ignoreFirstLine">false</boolProp>
<boolProp name="quotedData">true</boolProp>
<boolProp name="recycle">false</boolProp>
<stringProp name="shareMode">shareMode.all</stringProp>
<boolProp name="stopThread">true</boolProp>
<stringProp name="variableNames"></stringProp>
</CSVDataSet>
<hashTree/>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="独立用户登录并并发报名" enabled="true">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="循环控制器" enabled="true">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">1</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">${__P(threads,10)}</stringProp>
<stringProp name="ThreadGroup.ramp_time">${__P(loginRampSeconds,30)}</stringProp>
<boolProp name="ThreadGroup.scheduler">false</boolProp>
<stringProp name="ThreadGroup.duration"></stringProp>
<stringProp name="ThreadGroup.delay"></stringProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
</ThreadGroup>
<hashTree>
<ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP默认配置" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="用户定义变量" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${__P(host,127.0.0.1)}</stringProp>
<stringProp name="HTTPSampler.port">${__P(port,80)}</stringProp>
<stringProp name="HTTPSampler.protocol">${__P(protocol,http)}</stringProp>
<stringProp name="HTTPSampler.connect_timeout">${__P(connectTimeout,10000)}</stringProp>
<stringProp name="HTTPSampler.response_timeout">${__P(responseTimeout,120000)}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
</ConfigTestElement>
<hashTree/>
<CookieManager guiclass="CookiePanel" testclass="CookieManager" testname="每线程独立Cookie" enabled="true">
<collectionProp name="CookieManager.cookies"/>
<boolProp name="CookieManager.clearEachIteration">true</boolProp>
<boolProp name="CookieManager.controlledByThreadGroup">false</boolProp>
</CookieManager>
<hashTree/>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="请求头" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/x-www-form-urlencoded;charset=UTF-8</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="01-获取图形验证码" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="请求参数" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/login/captcha</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="提取验证码Key" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">captchaKey</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.key</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
<JSR223Sampler guiclass="TestBeanGUI" testclass="JSR223Sampler" testname="02-从本地Redis读取压测验证码" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
def captchaKey = vars.get('captchaKey')
if (!captchaKey || captchaKey == 'NOT_FOUND') {
throw new IllegalStateException('获取验证码Key失败')
}
def redisKey = 'budwk5mini:ucenter:captcha:' + captchaKey
def socket = new Socket(vars.get('redisHost'), vars.get('redisPort') as int)
socket.soTimeout = 3000
try {
def keyBytes = redisKey.getBytes('UTF-8')
def command = '*2\r\n$3\r\nGET\r\n$' + keyBytes.length + '\r\n' + redisKey + '\r\n'
socket.outputStream.write(command.getBytes('UTF-8'))
socket.outputStream.flush()
def reader = new BufferedReader(new InputStreamReader(socket.inputStream, 'UTF-8'))
def header = reader.readLine()
if (!header || !header.startsWith('$') || header == '$-1') {
throw new IllegalStateException('Redis中未找到验证码:' + redisKey)
}
def captchaCode = reader.readLine()
if (!captchaCode) {
throw new IllegalStateException('验证码内容为空')
}
vars.put('captchaCode', captchaCode)
} finally {
socket.close()
}
]]></stringProp>
</JSR223Sampler>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="03-获取登录RSA公钥" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="请求参数" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/login/publicKey</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="提取公钥和KeyId" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">publicKey;keyId</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.publicKey;$.data.keyId</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1;1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND;NOT_FOUND</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
<JSR223Sampler guiclass="TestBeanGUI" testclass="JSR223Sampler" testname="04-RSA加密测试密码" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
import java.security.KeyFactory
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
def publicKeyText = vars.get('publicKey')
if (!publicKeyText || publicKeyText == 'NOT_FOUND') {
throw new IllegalStateException('获取RSA公钥失败')
}
def keyBytes = Base64.decoder.decode(publicKeyText)
def publicKey = KeyFactory.getInstance('RSA').generatePublic(new X509EncodedKeySpec(keyBytes))
def cipher = Cipher.getInstance('RSA/ECB/PKCS1Padding')
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
def encrypted = cipher.doFinal(vars.get('testPassword').getBytes('UTF-8'))
vars.put('encryptedPassword', Base64.encoder.encodeToString(encrypted))
]]></stringProp>
</JSR223Sampler>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="05-人员账号登录" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="登录参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="username" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">username</stringProp><stringProp name="Argument.value">${loginname}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="password" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">password</stringProp><stringProp name="Argument.value">${encryptedPassword}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="platformKey" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">platformKey</stringProp><stringProp name="Argument.value">${captchaKey}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="platformCaptcha" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">platformCaptcha</stringProp><stringProp name="Argument.value">${captchaCode}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="keyId" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">keyId</stringProp><stringProp name="Argument.value">${keyId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/login/doLogin</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree>
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="断言登录成功" enabled="true">
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
import groovy.json.JsonSlurper
try {
def json = new JsonSlurper().parseText(prev.getResponseDataAsString())
if (json.code != 0) {
prev.setSuccessful(false)
prev.setResponseMessage('登录失败:' + json.msg)
}
} catch (Exception e) {
prev.setSuccessful(false)
prev.setResponseMessage('登录响应不是有效JSON:' + e.message)
}
]]></stringProp>
</JSR223PostProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="06-报名资格预校验" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="校验参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="courseId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">courseId</stringProp><stringProp name="Argument.value">${courseId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
<elementProp name="currentFamilyNumber" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">currentFamilyNumber</stringProp><stringProp name="Argument.value">0</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/trainSignUp/apply/validateSignUp</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="07-集合后并发提交登山活动报名" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="报名参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="activityId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">activityId</stringProp><stringProp name="Argument.value">${activityId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
<elementProp name="courseId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">courseId</stringProp><stringProp name="Argument.value">${courseId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
<elementProp name="mobileColumnsValue" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">mobileColumnsValue</stringProp><stringProp name="Argument.value">[]</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/trainSignUp/apply/doSignUp</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree>
<SyncTimer guiclass="TestBeanGUI" testclass="SyncTimer" testname="等待本线程组全部人员后同时报名" enabled="true">
<intProp name="groupSize">0</intProp>
<stringProp name="timeoutInMs">${__P(syncTimeoutMs,180000)}</stringProp>
</SyncTimer>
<hashTree/>
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="断言报名成功" enabled="true">
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
import groovy.json.JsonSlurper
try {
def json = new JsonSlurper().parseText(prev.getResponseDataAsString())
if (json.code != 0 || json.msg != '报名成功') {
prev.setSuccessful(false)
prev.setResponseMessage('报名未成功;expectedAuthorized=' + vars.get('expectedAuthorized') + ';返回:' + json.msg)
}
} catch (Exception e) {
prev.setSuccessful(false)
prev.setResponseMessage('报名响应不是有效JSON;expectedAuthorized=' + vars.get('expectedAuthorized') + ';' + e.message)
}
]]></stringProp>
</JSR223PostProcessor>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
+132
View File
@@ -0,0 +1,132 @@
param(
[ValidateRange(1, 1300)]
[int]$UserCount = 1300,
[ValidateSet("AllScope", "Authorized")]
[string]$PermissionMode = "AllScope",
[ValidatePattern("^[0-9a-fA-F]{32}$")]
[string]$ActivityId = "11619eee67574256ad8ccc8a16630023",
[string]$OutputFile = (Join-Path $PSScriptRoot "data\brand-activity-users.csv"),
[string]$ApplicationConfig = (Join-Path $PSScriptRoot "..\src\main\resources\application.yaml")
)
$ErrorActionPreference = "Stop"
function Get-YamlScalar {
param(
[string[]]$Lines,
[string]$Name
)
$matchedLine = $Lines | Where-Object { $_ -match ("^\s*" + [regex]::Escape($Name) + "\s*:\s*(.+?)\s*$") } | Select-Object -First 1
if (-not $matchedLine) {
throw "未在 application.yaml 中找到配置项:$Name"
}
return ([regex]::Match($matchedLine, "^\s*[^:]+:\s*(.+?)\s*$")).Groups[1].Value.Trim('"', "'")
}
if (-not (Test-Path -LiteralPath $ApplicationConfig)) {
throw "找不到应用配置文件:$ApplicationConfig"
}
$mysqlCommand = Get-Command mysql -ErrorAction SilentlyContinue
if (-not $mysqlCommand) {
throw "未找到 mysql 命令,请先把 MySQL 客户端 bin 目录加入 PATH。"
}
$configLines = Get-Content -LiteralPath $ApplicationConfig -Encoding UTF8
$jdbcUrl = Get-YamlScalar -Lines $configLines -Name "url"
$dbUser = Get-YamlScalar -Lines $configLines -Name "username"
$dbPassword = Get-YamlScalar -Lines $configLines -Name "password"
$jdbcMatch = [regex]::Match($jdbcUrl, "jdbc:mysql://(?<host>[^:/?]+)(:(?<port>\d+))?/(?<database>[^?]+)")
if (-not $jdbcMatch.Success) {
throw "无法解析 JDBC 地址:$jdbcUrl"
}
$dbHost = $jdbcMatch.Groups["host"].Value
$dbPort = if ($jdbcMatch.Groups["port"].Success) { $jdbcMatch.Groups["port"].Value } else { "3306" }
$database = $jdbcMatch.Groups["database"].Value
# 仅导出活动范围内、账号未禁用且尚未报名的人员;不读取密码、身份证号、手机号等敏感字段。
$permissionCondition = if ($PermissionMode -eq "Authorized") {
@"
AND EXISTS (
SELECT 1
FROM sys_user_role ur
JOIN sys_role_menu rm ON rm.roleId = ur.roleId
JOIN sys_menu m ON m.id = rm.menuId
WHERE ur.userId = u.id
AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply')
)
"@
} else {
""
}
$query = @"
SELECT
u.loginname,
CASE WHEN EXISTS (
SELECT 1
FROM sys_user_role ur
JOIN sys_role_menu rm ON rm.roleId = ur.roleId
JOIN sys_menu m ON m.id = rm.menuId
WHERE ur.userId = u.id
AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply')
) THEN 1 ELSE 0 END AS expectedAuthorized
FROM train_sign_up_activity a
JOIN activity_user_scope aus ON aus.groupId = a.activityGroupId
JOIN sys_user u ON u.id = aus.userId
LEFT JOIN train_sign_up_user su ON su.activityId = a.id AND su.userId = u.id
WHERE a.id = '$ActivityId'
AND u.disabled = 0
AND u.loginname IS NOT NULL
AND u.loginname <> ''
AND su.id IS NULL
$permissionCondition
GROUP BY u.id, u.loginname
ORDER BY expectedAuthorized DESC, u.loginname
LIMIT $UserCount;
"@
$previousMysqlPassword = $env:MYSQL_PWD
try {
$env:MYSQL_PWD = $dbPassword
$rows = @(& $mysqlCommand.Source --batch --raw --skip-column-names -h $dbHost -P $dbPort -u $dbUser $database -e $query)
if ($LASTEXITCODE -ne 0) {
throw "查询压测人员失败,mysql 退出码:$LASTEXITCODE"
}
} finally {
$env:MYSQL_PWD = $previousMysqlPassword
}
$users = @($rows | Where-Object { $_ -and $_.Trim() } | ForEach-Object {
$columns = $_ -split "`t", 2
[pscustomobject]@{
loginname = $columns[0]
expectedAuthorized = $columns[1]
}
})
if ($users.Count -lt $UserCount) {
throw "符合条件的人员只有 $($users.Count) 人,少于要求的 $UserCount 人。PermissionMode=$PermissionMode"
}
$outputDirectory = Split-Path -Parent $OutputFile
if (-not (Test-Path -LiteralPath $outputDirectory)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
$users | Export-Csv -LiteralPath $OutputFile -NoTypeInformation -Encoding UTF8 -UseQuotes Always
$authorizedCount = @($users | Where-Object { $_.expectedAuthorized -eq "1" }).Count
$unauthorizedCount = $users.Count - $authorizedCount
Write-Host "已生成 UTF-8 用户文件:$OutputFile"
Write-Host "总人数:$($users.Count),具备报名权限:$authorizedCount,无报名权限:$unauthorizedCount"
if ($unauthorizedCount -gt 0) {
Write-Warning "无报名权限的线程仍会访问报名接口,但会被权限校验拒绝。"
}
@@ -20,6 +20,9 @@ public class RedisConstant {
//企业微信token
public final static String REDIS_KEY_WX_WORK_TOKEN = PLATFORM_REDIS_PREFIX + "wxwork:token:";
// 学校消息中心钉钉提醒 Token;按 appId、compId 隔离,刷新锁与 Token 使用同一哈希槽。
public static final String REDIS_KEY_DING_MESSAGE_TOKEN = PLATFORM_REDIS_PREFIX + "ding:message:token:";
//健步走小程序TOKEN
public final static String REDIS_KEY_WE_APP_ACCESS_TOKEN = PLATFORM_REDIS_PREFIX + "weapp:token:";
@@ -31,6 +31,7 @@ RoleConstant {
SCHOOL_UNION_ARTICLE_FGH_ADMIN("校工会分工会新闻审批管理员"),
SCHOOL_UNION_ARTICLE_ADMIN("校工会新闻审批管理员"),
SCHOOL_OUTLAY_ADMIN("校工会经费管理员"),
TOUR_APPLICANT("疗休养领办人"),
BRANCH_UNION_ADMIN("分工会管理员"),
BRANCH_UNION_CHAIRMAN("分工会主席"),
@@ -136,4 +136,7 @@ public interface SmsService {
* @return
*/
void recallMsg(String msgId);
/** 后台群发卡片:工号列表、标题、正文、PC/H5 跳转地址及提交人的工号;无返回值。 */
void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser);
}
@@ -0,0 +1,321 @@
package com.budwk.app.base.sms.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.sms.SmsService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.integration.jedis.RedisService;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/**
* @ClassName SmsServiceImpl
* @Author JyuHsin
* @Date 2025/11/26 19:32
* @Version 1.0
* 通过学校消息中心发送钉钉提醒,正文填充钉钉模板变量 #{content}。
*/
@Slf4j
@IocBean
public class SmsServiceImpl implements SmsService {
private static final String BASE_URL = "https://ua.zjipc.cn/messageng/openApi";
// 由信息中心提供,templateId 必须对应包含 #{content} 的钉钉模板。
private static final String APP_ID = "xy6t2ht3qkkh6jdx9cyhwmg4bhgldjn3";
private static final String APP_SECRET = "T_AtDFHS0KHVCvpZK5zpx6bnRuIDNcwu4BgFLOZxXi0w45HrTIto1q_UYmX8lnjf";
private static final String COMP_ID = "30c2b301a1759c0ebcb4550635066ac0";
private static final String TEMPLATE_ID = "DING_083";
// 异步任务无登录上下文,按文档示例使用系统发送者。
private static final int REQUEST_TIMEOUT = 5000;
// 锁覆盖认证超时窗口,进程退出后自动释放;写缓存时再次校验锁归属。
private static final int TOKEN_LOCK_SECONDS = 30;
private static final String DELETE_IF_MATCH = "if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "return redis.call('del', KEYS[1]) else return 0 end";
private static final String CACHE_IF_OWNER = "if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "redis.call('setex', KEYS[2], ARGV[2], ARGV[3]); return 1 else return 0 end";
@Inject
private RedisService redisService;
/** 按工号单发正文,使用默认标题和首页链接,无返回值。 */
@Override
public void sendSingleMessage(String loginName, String content) {
this.sendSingleMessage(loginName, "智慧工会", content);
}
/** 按工号单发指定标题与正文,无返回值,链接默认使用首页。 */
@Override
public void sendSingleMessage(String loginName, String title, String content) {
this.massSendMessage(Collections.singletonList(loginName), title, content);
}
/** 按工号发送正文与 PC/H5 业务链接,使用默认标题,无返回值。 */
@Override
public void sendSingleTextCard(String loginName, String content, String pcUrl, String mobileUrl) {
this.sendSingleTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl);
}
/** 按工号发送指定标题、正文与 PC/H5 业务链接,无返回值。 */
@Override
public void sendSingleTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl) {
this.massSendTextCard(Collections.singletonList(loginName), title, content, pcUrl, mobileUrl);
}
/** 兼容原图文单发参数,使用默认标题,图片限制由群发入口统一处理,无返回值。 */
@Override
public void sendSinglePicTextCard(String loginName, String content, String pcUrl, String mobileUrl, String picUrl) {
this.sendSinglePicTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl, picUrl);
}
/** 按工号接收指定标题的原图文参数,仅发送正文与业务链接,无返回值。 */
@Override
public void sendSinglePicTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
this.massSendPicTextCard(Collections.singletonList(loginName), title, content, pcUrl, mobileUrl, picUrl);
}
/** 向工号列表发送相同正文,默认标题和首页链接,无返回值。 */
@Override
public void massSendMessage(List<String> loginNames, String content) {
this.massSendMessage(loginNames, "智慧工会", content);
}
/** 向工号列表发送指定标题和正文,默认使用首页链接,无返回值。 */
@Override
public void massSendMessage(List<String> loginNames, String title, String content) {
// 普通提醒也必须携带链接,未提供业务链接时使用系统首页。
this.doSend(loginNames, title, content, null, null);
}
/** 向工号列表发送正文与 PC/H5 链接,使用默认标题,无返回值。 */
@Override
public void massSendTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl) {
this.massSendTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl);
}
/** 向工号列表发送指定标题的卡片提醒,优先 H5 链接,无返回值。 */
@Override
public void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl) {
this.doSend(loginNames, title, content, pcUrl, mobileUrl);
}
/** 后台发送使用提交时取得的工号,不依赖工作线程的登录上下文;参数及返回约定见接口。 */
@Override
public void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser) {
this.doSend(loginNames, title, content, pcUrl, mobileUrl, fromUser);
}
/** 兼容原图文群发参数,使用默认标题,无返回值。 */
@Override
public void massSendPicTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl, String picUrl) {
this.massSendPicTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl, picUrl);
}
/** 接收工号列表、标题、正文、PC/H5 链接和图片 URL;图片不发送,无返回值。 */
@Override
public void massSendPicTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
// 文档无图片 URL 参数,保留调用入口但按普通卡片发送,不能将图片误填为附件 ID。
if (StrUtil.isNotBlank(picUrl)) {
log.warn("钉钉消息中心暂不支持图片 URL,本次仅发送正文与链接");
}
this.doSend(loginNames, title, content, pcUrl, mobileUrl);
}
/** 接收待撤回消息 ID;当前文档无撤回能力,仅记录未执行,无返回值。 */
@Override
public void recallMsg(String msgId) {
// 文档未提供撤回协议,保留接口兼容调用方,不再请求旧企业微信平台。
log.warn("钉钉消息中心文档未提供撤回接口,未执行撤回");
}
/**
* 接收工号列表、标题、正文和 PC/H5 链接;按文档逐人发送,无返回值。
* 正文映射为模板变量 content,单人失败记录后继续其余接收人,沿用原服务行为。
*/
private void doSend(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl) {
this.doSend(loginNames, title, content, pcUrl, mobileUrl, null);
}
/** fromUser 为后台任务传入的发送人工号;为空时沿用当前请求身份。发送结果写入日志。 */
private void doSend(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser) {
if (!Globals.MyConfig.getBoolean("AppSms", false) || !Globals.sso) {
log.info("钉钉消息提醒未发送:消息开关未开启或当前为开发模式");
return;
}
if (StrUtil.hasBlank(APP_ID, APP_SECRET, COMP_ID, TEMPLATE_ID)) {
log.warn("钉钉消息提醒未发送:请补充 appId、appSecret、compId、templateId");
return;
}
if (loginNames == null || loginNames.isEmpty() || StrUtil.isBlank(content)) {
log.warn("钉钉消息提醒未发送:接收人或正文为空");
return;
}
List<String> receivers = loginNames.stream().filter(StrUtil::isNotBlank)
.map(String::trim).distinct().toList();
if (receivers.isEmpty()) {
return;
}
final String linkUrl;
final String token;
try {
// 协议仅接受一个链接;优先移动端,其次 PC,普通消息使用系统首页。
String target = StrUtil.blankToDefault(mobileUrl, StrUtil.blankToDefault(pcUrl, Globals.AppDomain));
URI uri = URI.create(target);
URI resolved = uri.isAbsolute() ? uri : URI.create(Globals.AppDomain + "/").resolve(uri);
if (!("https".equalsIgnoreCase(resolved.getScheme()) || "http".equalsIgnoreCase(resolved.getScheme()))
|| StrUtil.isBlank(resolved.getHost())) {
throw new IllegalArgumentException("钉钉提醒链接必须为 HTTP/HTTPS 地址");
}
linkUrl = resolved.toString();
token = getAccessToken();
} catch (Exception e) {
// HTTP 异常可能带有含凭证的 URL,不输出异常正文或 Token。
log.error("钉钉消息提醒准备失败,异常类型:{}", e.getClass().getSimpleName());
return;
}
for (String userId : receivers) {
String messageId = UUID.randomUUID().toString().replace("-", "");
try {
// 外层 content 必须为 JSON 字符串,内层 content 对应模板中的 #{content}。
JSONObject params = new JSONObject();
params.set("id", messageId);
params.set("templateId", TEMPLATE_ID);
params.set("title", StrUtil.blankToDefault(title, "智慧工会"));
params.set("fromUser", StrUtil.isNotBlank(fromUser) ? fromUser : SecurityUtil.getUserLoginname());
params.set("userId", userId);
params.set("content", new JSONObject().set("content", content).toString());
params.set("detailInfo", new JSONObject().set("linkUrl", linkUrl).set("urlDes", "查看详情"));
log.info("钉钉消息提醒参数打印,detailInfo={}", params.toString());
JSONObject result = sendMessage(token, params);
log.info("钉钉消息提醒请求结果返回,result={}", result.toString());
String code = result.getStr("code");
// 凭证失效清理缓存并停止批次,下次发送重新获取;网络异常不重发,避免重复提醒。
if ("10002".equals(code)) {
invalidateToken(token);
log.error("钉钉消息提醒认证失效,msgId={},停止本批次发送", messageId);
break;
}
if (Boolean.TRUE.equals(result.getBool("status")) && ("1".equals(code) || "5".equals(code))) {
log.info("钉钉消息提醒发送成功,msgId={},code={}", messageId, code);
} else {
log.error("钉钉消息提醒发送未成功,msgId={},code={}", messageId, code);
}
} catch (Exception e) {
log.error("钉钉消息提醒发送异常,msgId={},异常类型:{}", messageId, e.getClass().getSimpleName());
}
}
}
/**
* 返回认证响应 result.access_token;result.expires_in 为有效秒数。
* 优先读取 Redis,多实例缓存未命中时仅允许持锁者申请,其余请求限时等待缓存。
*/
private String getAccessToken() {
String key = tokenCacheKey();
String lockKey = key + ":refresh";
String owner = UUID.randomUUID().toString();
long deadline = System.nanoTime() + REQUEST_TIMEOUT * 2L * 1_000_000L;
while (true) {
String cached = redisService.get(key);
if (StrUtil.isNotBlank(cached)) {
return cached;
}
if ("OK".equals(redisService.set(lockKey, owner, "NX", "EX", TOKEN_LOCK_SECONDS))) {
try {
// 取锁前其他实例可能刚写入缓存,二次读取避免重复认证。
cached = redisService.get(key);
return StrUtil.isNotBlank(cached) ? cached : requestAndCacheToken(key, lockKey, owner);
} finally {
// 仅释放自己的锁;Redis 异常不覆盖认证结果,锁仍会按 TTL 自动过期。
try {
redisService.eval(DELETE_IF_MATCH, List.of(lockKey), List.of(owner));
} catch (Exception e) {
log.warn("钉钉 Token 刷新锁释放失败,将自动过期,异常类型:{}", e.getClass().getSimpleName());
}
}
}
if (System.nanoTime() >= deadline) {
throw new IllegalStateException("等待钉钉 Token 刷新超时");
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("等待钉钉 Token 刷新被中断");
}
}
}
/**
* key 为 Token 缓存键,lockKey/owner 为当前刷新锁及归属标识,返回可发送消息的 Token。
* TTL 按 expires_in 扣除请求耗时及提前过期窗口计算,失败或锁过期时不写入缓存。
*/
private String requestAndCacheToken(String key, String lockKey, String owner) {
long requestedAt = System.nanoTime();
try (HttpResponse response = HttpRequest.get(BASE_URL + "/token")
.form("appId", APP_ID).form("appSecret", APP_SECRET).form("compId", COMP_ID)
.timeout(REQUEST_TIMEOUT).execute()) {
if (!response.isOk()) {
throw new IllegalStateException("消息中心认证 HTTP 状态异常");
}
JSONObject result = JSONUtil.parseObj(response.body());
JSONObject data = result.getJSONObject("result");
if (!Boolean.TRUE.equals(result.getBool("success")) || !"0".equals(result.getStr("code")) || data == null) {
throw new IllegalStateException("消息中心认证失败");
}
String value = data.getStr("access_token");
long expiresIn = data.getLong("expires_in", 0L);
if (StrUtil.isBlank(value) || expiresIn <= 0) {
throw new IllegalStateException("消息中心认证凭证或有效期为空");
}
long elapsedSeconds = (System.nanoTime() - requestedAt) / 1_000_000_000L + 1;
long ttl = expiresIn - Math.min(60, expiresIn / 10) - elapsedSeconds;
if (ttl <= 0 || ttl > Integer.MAX_VALUE) {
throw new IllegalStateException("消息中心 Token 缓存有效期不合法");
}
Object saved = redisService.eval(CACHE_IF_OWNER, List.of(lockKey, key),
List.of(owner, String.valueOf(ttl), value));
if (!Long.valueOf(1).equals(saved)) {
throw new IllegalStateException("消息中心 Token 刷新锁已失效");
}
return value;
}
}
/** token 为接口报告失效的旧凭证;Lua 原子比较删除,避免误删其他实例刷新的凭证。 */
private void invalidateToken(String token) {
redisService.eval(DELETE_IF_MATCH, List.of(tokenCacheKey()), List.of(token));
}
/** 按应用与单位隔离凭证;花括号确保 Token 和刷新锁在 Redis Cluster 中属于同一槽。 */
private String tokenCacheKey() {
return RedisConstant.REDIS_KEY_DING_MESSAGE_TOKEN + "{" + APP_ID + ":" + COMP_ID + "}";
}
/**
* token 为 URL 查询凭证,params 为单人消息 JSON;
* 返回含 status、code、message、info 的响应对象,调用方继续判断业务发送结果。
*/
private JSONObject sendMessage(String token, JSONObject params) {
String url = BASE_URL + "/message/send?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
try (HttpResponse response = HttpRequest.post(url).header("Content-Type", "application/json; charset=UTF-8").body(params.toString())
.timeout(REQUEST_TIMEOUT).execute()) {
if (!response.isOk()) {
throw new IllegalStateException("消息中心发送 HTTP 状态异常");
}
return JSONUtil.parseObj(response.body());
}
}
}
@@ -1,182 +0,0 @@
package com.budwk.app.base.sms.impl.jshvc;
import com.budwk.app.base.sms.SmsService;
import com.budwk.app.todo.platform.TodoPlatformConfig;
import com.budwk.app.web.commons.base.Globals;
import com.wisedu.casp.sdk.api.mc.ReceiverDto;
import com.wisedu.casp.sdk.api.mc.SendMsgRequest;
import com.wisedu.casp.sdk.api.mc.SendMsgResponse;
import com.wisedu.casp.sdk.api.mc.constants.MsgType_Constants;
import com.wisedu.casp.sdk.api.mc.constants.SendType_Constants;
import com.wisedu.casp.sdk.api.mc.constants.WxSendType_Constants;
import com.wisedu.casp.sdk.api.mc.recallMsg.RecallMsgRequest;
import com.wisedu.casp.sdk.api.mc.recallMsg.RecallMsgResponse;
import com.wisedu.casp.sdk.core.Client;
import com.wisedu.casp.sdk.core.DefaultClient;
import lombok.extern.slf4j.Slf4j;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Lang;
import java.util.List;
/**
* @ClassName SmsJshvcServiceImpl
* @Author JyuHsin
* @Date 2025/11/26 19:32
* @Version 1.0
* @Description TODO
*/
@Slf4j
@IocBean
public class SmsJshvcServiceImpl implements SmsService {
@Override
public void sendSingleMessage(String loginName, String content) {
this.sendSingleMessage(loginName, "智慧工会", content);
}
@Override
public void sendSingleMessage(String loginName, String title, String content) {
this.massSendMessage(List.of(loginName), title, content);
}
@Override
public void sendSingleTextCard(String loginName, String content, String pcUrl, String mobileUrl) {
this.sendSingleTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl);
}
@Override
public void sendSingleTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl) {
this.massSendTextCard(List.of(loginName), title, content, pcUrl, mobileUrl);
}
@Override
public void sendSinglePicTextCard(String loginName, String content, String pcUrl, String mobileUrl, String picUrl) {
this.sendSinglePicTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl, picUrl);
}
@Override
public void sendSinglePicTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
this.massSendPicTextCard(List.of(loginName), title, content, pcUrl, mobileUrl, picUrl);
}
@Override
public void massSendMessage(List<String> loginNames, String content) {
this.massSendMessage(loginNames, "智慧工会", content);
}
@Override
public void massSendMessage(List<String> loginNames, String title, String content) {
List<ReceiverDto> list = loginNames.stream().map(loginName -> {
ReceiverDto receiverDto = new ReceiverDto();
receiverDto.setUserId(loginName);
return receiverDto;
}).toList();
this.doSend(WxSendType_Constants.TEXT, list, title, content, null, null, null);
}
@Override
public void massSendTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl) {
this.massSendTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl);
}
@Override
public void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl) {
List<ReceiverDto> list = loginNames.stream().map(loginName -> {
ReceiverDto receiverDto = new ReceiverDto();
receiverDto.setUserId(loginName);
return receiverDto;
}).toList();
this.doSend(WxSendType_Constants.TEXT_CARD, list, title, content, pcUrl, mobileUrl, null);
}
@Override
public void massSendPicTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl, String picUrl) {
this.massSendPicTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl, picUrl);
}
@Override
public void massSendPicTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
List<ReceiverDto> list = loginNames.stream().map(loginName -> {
ReceiverDto receiverDto = new ReceiverDto();
receiverDto.setUserId(loginName);
return receiverDto;
}).toList();
this.doSend(WxSendType_Constants.NEWS, list, title, content, pcUrl, mobileUrl, picUrl);
}
@Override
public void recallMsg(String msgId) {
try {
Client client = new DefaultClient(TodoPlatformConfig.TODO_PLATFORM_BASE_URL, TodoPlatformConfig.TODO_APP_ID, TodoPlatformConfig.TODO_APP_SECRET);
RecallMsgRequest request = new RecallMsgRequest();
request.setMsgId(msgId);
log.info("撤回企业微信消息,请求参数:{}", Json.toJson(request));
RecallMsgResponse response = client.execute(request);
log.info("撤回企业微信消息,接口响应数据:{}", Json.toJson(response));
} catch (Exception e) {
log.error("撤回企业微信消息失败", e);
}
}
private void doSend(String sendType,
List<ReceiverDto> receivers,
String title,
String content,
String pcUrl,
String mobileUrl,
String picUrl) {
if (!Globals.MyConfig.getBoolean("AppSms", false)) {
log.error("【log】消息配置未开启");
return;
}
if (!Globals.sso) {
log.error("【log】开发模式不允许发送企业微信通知");
return;
}
if (Lang.isEmpty(receivers)) {
log.error("send fail by receivers is null");
return;
}
try {
// 创建客户端
Client client = new DefaultClient(TodoPlatformConfig.TODO_PLATFORM_BASE_URL, TodoPlatformConfig.TODO_APP_ID, TodoPlatformConfig.TODO_APP_SECRET);
SendMsgRequest request = new SendMsgRequest();
request.setSubject(title);
request.setContent(content);
request.setMsgType(MsgType_Constants.COMMON);
request.setSendType(SendType_Constants.WECHAT_ENTERPRISE);
request.setWxSendType(sendType);
request.setReceivers(receivers);
if (WxSendType_Constants.TEXT_CARD.equals(sendType)) {
request.setPcUrl(pcUrl);
request.setMobileUrl(mobileUrl);
request.setUrlDesc("查看详情");
}
if (WxSendType_Constants.NEWS.equals(sendType)) {
request.setPcUrl(pcUrl);
request.setMobileUrl(mobileUrl);
request.setUrlDesc("查看详情");
request.setQyWeChatImgUrl(picUrl);
}
log.info("发送企业微信通知,请求参数:{}", Json.toJson(request));
SendMsgResponse response = client.execute(request);
log.info("发送企业微信通知,接口响应数据:{}", Json.toJson(response));
if (response.isSuccess() && response.getStatus() == 200) {
// TODO 消息Id,后面可以存着,撤回要用到
String msgId = response.getMsgId();
log.info("发送企业微信通知成功: {}, msg: {}", response.getStatus(), response.getMsg());
} else {
log.info("发送企业微信通知失败:{}", response.getMsg());
}
} catch (Exception e) {
log.error("发送企业微信通知失败", e);
}
}
}
@@ -24,7 +24,9 @@ import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.*;
@@ -143,6 +145,43 @@ public class SysFileController {
return Result.success().addData(sysFileService.convertHtml(file));
}
/**
* 将PDF逐页转换为图片富文本。
*
* @param file 上传的PDF文件
* @return Result,data为按PDF页码顺序组成的图片HTML
*/
@At
@SaCheckLogin
@ApiOperation("文件管理-PDF转换图片富文本")
@AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"})
@Aop(TransAop.READ_COMMITTED)
public Result convertPdfToImageHtml(TempFile file) {
if (file == null || !"pdf".equalsIgnoreCase(FileUtil.extName(file.getSubmittedFileName()))) {
return Result.error("请上传PDF文件");
}
return Result.success().addData(sysFileService.convertPdfToImageHtml(file));
}
/**
* 将Excel的每个非空页签转换为独立HTML表格。
*
* @param file 上传的xls或xlsx文件
* @return Result,data为按页签顺序组成的标题及HTML表格
*/
@At
@SaCheckLogin
@ApiOperation("文件管理-Excel转换表格富文本")
@AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"})
@Aop(TransAop.READ_COMMITTED)
public Result convertExcelToHtml(TempFile file) {
String extension = file == null ? "" : FileUtil.extName(file.getSubmittedFileName());
if (!"xls".equalsIgnoreCase(extension) && !"xlsx".equalsIgnoreCase(extension)) {
return Result.error("请上传xls或xlsx文件");
}
return Result.success().addData(sysFileService.convertExcelToHtml(file));
}
@At
@ApiOperation("文件管理-删除文件")
@@ -199,7 +199,7 @@ public class Sys_user extends BaseModel implements Serializable {
@Column
@Comment("编制类别码")
@ColDefine(type = ColType.VARCHAR, width = 30)
@DataCenterColumn(name = "编制类型", key = "BZLB")
@DataCenterColumn(name = "编制类型", key = "yrxsmc")
private String preparedBy;
@Column
@@ -386,4 +386,16 @@ public class Sys_user extends BaseModel implements Serializable {
@ColDefine(type = ColType.VARCHAR, width = 50)
@DataCenterColumn(name = "党政职务", key = "DZZW")
private String position;
@Column
@Comment("人员类别码")
@ColDefine(type = ColType.VARCHAR, width = 50)
@DataCenterColumn(name = "人员类别码", key = "rylbm")
private String rylbm;
@Column
@Comment("在岗情况码")
@ColDefine(type = ColType.VARCHAR, width = 50)
@DataCenterColumn(name = "在岗情况码", key = "zgqkm")
private String zgqkm;
}
@@ -71,4 +71,20 @@ public interface SysFileService extends BaseService<Sys_file> {
* 转换为HTML
*/
String convertHtml(TempFile file);
/**
* 将PDF逐页转换为图片并生成富文本HTML。
*
* @param file PDF文件,文件内容不能为空
* @return 按PDF页码顺序排列的图片HTML
*/
String convertPdfToImageHtml(TempFile file);
/**
* 将Excel中的非空页签转换为富文本HTML表格。
*
* @param file xls或xlsx文件,文件内容不能为空
* @return 按页签顺序排列的标题及HTML表格
*/
String convertExcelToHtml(TempFile file);
}
@@ -23,6 +23,7 @@ import com.budwk.app.zhgh.democratic.proposal.models.ProposalConfig;
import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin;
import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeType;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
@@ -163,9 +164,9 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService
Cnd cnd = Cnd.where(Sys_user_source::getPullTime, "=", updateParam.getPullTime());
// 处理复杂条件
if (updateParam.getConditionGroup() != null) {
// if (updateParam.getConditionGroup() != null) {
// cnd = ConditionGroupUtil.applyConditionGroup(cnd, updateParam.getConditionGroup());
}
// }
List<Sys_user_source> sources = dao.query(Sys_user_source.class, cnd.groupBy("loginname"));
log.info("符合条件的数据源记录数: {}", sources.size());
@@ -181,6 +182,9 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService
List<String> addMemberUserIds = Collections.synchronizedList(new ArrayList<>());
List<String> removeMemberUserIds = Collections.synchronizedList(new ArrayList<>());
List<String> preparedByList = List.of("人才市场派遣", "人力资源服务中心派遣", "海川派遣", "越仕中心", "柔性引进");
List<String> rylbmList = List.of("退休返聘", "临聘");
// 处理每条数据
for (Sys_user_source source : sources) {
Sys_user user = userMap.get(source.getLoginname());
@@ -195,8 +199,11 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService
u.setSalt(salt);
u.setPassword(PwdUtil.getPassword(PwdUtil.generate(12), salt));
if (u.getMember()) {
//addMemberUserIds.add(u.getId());
if (u.getUserState().equals("在职")
&& (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy()))
&& (StringUtils.isBlank(u.getRylbm()) || !rylbmList.contains(u.getRylbm()))) {
addMemberUserIds.add(u.getId());
u.setMember(true);
}
needInitUserList.add(u);
} else {
@@ -205,12 +212,16 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService
u.setMember(null);
u.setMemberTime(null);
// 更新会员状态
boolean currentIsMember = user.getMember() != null && user.getMember();
if (!currentIsMember) {
//addMemberUserIds.add(user.getId());
} else{
//removeMemberUserIds.add(user.getId());
if (u.getUserState().equals("在职")
&& (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy()))
&& (StringUtils.isBlank(u.getRylbm()) || !rylbmList.contains(u.getRylbm()))) {
if (!user.getMember()) {
addMemberUserIds.add(u.getId());
u.setMember(true);
}
} else {
removeMemberUserIds.add(user.getId());
u.setMember(false);
}
needDoUpdateList.add(u);
@@ -23,16 +23,53 @@ import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.sys.utils.SysFileLocalUtil;
import com.budwk.app.sys.utils.SysFileMinIoUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.poi.hssf.usermodel.HSSFClientAnchor;
import org.apache.poi.hssf.usermodel.HSSFPatriarch;
import org.apache.poi.hssf.usermodel.HSSFPicture;
import org.apache.poi.hssf.usermodel.HSSFShape;
import org.apache.poi.hssf.usermodel.HSSFSheet;
import org.apache.poi.hssf.util.HSSFColor;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.PictureData;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFPicture;
import org.apache.poi.xssf.usermodel.XSSFShape;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.jsoup.Jsoup;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Strings;
import org.nutz.lang.random.R;
import org.nutz.mvc.upload.TempFile;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
@@ -41,7 +78,12 @@ import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.Base64;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -199,6 +241,370 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
}
}
@Override
@Aop(TransAop.READ_COMMITTED)
public String convertPdfToImageHtml(TempFile file) {
validateFileExtension(file, List.of("pdf"), "请上传PDF文件");
try (PDDocument document = Loader.loadPDF(file.getFile())) {
validatePdfSafety(document);
if (document.getNumberOfPages() < 1) {
throw new BaseException("PDF文件中没有可导入的页面");
}
PDFRenderer renderer = new PDFRenderer(document);
StringBuilder html = new StringBuilder();
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
// 144 DPI兼顾文字清晰度和图片体积,页面顺序与原PDF保持一致。
BufferedImage pageImage = renderer.renderImageWithDPI(pageIndex, 144, ImageType.RGB);
try (ByteArrayOutputStream imageOutput = new ByteArrayOutputStream()) {
ImageIO.write(pageImage, "png", imageOutput);
String imageUrl = storageFile(SysFileEngineTypeEnum.MINIO.getValue(),
R.UU32() + ".png", imageOutput.toByteArray(), false);
html.append("<p style=\"margin:0 0 12px;\">")
.append("<img src=\"").append(imageUrl)
.append("\" style=\"display:block;max-width:100%;height:auto;margin:0 auto;\">")
.append("</p>");
} finally {
pageImage.flush();
}
}
return html.toString();
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("PDF转换图片异常", e);
throw new BaseException("PDF文件读取失败,请确认文件未损坏");
}
}
@Override
@Aop(TransAop.READ_COMMITTED)
public String convertExcelToHtml(TempFile file) {
validateFileExtension(file, List.of("xls", "xlsx"), "请上传xls或xlsx文件");
try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) {
DataFormatter formatter = new DataFormatter();
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
StringBuilder html = new StringBuilder();
int tableCount = 0;
for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) {
Sheet sheet = workbook.getSheetAt(sheetIndex);
String tableHtml = buildSheetHtml(workbook, sheet, formatter, evaluator);
if (StrUtil.isBlank(tableHtml)) {
continue;
}
tableCount++;
html.append("<div style=\"margin-bottom:20px;\">")
.append("<h3 style=\"margin:0 0 10px;font-size:16px;\">")
.append(Strings.escapeHtml(sheet.getSheetName()))
.append("</h3>")
.append("<div style=\"max-width:100%;overflow-x:auto;\">")
.append(tableHtml)
.append("</div></div>");
}
if (tableCount < 1) {
throw new BaseException("Excel文件中没有可导入的表格内容");
}
return html.toString();
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("Excel转换HTML异常", e);
throw new BaseException("Excel文件读取失败,请确认文件格式正确且未损坏");
}
}
/**
* 将单个非空页签转换为HTML表格,合并单元格使用rowspan和colspan还原。
*/
private String buildSheetHtml(Workbook workbook, Sheet sheet, DataFormatter formatter,
FormulaEvaluator evaluator) {
List<ExcelEmbeddedImage> embeddedImages = extractSheetImages(sheet);
int minRow = Integer.MAX_VALUE;
int maxRow = -1;
int minColumn = Integer.MAX_VALUE;
int maxColumn = -1;
for (Row row : sheet) {
for (Cell cell : row) {
if (StrUtil.isBlank(getCellDisplayValue(cell, formatter, evaluator))) {
continue;
}
minRow = Math.min(minRow, cell.getRowIndex());
maxRow = Math.max(maxRow, cell.getRowIndex());
minColumn = Math.min(minColumn, cell.getColumnIndex());
maxColumn = Math.max(maxColumn, cell.getColumnIndex());
}
}
// Excel图片存放在绘图层,即使锚定单元格没有文字,也必须纳入表格范围。
for (ExcelEmbeddedImage image : embeddedImages) {
minRow = Math.min(minRow, image.rowIndex);
maxRow = Math.max(maxRow, image.rowIndex);
minColumn = Math.min(minColumn, image.columnIndex);
maxColumn = Math.max(maxColumn, image.columnIndex);
}
if (maxRow < 0 || maxColumn < 0) {
return "";
}
// 内容位于合并区域时,将区域边界纳入表格范围,避免合并内容被截断。
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.getFirstRow() <= maxRow && range.getLastRow() >= minRow
&& range.getFirstColumn() <= maxColumn && range.getLastColumn() >= minColumn) {
minRow = Math.min(minRow, range.getFirstRow());
maxRow = Math.max(maxRow, range.getLastRow());
minColumn = Math.min(minColumn, range.getFirstColumn());
maxColumn = Math.max(maxColumn, range.getLastColumn());
}
}
Map<String, CellRangeAddress> mergedStarts = new HashMap<>();
Set<String> mergedCovered = new HashSet<>();
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.getLastRow() < minRow || range.getFirstRow() > maxRow
|| range.getLastColumn() < minColumn || range.getFirstColumn() > maxColumn) {
continue;
}
mergedStarts.put(cellKey(range.getFirstRow(), range.getFirstColumn()), range);
for (int rowIndex = range.getFirstRow(); rowIndex <= range.getLastRow(); rowIndex++) {
for (int columnIndex = range.getFirstColumn(); columnIndex <= range.getLastColumn(); columnIndex++) {
if (rowIndex != range.getFirstRow() || columnIndex != range.getFirstColumn()) {
mergedCovered.add(cellKey(rowIndex, columnIndex));
}
}
}
}
Map<String, List<String>> imageUrlsByCell = new HashMap<>();
for (ExcelEmbeddedImage image : embeddedImages) {
int imageRow = image.rowIndex;
int imageColumn = image.columnIndex;
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.isInRange(imageRow, imageColumn)) {
imageRow = range.getFirstRow();
imageColumn = range.getFirstColumn();
break;
}
}
imageUrlsByCell.computeIfAbsent(cellKey(imageRow, imageColumn), key -> new ArrayList<>())
.add(image.imageUrl);
}
StringBuilder html = new StringBuilder("<table style=\"border-collapse:collapse;width:100%;table-layout:auto;\">");
for (int rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {
Row row = sheet.getRow(rowIndex);
html.append("<tr>");
for (int columnIndex = minColumn; columnIndex <= maxColumn; columnIndex++) {
String key = cellKey(rowIndex, columnIndex);
if (mergedCovered.contains(key)) {
continue;
}
Cell cell = row == null ? null : row.getCell(columnIndex);
CellRangeAddress range = mergedStarts.get(key);
html.append("<td");
if (range != null) {
int rowSpan = range.getLastRow() - range.getFirstRow() + 1;
int columnSpan = range.getLastColumn() - range.getFirstColumn() + 1;
if (rowSpan > 1) {
html.append(" rowspan=\"").append(rowSpan).append("\"");
}
if (columnSpan > 1) {
html.append(" colspan=\"").append(columnSpan).append("\"");
}
}
html.append(" style=\"").append(buildCellStyle(workbook, cell)).append("\">")
.append(buildCellHtml(cell, formatter, evaluator, imageUrlsByCell.get(key)))
.append("</td>");
}
html.append("</tr>");
}
return html.append("</table>").toString();
}
/**
* 提取页签绘图层中的嵌入图片,图片锚点用于确定其所属单元格。
*/
private List<ExcelEmbeddedImage> extractSheetImages(Sheet sheet) {
List<ExcelEmbeddedImage> images = new ArrayList<>();
if (sheet instanceof XSSFSheet xssfSheet) {
XSSFDrawing drawing = xssfSheet.getDrawingPatriarch();
if (drawing != null) {
for (XSSFShape shape : drawing.getShapes()) {
if (shape instanceof XSSFPicture picture && picture.getAnchor() instanceof XSSFClientAnchor anchor) {
addExcelEmbeddedImage(images, anchor.getRow1(), anchor.getCol1(), picture.getPictureData());
}
}
}
} else if (sheet instanceof HSSFSheet hssfSheet) {
HSSFPatriarch drawing = hssfSheet.getDrawingPatriarch();
if (drawing != null) {
for (HSSFShape shape : drawing.getChildren()) {
if (shape instanceof HSSFPicture picture && picture.getAnchor() instanceof HSSFClientAnchor anchor) {
addExcelEmbeddedImage(images, anchor.getRow1(), anchor.getCol1(), picture.getPictureData());
}
}
}
}
return images;
}
/**
* 将Excel嵌入图片写入现有文件存储,并记录图片对应的单元格位置。
*/
private void addExcelEmbeddedImage(List<ExcelEmbeddedImage> images, int rowIndex, int columnIndex,
PictureData pictureData) {
if (pictureData == null || pictureData.getData() == null || pictureData.getData().length < 1) {
return;
}
String extension = StrUtil.blankToDefault(pictureData.suggestFileExtension(), "png")
.replaceAll("[^a-zA-Z0-9]", "");
if (StrUtil.isBlank(extension)) {
extension = "png";
}
String imageUrl = storageFile(SysFileEngineTypeEnum.MINIO.getValue(),
R.UU32() + "." + extension.toLowerCase(), pictureData.getData(), false);
images.add(new ExcelEmbeddedImage(rowIndex, columnIndex, imageUrl));
}
private String getCellDisplayValue(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) {
if (cell == null) {
return "";
}
try {
return formatter.formatCellValue(cell, evaluator);
} catch (Exception ignored) {
return formatter.formatCellValue(cell);
}
}
private String formatCellHtml(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) {
String value = Strings.escapeHtml(getCellDisplayValue(cell, formatter, evaluator));
return StrUtil.isBlank(value) ? "&nbsp;" : value.replace("\r\n", "<br>").replace("\n", "<br>");
}
private String buildCellHtml(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator,
List<String> imageUrls) {
String textHtml = formatCellHtml(cell, formatter, evaluator);
if (imageUrls == null || imageUrls.isEmpty()) {
return textHtml;
}
StringBuilder html = new StringBuilder();
if (!"&nbsp;".equals(textHtml)) {
html.append("<div style=\"margin-bottom:6px;\">").append(textHtml).append("</div>");
}
for (String imageUrl : imageUrls) {
html.append("<img src=\"").append(imageUrl)
.append("\" style=\"display:block;max-width:100%;max-height:240px;height:auto;margin:4px auto;object-fit:contain;\">");
}
return html.toString();
}
private String buildCellStyle(Workbook workbook, Cell cell) {
StringBuilder styleText = new StringBuilder("padding:6px 8px;border:1px solid #dcdfe6;");
if (cell == null) {
return styleText.toString();
}
CellStyle cellStyle = cell.getCellStyle();
HorizontalAlignment alignment = cellStyle.getAlignment();
if (alignment == HorizontalAlignment.CENTER || alignment == HorizontalAlignment.CENTER_SELECTION) {
styleText.append("text-align:center;");
} else if (alignment == HorizontalAlignment.RIGHT) {
styleText.append("text-align:right;");
} else {
styleText.append("text-align:left;");
}
VerticalAlignment verticalAlignment = cellStyle.getVerticalAlignment();
if (verticalAlignment == VerticalAlignment.TOP) {
styleText.append("vertical-align:top;");
} else if (verticalAlignment == VerticalAlignment.BOTTOM) {
styleText.append("vertical-align:bottom;");
} else {
styleText.append("vertical-align:middle;");
}
styleText.append(cellStyle.getWrapText() ? "white-space:pre-wrap;" : "white-space:nowrap;");
Font font = workbook.getFontAt(cellStyle.getFontIndex());
if (font.getBold()) {
styleText.append("font-weight:bold;");
}
if (font.getItalic()) {
styleText.append("font-style:italic;");
}
if (font.getFontHeightInPoints() > 0) {
styleText.append("font-size:").append(font.getFontHeightInPoints()).append("pt;");
}
appendIndexedColor(styleText, "color", font.getColor());
if (cellStyle.getFillPattern() != FillPatternType.NO_FILL) {
appendIndexedColor(styleText, "background-color", cellStyle.getFillForegroundColor());
}
if (cellStyle.getBorderTop() != BorderStyle.NONE || cellStyle.getBorderRight() != BorderStyle.NONE
|| cellStyle.getBorderBottom() != BorderStyle.NONE || cellStyle.getBorderLeft() != BorderStyle.NONE) {
styleText.append("border:1px solid #909399;");
}
return styleText.toString();
}
private void appendIndexedColor(StringBuilder styleText, String cssName, short colorIndex) {
try {
HSSFColor color = HSSFColor.getIndexHash().get(colorIndex);
if (color != null) {
short[] rgb = color.getTriplet();
styleText.append(cssName).append(":#")
.append(String.format("%02x%02x%02x", rgb[0], rgb[1], rgb[2]))
.append(";");
}
} catch (Exception ignored) {
// 自定义主题色无法直接映射时保留浏览器默认颜色,不影响表格内容导入。
}
}
private String cellKey(int rowIndex, int columnIndex) {
return rowIndex + "_" + columnIndex;
}
/**
* Excel嵌入图片及其左上角锚点信息。
*/
private static class ExcelEmbeddedImage {
private final int rowIndex;
private final int columnIndex;
private final String imageUrl;
private ExcelEmbeddedImage(int rowIndex, int columnIndex, String imageUrl) {
this.rowIndex = rowIndex;
this.columnIndex = columnIndex;
this.imageUrl = imageUrl;
}
}
private void validateFileExtension(TempFile file, List<String> allowedExtensions, String errorMessage) {
if (file == null || file.getFile() == null || !file.getFile().exists()) {
throw new BaseException("文件内容为空");
}
String extension = FileUtil.extName(file.getSubmittedFileName());
if (StrUtil.isBlank(extension) || !allowedExtensions.contains(extension.toLowerCase())) {
throw new BaseException(errorMessage);
}
}
private void validatePdfSafety(PDDocument document) {
COSDictionary catalog = document.getDocumentCatalog().getCOSObject();
if (catalog.containsKey(COSName.NAMES)) {
COSDictionary names = (COSDictionary) catalog.getDictionaryObject(COSName.NAMES);
if (names != null && names.containsKey(COSName.JAVA_SCRIPT)) {
throw new BaseException("系统检测到此PDF文件具有一定危险性");
}
}
if (catalog.containsKey(COSName.OPEN_ACTION)) {
Object openAction = catalog.getDictionaryObject(COSName.OPEN_ACTION);
if (openAction instanceof COSDictionary action
&& COSName.JAVA_SCRIPT.equals(action.getDictionaryObject(COSName.S))) {
throw new BaseException("系统检测到此PDF文件具有一定危险性");
}
}
}
/**
* 存储文件
**/
@@ -57,6 +57,6 @@ public class SysUserAllRenewJob implements Job {
param.setUpdateMode(SysDataUpdateMode.ALL.name());
param.setConditionGroup(conditionGroup);
// sysDataUserUpdateService.update(param);
sysDataUserUpdateService.update(param);
}
}
@@ -115,7 +115,7 @@ public class ActivityBasicScopeController {
""");
try {
Cnd cnd = getCnd(pageForm);
cnd.and("u.member", "=", 1);
// cnd.and("u.member", "=", 1);
sql.setCondition(cnd);
Pagination pagination = baseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
return Result.success(pagination);
@@ -199,7 +199,7 @@ public class ActivityBasicScopeController {
$condition
""");
Cnd cnd = Cnd.NEW();
if (!StpUtil.hasRole(RoleConstant.SYSADMIN.name()) && !StpUtil.hasRole(RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (!canViewAllScopeUsers()) {
SqlExpressionGroup group = new SqlExpressionGroup();
group.or("creator", "=", SecurityUtil.getUserId());
if(AuthUtil.hasRole(RoleConstant.CLUB_PRESIDENT.name())) {
@@ -234,16 +234,17 @@ public class ActivityBasicScopeController {
}
/**
* 判断用户权限及获取工会id
* 获取参加人员范围弹窗所需的当前用户角色和所属工会信息。
* 无请求参数;返回 Result,data 中包含校工会管理员、校工会活动管理员、分工会角色、
* 系统管理员判断结果及当前用户所属工会 id,供前端控制全校人员筛选条件。
*
* @return java.lang.Object
* @author zhf
* @description
* @return 当前用户的活动人员范围权限信息
*/
@At
public Result getRolesAndUnion() {
return Result.success(new NutMap() {{
addv("is_A06", StpUtil.hasRole(RoleConstant.SCHOOL_UNION_ADMIN.name()));
addv("is_school_union_activity_admin", StpUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name()));
addv("is_H04", AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_WENTI_SPORTS.name(), RoleConstant.BRANCH_UNION_XJSHAN_PROMOTION.name(), RoleConstant.BRANCH_UNION_WENTI_WY.name()));
addv("is_sysadmin", StpUtil.hasRole(RoleConstant.SYSADMIN.name()));
addv("unionid", SecurityUtil.getUnionId());
@@ -377,7 +378,7 @@ public class ActivityBasicScopeController {
try {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())){
if (!canViewAllScopeUsers()) {
cnd.andEX("u.unionid", "=", SecurityUtil.getUnionId());
}
cnd.andEX("u.unionid", EQ_OR_NEQ_OP, activityUserScopePageParam.getUnionId());
@@ -393,11 +394,25 @@ public class ActivityBasicScopeController {
throw new RuntimeException(e);
}
cnd.and("u.member", "=", 1);
// cnd.and("u.member", "=", 1);
return cnd;
}
/**
* 判断当前用户是否拥有活动参加人员范围的全校人员查看权限。
* 系统管理员、校工会管理员和校工会活动管理员可查看全部人员,其他角色仍按所属工会限制。
*
* @return true 表示可查看全校人员,false 表示仅可查看所属工会人员
*/
private boolean canViewAllScopeUsers() {
return AuthUtil.hasRoleOr(
RoleConstant.SYSADMIN.name(),
RoleConstant.SCHOOL_UNION_ADMIN.name(),
RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name()
);
}
@At
@Ok("void")
@@ -71,7 +71,15 @@ public class ActivityBasicScopeUserDataController {
public void index() {
}
/**
* 分页查询已加入活动分组的人员。
* pageForm 传递分页、排序和姓名/工号关键字;unionId、unitId、personType、userState、groupId
* 分别传递工会、单位、教职工类别、在职状态和活动分组筛选值;existsLoginNameRedisKey
* 用于限制导入核对产生的工号集合。返回 Result,data 为包含 list 和 totalCount 的分页结果。
* 校工会活动管理员可查看全部活动分组人员,但删除权限仍沿用原有创建人限制。
*
* @return 活动分组人员分页结果
*/
@At
@SaCheckPermission("activity.basic.user")
public Result pageData(PageForm pageForm,
@@ -116,8 +124,10 @@ public class ActivityBasicScopeUserDataController {
cnd.andEX("u.unitid", "=", unitId);
cnd.andEX("u.personType", "=", personType);
cnd.andEX("u.userState", "=", userState);
// 校工会活动管理员在设置参加人员范围时,可查看其他管理员已建立的人员分组。
if (!StpUtil.hasRole(RoleConstant.SYSADMIN.name()) &&
!StpUtil.hasRole(RoleConstant.SCHOOL_UNION_ADMIN.name())) {
!StpUtil.hasRole(RoleConstant.SCHOOL_UNION_ADMIN.name()) &&
!StpUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name())) {
cnd.and("aus.creator", "=", SecurityUtil.getUserId());
}
if (StrUtil.isNotBlank(existsLoginNameRedisKey)) {
@@ -14,6 +14,7 @@ import com.budwk.app.sys.models.Sys_unit;
import com.budwk.app.zhgh.activity.trainSignUp.models.*;
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
import com.budwk.app.zhgh.club.model.SysClub;
import com.budwk.app.zhgh.welfare.service.WelfareProjectService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
@@ -22,6 +23,8 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -52,6 +55,8 @@ public class TrainSignUpManageController {
private TrainSignUpActivityService trainSignUpActivityManageService;
@Inject
private Dao dao;
@Inject
private WelfareProjectService welfareProjectService;
@At("")
@SaCheckPermission("trainSignUp.manage")
@@ -90,6 +95,34 @@ public class TrainSignUpManageController {
return Result.success();
}
/** 接收主活动和子活动 ID;Result.data 为当前预计接收人数。 */
@At
@ApiOperation("查询子活动报名提醒人数")
@SaCheckPermission("trainSignUp.manage")
public Result reminderRecipientCount(@Param("activityId") String activityId, @Param("courseId") String courseId) {
try {
return Result.success().addData(trainSignUpActivityManageService.countReminderRecipients(activityId, courseId));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
/** 接收主活动、子活动 ID 和提醒正文;成功表示已提交后台发送,data 为本批次人数。 */
@At
@ApiOperation("提交子活动报名提醒")
@SaCheckPermission("trainSignUp.manage")
@SLog(tag = "品牌活动-活动管理", msg = "提交子活动报名提醒")
@Aop(TransAop.READ_COMMITTED)
public Result submitSignupReminder(@Param("activityId") String activityId, @Param("courseId") String courseId,
@Param("content") String content) {
try {
int count = trainSignUpActivityManageService.submitSignupReminder(activityId, courseId, content);
return Result.success("已提交发送,接收人数:" + count).addData(count);
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("活动状态变更")
@SaCheckPermission("trainSignUp.manage")
@@ -153,13 +186,25 @@ public class TrainSignUpManageController {
@ApiOperation("品牌活动新增/修改")
@SaCheckPermission("trainSignUp.manage")
@SLog(tag = "品牌活动-活动管理", msg = "新增/修改活动")
@Aop(TransAop.READ_COMMITTED)
public Result doHandle(TrainSignUpActivity activity) {
if (StrUtil.isBlank(activity.getId())) {
trainSignUpActivityManageService.add(activity, null);
} else {
trainSignUpActivityManageService.edit(activity);
try {
if (StrUtil.isBlank(activity.getId())) {
trainSignUpActivityManageService.add(activity, null);
} else {
trainSignUpActivityManageService.edit(activity);
}
return Result.success();
} catch (IllegalArgumentException e) {
return Result.error(99, e.getMessage());
}
return Result.success();
}
@At
@ApiOperation("查询签到扩展可关联的福利项目")
@SaCheckPermission("trainSignUp.manage")
public Result getWelfareProjects(Integer year) {
return Result.success(welfareProjectService.getWelfareList(year));
}
@At
@@ -10,6 +10,7 @@ import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.DistanceUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpCourse;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivity;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUserCourse;
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
import io.swagger.annotations.Api;
@@ -23,6 +24,7 @@ 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 org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@@ -75,6 +77,18 @@ public class TrainsignUpMineController {
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
@SLog(tag = "品牌活动-活动签到", msg = "主动扫码签到")
public Result drivingScan(String courseId, Double lat, Double lng) {
TrainSignUpCourse extensionCourse = dao.fetch(TrainSignUpCourse.class, courseId);
TrainSignUpActivity extensionActivity = extensionCourse == null ? null
: dao.fetch(TrainSignUpActivity.class, extensionCourse.getActivityId());
if (extensionActivity != null && extensionActivity.getSignPointCount() != null
&& extensionActivity.getSignPointCount() > 0) {
try {
return Result.success("签到成功").addData(activityService.handleScanCodeSign(
courseId, SecurityUtil.getUserId(), lat, lng));
} catch (IllegalArgumentException e) {
return Result.error(99, e.getMessage());
}
}
// 主动扫码签到是用户自己打开扫一扫,扫二维码签到
int count = dao.count(TrainSignUpUserCourse.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
@@ -113,6 +127,42 @@ public class TrainsignUpMineController {
return Result.success("签到成功");
}
/**
* @param courseId 扫码二维码中携带的课程点位ID
* @return Result.data为地图点位、签到进度、处理方式和动态表单配置
*/
@At
@ApiOperation("查询签到扩展页面数据")
@SaCheckPermission("h5.trainSignUp.mine")
public Result getSignData(String courseId) {
try {
return Result.success().addData(activityService.getScanCodeSignData(courseId, SecurityUtil.getUserId()));
} catch (IllegalArgumentException e) {
return Result.error(99, e.getMessage());
}
}
/**
* @param courseId 当前扫码课程ID
* @param mobileColumnsValue JSON数组,每项包含字段编码和值
* @return Result,code为0表示保存成功,99表示业务校验未通过
*/
@At
@ApiOperation("保存签到达标后的填写信息")
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("h5.trainSignUp.mine")
public Result saveDynamicForm(String courseId, String mobileColumnsValue) {
if (courseId == null || mobileColumnsValue == null) {
return Result.error(99, "请填写签到信息");
}
try {
activityService.saveScanCodeDynamicForm(courseId, SecurityUtil.getUserId(), mobileColumnsValue);
return Result.success("保存成功");
} catch (IllegalArgumentException e) {
return Result.error(99, e.getMessage());
}
}
@At
@ApiOperation("被动扫码签到")
@Aop(TransAop.READ_COMMITTED)
@@ -217,6 +267,13 @@ public class TrainsignUpMineController {
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
public Result fetchCourse(String courseId) {
TrainSignUpCourse course = dao.fetch(TrainSignUpCourse.class, courseId);
return Result.success(course);
if (course == null) {
return Result.error(99, "未找到签到课程");
}
TrainSignUpActivity activity = dao.fetch(TrainSignUpActivity.class, course.getActivityId());
NutMap data = Lang.obj2nutmap(course);
data.put("signExtensionEnabled", activity != null && activity.getSignPointCount() != null
&& activity.getSignPointCount() > 0);
return Result.success(data);
}
}
@@ -124,6 +124,21 @@ public class TrainSignUpActivity extends BaseModel implements Serializable, SysH
@ColDefine(type = ColType.MYSQL_JSON)
private List<String> helpUnits;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到达标点位数,空或0表示未开启签到扩展")
private Integer signPointCount;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到达标处理方式:1填写信息,2选择福利,3无")
private Integer signHandleType;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("签到达标后关联的福利项目ID")
private String welfareProjectId;
@Override
public Sys_home_activity covertToSysHomeActivity() {
Sys_home_activity sysHomeActivity = new Sys_home_activity();
@@ -77,6 +77,12 @@ public interface TrainSignUpActivityService extends BaseService<TrainSignUpActiv
*/
void doSignUp(TrainSignUpUser trainSignUpUser) throws Exception;
/** activityId、courseId 分别为主活动和子活动 ID;返回该子活动可提醒的未报名人数。 */
int countReminderRecipients(String activityId, String courseId);
/** 根据主活动、子活动 ID 和管理员填写的正文提交钉钉提醒;返回本批次接收人数。 */
int submitSignupReminder(String activityId, String courseId, String content);
/**
* 异步插入每个报名成功人员的课程数据
*
@@ -128,4 +134,33 @@ public interface TrainSignUpActivityService extends BaseService<TrainSignUpActiv
List<NutMap> qdInfoByUserId(String userId, String activityId);
List<TrainSignUpCourse> filterCourseByHostUnion(List<TrainSignUpCourse> courseList);
/**
* 查询扩展签到页面数据。
*
* @param courseId 扫码进入的课程点位ID
* @param userId 当前登录用户ID
* @return 地图点位、签到进度、达标处理方式及动态表单字段
*/
NutMap getScanCodeSignData(String courseId, String userId);
/**
* 完成扩展点位签到。
*
* @param courseId 扫码进入的课程点位ID
* @param userId 当前登录用户ID
* @param latitude 浏览器定位得到的WGS84纬度
* @param longitude 浏览器定位得到的WGS84经度
* @return 最新签到进度及达标后的处理信息
*/
NutMap handleScanCodeSign(String courseId, String userId, Double latitude, Double longitude);
/**
* 保存签到达标后填写的动态表单。
*
* @param courseId 当前扫码课程ID
* @param userId 当前登录用户ID
* @param mobileColumnsValue JSON数组,每项包含columnCode和columnValue
*/
void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue);
}
@@ -1,6 +1,9 @@
package com.budwk.app.zhgh.activity.trainSignUp.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.core.date.DateUtil;
import com.budwk.app.base.sms.SmsService;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
@@ -14,6 +17,8 @@ import com.budwk.app.zhgh.activity.family.service.FamilyActivityStatisticsServic
import com.budwk.app.zhgh.activity.trainSignUp.models.*;
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService;
import com.budwk.app.zhgh.welfare.model.WelfareProject;
import com.budwk.app.zhgh.welfare.service.WelfareListService;
import com.google.common.collect.Sets;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.async.Async;
@@ -27,6 +32,7 @@ import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Lang;
import org.nutz.lang.util.NutMap;
@@ -45,6 +51,84 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
@Inject
private TrainSignUpActivityStatisticsService statisticsService;
@Inject
private WelfareListService welfareListService;
@Inject
private SmsService smsService;
/** 按主活动及子活动 ID 筛选可提醒人员,返回去重后的工号数量。 */
@Override
public int countReminderRecipients(String activityId, String courseId) {
return listReminderRecipients(activityId, courseId).size();
}
/** 按主活动、子活动和正文提交后台钉钉提醒,返回本次接收人数,不代表送达数。 */
@Override
public int submitSignupReminder(String activityId, String courseId, String content) {
if (StrUtil.isBlank(content)) {
throw new IllegalArgumentException("请输入提醒内容");
}
// 预览人数可能已变化,发送前重新筛选,不能信任前端提交的接收人。
List<String> recipients = listReminderRecipients(activityId, courseId);
if (recipients.isEmpty()) {
throw new IllegalArgumentException("该子活动没有符合条件的未报名人员");
}
String sender = SecurityUtil.getUserLoginname();
if (StrUtil.isBlank(sender)) {
throw new IllegalArgumentException("无法获取发送人工号");
}
String url = "/platform/trainSignUp/apply/h5?id=" + activityId;
String message = content.trim();
ThreadUtil.execute(() -> smsService.massSendTextCard(recipients, "品牌活动报名提醒", message, null, url, sender));
return recipients.size();
}
/**
* 根据主活动范围和子活动报名记录返回工号列表;其他子活动的报名不影响本次筛选。
* 停用、过期或子活动归属不匹配时拒绝发送,空人员范围返回空列表。
*/
private List<String> listReminderRecipients(String activityId, String courseId) {
if (StrUtil.isBlank(activityId) || StrUtil.isBlank(courseId)) {
throw new IllegalArgumentException("请选择子活动");
}
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, activityId);
TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId);
if (activity == null || course == null || !activityId.equals(course.getActivityId())) {
throw new IllegalArgumentException("当前活动或子活动不存在");
}
Date now = new Date();
if (activity.isDisabled() || activity.getActivitySignUpEndTime() == null
|| !now.before(activity.getActivitySignUpEndTime())) {
throw new IllegalArgumentException("活动已停用或报名已截止,无法发送提醒");
}
if (activity.getActivityGroupId() == null) {
return List.of();
}
// 与子活动对内开放规则一致:开放时间前只提醒承办工会的人员。
boolean hostOnly = Boolean.FALSE.equals(course.getOpenOtherUnion())
&& StrUtil.isNotBlank(course.getInterTime())
&& DateUtil.compare(now, DateUtil.parse(course.getInterTime()), "yyyy-MM-dd HH:mm") < 0;
Set<String> allowedUnions = Lang.isEmpty(course.getUnionLimit()) ? Set.of()
: course.getUnionLimit().stream().map(v -> v.getString("id")).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
Sql sql = Sqls.create("""
SELECT DISTINCT u.loginname, u.unionId
FROM activity_user_scope s
JOIN vw_user u ON u.id = s.userId
WHERE s.groupId = @groupId
AND u.loginname IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM train_sign_up_user r
WHERE r.courseId = @courseId AND r.userId = s.userId
)
""");
sql.setParam("groupId", activity.getActivityGroupId());
sql.setParam("courseId", courseId);
return listMap(sql).stream()
.filter(row -> !hostOnly || Objects.equals(course.getHostUnionId(), row.getString("unionId")))
.filter(row -> Lang.isEmpty(course.getUnionLimit()) || allowedUnions.contains(row.getString("unionId")))
.map(row -> row.getString("loginname"))
.filter(StrUtil::isNotBlank).map(String::trim).distinct().toList();
}
public TrainSignUpActivityServiceImpl(Dao dao) {
super(dao);
@@ -53,6 +137,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
@Override
@Aop(TransAop.READ_COMMITTED)
public void add(TrainSignUpActivity activity, TrainSignUpCourse course) {
validateSignExtension(activity);
dao().insert(activity);
@@ -78,6 +163,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
@Override
@Aop(TransAop.READ_COMMITTED)
public void edit(TrainSignUpActivity activity) {
validateSignExtension(activity);
// 1. 更新主活动
update(activity);
// 2. 处理类型限制
@@ -524,6 +610,261 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
return listMap(sql);
}
@Override
public NutMap getScanCodeSignData(String courseId, String userId) {
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
return buildScanCodeData(activity, course, userId, false);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public NutMap handleScanCodeSign(String courseId, String userId, Double latitude, Double longitude) {
lockScanCodeUser(userId);
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
requireScanCodeLocation(course.getTransPosition(), latitude, longitude, effectiveRadius(course));
List<TrainSignUpUserCourse> records = dao().query(TrainSignUpUserCourse.class,
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId)
.and("userId", "=", userId).asc("courseStartTime"));
if (records.stream().anyMatch(TrainSignUpUserCourse::isAttend)) {
return buildScanCodeData(activity, course, userId, true)
.addv("message", "此点位,您已经签到");
}
Date now = new Date();
int signUnit = course.getSignUnit() == null ? 0 : course.getSignUnit();
List<TrainSignUpUserCourse> availableRecords = records.stream().filter(item -> item.getCourseStartTime() != null
&& item.getCourseEndTime() != null
&& !now.before(cn.hutool.core.date.DateUtil.offsetMinute(item.getCourseStartTime(), -signUnit))
&& !now.after(cn.hutool.core.date.DateUtil.offsetMinute(item.getCourseEndTime(), signUnit)))
.collect(Collectors.toList());
if (availableRecords.size() != 1) {
throw new IllegalArgumentException(availableRecords.isEmpty() ? "当前不在签到时间范围内" : "当前点位存在多个可签到课次,请联系管理员");
}
TrainSignUpUserCourse userCourse = availableRecords.get(0);
userCourse.setAttend(true);
userCourse.setAttendTime(now);
dao().update(userCourse);
return buildScanCodeData(activity, course, userId, true).addv("message", "签到成功");
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue) {
lockScanCodeUser(userId);
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
if (!Integer.valueOf(1).equals(activity.getSignHandleType())) {
throw new IllegalArgumentException("当前活动无需填写信息");
}
if (!buildScanCodeData(activity, course, userId, false).getBoolean("isReachTarget", false)) {
throw new IllegalArgumentException("当前活动尚未达到签到点位数");
}
List<NutMap> inputData;
try {
inputData = Json.fromJsonAsList(NutMap.class, mobileColumnsValue);
} catch (Exception e) {
throw new IllegalArgumentException("填写信息格式不正确");
}
if (inputData == null) {
throw new IllegalArgumentException("请填写当前签到信息");
}
Map<String, NutMap> inputMap = new LinkedHashMap<>();
for (NutMap item : inputData) {
String code = item == null ? null : item.getString("columnCode");
if (StrUtil.isBlank(code) || inputMap.put(code, item) != null) {
throw new IllegalArgumentException("填写信息包含无效或重复字段");
}
}
List<TrainMobileSignColumn> columns = getScanCodeColumns(course);
List<NutMap> normalized = new ArrayList<>();
for (TrainMobileSignColumn column : columns) {
NutMap input = inputMap.get(column.getColumnCode());
Object value = input == null ? null : input.get("columnValue");
if (Boolean.TRUE.equals(column.getIsRequired()) && isEmptyDynamicValue(value)) {
throw new IllegalArgumentException("请填写" + column.getColumnName());
}
normalized.add(NutMap.NEW().addv("columnCode", column.getColumnCode())
.addv("columnName", column.getColumnName()).addv("columnFormType", column.getColumnFormType())
.addv("columnValue", value));
}
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId));
signUsers.forEach(item -> item.setMobileColumnsValue(normalized));
dao().update(signUsers);
}
/** 保存活动前统一校验签到扩展,未开启时清理无效配置,避免历史活动被新逻辑影响。 */
private void validateSignExtension(TrainSignUpActivity activity) {
if (activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) {
activity.setSignPointCount(null);
activity.setSignHandleType(3);
activity.setWelfareProjectId(null);
return;
}
List<TrainSignUpCourse> courses = activity.getCourseList();
if (Lang.isEmpty(courses) || activity.getSignPointCount() > courses.size()) {
throw new IllegalArgumentException("签到达标点位数不能超过活动课程数量");
}
for (TrainSignUpCourse item : courses) {
if (!item.isMobileSign() || !Integer.valueOf(1).equals(item.getSignType())) {
throw new IllegalArgumentException("开启签到扩展后,所有课程必须启用扫码签到");
}
if (!validCoordinates(item.getCourseLocationCoordinates()) || !validCoordinates(item.getTransPosition())) {
throw new IllegalArgumentException("课程“" + item.getCourseName() + "”未配置有效签到坐标");
}
if (item.getRadius() != null && item.getRadius() <= 0) {
throw new IllegalArgumentException("课程“" + item.getCourseName() + "”签到半径必须大于0");
}
}
Integer handleType = activity.getSignHandleType() == null ? 3 : activity.getSignHandleType();
if (!List.of(1, 2, 3).contains(handleType)) {
throw new IllegalArgumentException("签到达标处理方式无效");
}
activity.setSignHandleType(handleType);
if (handleType == 1 && (courses.stream().anyMatch(item -> StrUtil.isBlank(item.getCourseType()))
|| courses.stream().map(TrainSignUpCourse::getCourseType).distinct().count() != 1)) {
throw new IllegalArgumentException("达标后填写信息要求所有课程使用同一种课程类型");
}
if (handleType == 2) {
WelfareProject project = StrUtil.isBlank(activity.getWelfareProjectId()) ? null
: dao().fetch(WelfareProject.class, activity.getWelfareProjectId());
if (project == null) {
throw new IllegalArgumentException("请选择有效的福利项目");
}
if (Boolean.TRUE.equals(project.getIsDisabled())
|| project.getChoiceTimeEnd() != null && project.getChoiceTimeEnd().before(new Date())) {
throw new IllegalArgumentException("所选福利项目已禁用或已超过选择截止时间");
}
} else {
activity.setWelfareProjectId(null);
}
}
/** 校验扩展签到入口,不改变当前项目“必须先报名才能签到”的原有规则。 */
private TrainSignUpCourse requireScanCodeCourse(String courseId, String userId) {
TrainSignUpCourse course = StrUtil.isBlank(courseId) ? null : dao().fetch(TrainSignUpCourse.class, courseId);
if (course == null || !course.isMobileSign() || !Integer.valueOf(1).equals(course.getSignType())) {
throw new IllegalArgumentException("当前课程未开启扫码签到");
}
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
if (activity == null || activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) {
throw new IllegalArgumentException("当前活动未开启签到扩展");
}
Date now = new Date();
if (activity.isDisabled() || activity.getActivityStartTime() == null || activity.getActivityEndTime() == null
|| now.before(activity.getActivityStartTime()) || now.after(activity.getActivityEndTime())) {
throw new IllegalArgumentException("当前活动未开放或不在活动时间范围内");
}
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId).and("userId", "=", userId));
if (signUsers.isEmpty()) {
throw new IllegalArgumentException("未查询到您的报名记录");
}
if (signUsers.stream().noneMatch(item -> Integer.valueOf(1).equals(item.getState()) || Integer.valueOf(3).equals(item.getState()))) {
throw new IllegalArgumentException("当前报名状态不能签到");
}
return course;
}
/** 组装当前点位、全部点位、签到进度及达标处理状态。 */
private NutMap buildScanCodeData(TrainSignUpActivity activity, TrainSignUpCourse course, String userId, boolean allowHandle) {
List<TrainSignUpUserCourse> attended = dao().query(TrainSignUpUserCourse.class,
Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId).and("isAttend", "=", true));
long signCount = attended.stream().map(TrainSignUpUserCourse::getCourseId).filter(StrUtil::isNotBlank).distinct().count();
boolean reached = signCount >= activity.getSignPointCount();
List<TrainSignUpCourse> courses = dao().query(TrainSignUpCourse.class,
Cnd.where("activityId", "=", activity.getId()).asc("orderNum"));
List<NutMap> signPoints = courses.stream().map(item -> NutMap.NEW().addv("courseId", item.getId())
.addv("courseName", item.getCourseName()).addv("courseLocation", item.getCourseLocation())
.addv("signed", attended.stream().anyMatch(record -> Objects.equals(record.getCourseId(), item.getId()))))
.collect(Collectors.toList());
Map<String, Object> savedValues = new HashMap<>();
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId));
signUsers.stream().map(TrainSignUpUser::getMobileColumnsValue).filter(Lang::isNotEmpty).findFirst()
.ifPresent(values -> values.forEach(value -> savedValues.put(value.getString("columnCode"), value.get("columnValue"))));
List<NutMap> dynamicColumns = getScanCodeColumns(course).stream().map(item -> {
NutMap map = Lang.obj2nutmap(item);
map.put("columnValue", savedValues.containsKey(item.getColumnCode()) ? savedValues.get(item.getColumnCode())
: "JSON".equals(item.getColumnType()) ? new ArrayList<>() : "");
return map;
}).collect(Collectors.toList());
NutMap data = NutMap.NEW().addv("activityName", activity.getActivityName()).addv("courseName", course.getCourseName())
.addv("courseLocation", course.getCourseLocation()).addv("courseLocationCoordinates", course.getCourseLocationCoordinates())
.addv("signRadius", effectiveRadius(course)).addv("currentPointSigned", attended.stream()
.anyMatch(item -> Objects.equals(item.getCourseId(), course.getId())))
.addv("signCount", signCount).addv("signPointCount", activity.getSignPointCount())
.addv("signPoints", signPoints).addv("isReachTarget", reached)
.addv("signHandleType", activity.getSignHandleType()).addv("dynamicColumnsData", dynamicColumns)
.addv("needDynamicForm", reached && Integer.valueOf(1).equals(activity.getSignHandleType()) && !dynamicColumns.isEmpty())
.addv("needWelfareSelect", false).addv("welfareJumpUrl", "")
.addv("handleMsg", reached ? "当前活动已达标" : "请继续完成其他点位签到");
if (reached && Integer.valueOf(2).equals(activity.getSignHandleType())) {
WelfareProject welfareProject = dao().fetch(WelfareProject.class, activity.getWelfareProjectId());
if (welfareProject == null || Boolean.TRUE.equals(welfareProject.getIsDisabled())
|| welfareProject.getChoiceTimeEnd() != null && welfareProject.getChoiceTimeEnd().before(new Date())) {
return data.setv("handleMsg", "关联福利项目已结束,请联系管理员");
}
if (allowHandle) {
welfareListService.ensureWelfareListUser(userId, activity.getWelfareProjectId());
}
data.setv("needWelfareSelect", true)
.setv("welfareJumpUrl", "/platform/h5/welfare/userSelect/index?id=" + activity.getWelfareProjectId())
.setv("handleMsg", "签到已达标,请选择福利");
}
return data;
}
private List<TrainMobileSignColumn> getScanCodeColumns(TrainSignUpCourse course) {
return StrUtil.isBlank(course.getCourseType()) ? new ArrayList<>() : dao().query(TrainMobileSignColumn.class,
Cnd.where("typeId", "=", course.getCourseType()).asc("columnIndex"));
}
/** 锁定当前登录用户,避免同一用户并发扫码造成重复达标处理。 */
private void lockScanCodeUser(String userId) {
if (StrUtil.isBlank(userId)) {
throw new IllegalArgumentException("未获取到当前登录用户");
}
Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id = @userId FOR UPDATE").setParam("userId", userId);
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
NutMap user = sql.getObject(NutMap.class);
if (user == null || user.isEmpty()) {
throw new IllegalArgumentException("未找到当前用户信息");
}
}
private int effectiveRadius(TrainSignUpCourse course) {
return course.getRadius() == null ? 100 : course.getRadius();
}
private boolean validCoordinates(List<Double> coordinates) {
return coordinates != null && coordinates.size() == 2 && coordinates.get(0) != null && coordinates.get(1) != null
&& Double.isFinite(coordinates.get(0)) && Double.isFinite(coordinates.get(1));
}
/** 当前定位与transPosition均为WGS84坐标,沿用原签到距离计算口径。 */
private void requireScanCodeLocation(List<Double> coordinates, Double latitude, Double longitude, int radius) {
if (!validCoordinates(coordinates)) {
throw new IllegalArgumentException("当前点位未配置有效签到坐标");
}
if (latitude == null || longitude == null || !Double.isFinite(latitude) || !Double.isFinite(longitude)) {
throw new IllegalArgumentException("请先获取当前定位后再签到");
}
double distance = com.budwk.app.base.utils.DistanceUtil.getDistance(coordinates.get(0), coordinates.get(1), latitude, longitude);
if (distance > radius) {
throw new IllegalArgumentException("签到失败,您距离签到点还有" + String.format("%.1f", distance - radius) + "米");
}
}
private boolean isEmptyDynamicValue(Object value) {
return value == null || value instanceof String && StrUtil.isBlank((String) value)
|| value instanceof Collection && ((Collection<?>) value).isEmpty();
}
@Override
public List<TrainSignUpCourse> filterCourseByHostUnion(List<TrainSignUpCourse> courseList) {
if (Lang.isEmpty(courseList)) {
@@ -17,6 +17,7 @@ import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@@ -59,18 +60,17 @@ public class QsvActivityController {
//保存问卷基础信息
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存问卷基础信息")
@SLog(type = "qsv.activity", tag = "保存问卷基础信息", msg = "保存问卷基础信息")
public Result save(QsvActivity qsvActivity) {
if (qsvActivity.getCategory().equals("QUIZ")) {
if (qsvActivity.getMode().equals("SCHEDULED")) {
qsvActivity.setRepeatMode("DAILY");
} else if (qsvActivity.getMode().equals("REGULAR")) {
qsvActivity.setRepeatMode("TOTAL");
}
public Result save(QsvActivity qsvActivity, @Param("forceSave") Boolean forceSave) {
NutMap confirmResult = qsvActivityService.checkAnswerModeChangeConfirm(qsvActivity);
// Mode changes with existing answer records must be returned as a normal response, so the page can show a confirm dialog.
if (!Boolean.TRUE.equals(forceSave) && Boolean.TRUE.equals(confirmResult.get("needConfirm"))) {
return Result.success(confirmResult.getString("message"), confirmResult);
}
dao.insertOrUpdate(qsvActivity);
return Result.success();
QsvActivity activity = qsvActivityService.saveActivity(qsvActivity, forceSave);
return Result.success(activity);
}
@At
@@ -83,13 +83,25 @@ public class QsvActivityController {
// 删除问卷
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除问卷")
@SLog(type = "qsv.activity", tag = "删除问卷", msg = "删除问卷")
public Result delete(@Valid String id) {
dao.delete(QsvActivity.class, id);
qsvActivityService.deleteActivity(id);
return Result.success();
}
// 更新活动开启状态
@At
@SaCheckPermission("qsv.activity")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("更新活动开启状态")
@SLog(type = "qsv.activity", tag = "更新活动开启状态", msg = "更新活动开启状态")
public Result updateEnabled(@Valid String id, Boolean enabled) {
qsvActivityService.updateEnabled(id, enabled);
return Result.success(enabled ? "开启成功" : "关闭成功");
}
// 保存问卷题目
@At
@@ -9,8 +9,10 @@ import com.budwk.app.zhgh.dayofficework.qsv.models.QsvBank;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvBankService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
@@ -51,6 +53,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存题库")
public Result save(@Param("qsvBank") QsvBank qsvBank) {
dao.insert(qsvBank);
@@ -59,6 +62,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("修改题库")
public Result update(@Param("qsvBank") QsvBank qsvBank) {
dao.updateIgnoreNull(qsvBank);
@@ -75,6 +79,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除题库")
public Result delete(@Valid String id) {
dao.delete(QsvBank.class, id);
@@ -83,6 +88,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存题库题目")
public Result saveSubjects(@Valid String id, @Param("subjects") QsvSubject[] subjects) {
QsvBank qsvBank = dao.fetch(QsvBank.class, id);
@@ -0,0 +1,27 @@
package com.budwk.app.zhgh.dayofficework.qsv.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@At("/platform/qsv")
@Ok("json:full")
@ApiOperation("问卷调查新建")
/**
* 问卷新建页面控制器,负责承载 PC 端问卷创建向导入口。
*/
public class QsvNewController {
/**
* 打开问卷新建页面,页面内继续完成基础信息、题目与发布配置。
*/
@At("/new")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/activity/new.html")
@SaCheckPermission("qsv.new")
public void newPage() {
}
}
@@ -0,0 +1,611 @@
package com.budwk.app.zhgh.dayofficework.qsv.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.dto.QsvCheckAnswerResult;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvQuizService;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@IocBean
@At("/platform/qsv/online")
@Ok("json:full")
@ApiOperation("在线答题")
/**
* PC 端在线答题控制器,负责答题/投票任务列表、题目读取、提交和投票统计。
*/
public class QsvOnlineController {
@Inject
private Dao dao;
@Inject
private QsvUserAnswerRecordService qsvUserAnswerRecordService;
@Inject
private QsvQuizService qsvQuizService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/online/index.html")
@SaCheckPermission("qsv.online")
/**
* 打开 PC 端在线答题页面。
*/
public void index() {
}
/**
* 分页查询当前用户可参与的问卷活动,并根据是否已答过滤列表。
*
* @param pageForm 分页参数
* @param year 活动年度
* @param title 活动标题关键字
* @param isAnswered 是否只查询已答记录
* @return 当前用户可见的在线答题分页数据
*/
@At
@SaCheckPermission("qsv.online")
@ApiOperation("在线答题任务分页")
public Result pageData(@Valid PageForm pageForm, Integer year, String title, Boolean isAnswered) {
// 先按用户参与范围过滤活动,避免用户看到无权限参与的问卷。
List<Integer> groupIds = dao.query(ActivityUserScope.class, Cnd.where("userId", "=", SecurityUtil.getUserId()))
.stream().map(ActivityUserScope::getGroupId).collect(Collectors.toList());
if (groupIds.isEmpty()) {
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()));
}
Cnd cnd = Cnd.where("enabled", "=", true);
cnd.and("groupId", "in", groupIds);
cnd.andEX("YEAR(startTime)", "=", year);
cnd.and(Cnd.likeEX("title", title));
cnd.desc("startTime");
int totalCount = dao.count(QsvActivity.class, cnd);
if (totalCount == 0) {
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()));
}
List<QsvActivity> activities = dao.query(QsvActivity.class, cnd);
List<String> activityIds = activities.stream().map(QsvActivity::getId).collect(Collectors.toList());
// 预加载用户答题记录,后续构建列表状态时避免循环查询数据库。
Map<String, List<QsvUserAnswerRecord>> recordGroup = dao.query(QsvUserAnswerRecord.class, Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("activityId", "in", activityIds))
.stream().collect(Collectors.groupingBy(QsvUserAnswerRecord::getActivityId));
List<NutMap> rows = activities.stream()
.map((activity) -> buildOnlineRow(activity, recordGroup.getOrDefault(activity.getId(), new ArrayList<>())))
.filter((row) -> {
if (Boolean.TRUE.equals(isAnswered)) {
return row.getBoolean("isAnswered");
}
return !row.getBoolean("isAnswered") || row.getBoolean("canAnswer");
})
.collect(Collectors.toList());
int filteredTotal = rows.size();
int fromIndex = Math.min((pageForm.getPageNumber() - 1) * pageForm.getPageSize(), filteredTotal);
int toIndex = Math.min(fromIndex + pageForm.getPageSize(), filteredTotal);
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), filteredTotal, rows.subList(fromIndex, toIndex)));
}
@At
@SaCheckPermission("qsv.online")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("在线答题题目")
/**
* 查询在线答题题目,并在用户首次进入时生成答题记录。
*
* @param activityId 活动 ID
* @param viewOnly 是否仅查看已完成答题
* @return 活动、题目和答题记录信息
*/
public Result subjects(@Valid String activityId, Boolean viewOnly) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
if (activity == null) {
return Result.error("调查不存在");
}
QsvUserAnswerRecord answerRecord = findCurrentAnswerRecord(activityId, Boolean.TRUE.equals(viewOnly));
if (Boolean.TRUE.equals(viewOnly) && answerRecord == null) {
return Result.error("未找到已完成的答题记录");
}
boolean finishedView = Boolean.TRUE.equals(viewOnly) && answerRecord != null && Boolean.TRUE.equals(answerRecord.getIsFinish());
if (!finishedView) {
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
}
Result scopeResult = checkUserScope(activity);
if (scopeResult != null) {
return scopeResult;
}
answerRecord = prepareAnswerRecord(activity, answerRecord);
List<QsvSubject> activitySubjects = queryActivitySubjectsForSync(activity);
// 后台调整题目后,同步未完成记录的题目范围,保证用户继续作答时题目完整。
syncAnswerRecordSubjects(answerRecord, activity, activitySubjects);
List<QsvSubject> subjects = querySubjectsByRecordOrder(answerRecord.getSubjectIds());
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
subjects.forEach((subject) -> {
if (subject.getUserSelectOptionIds() == null) {
subject.setUserSelectOptionIds(new ArrayList<>());
}
});
return Result.success(NutMap.NEW()
.addv("activity", activity)
.addv("subjects", subjects)
.addv("answerRecordId", answerRecord.getId()));
}
@At
@SaCheckPermission("qsv.online")
@ApiOperation("在线答题记录")
/**
* 查询单条答题记录,用于恢复用户已填内容。
*
* @param answerRecordId 答题记录 ID
* @return 答题记录
*/
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
return Result.success(record);
}
@At
@SaCheckPermission("qsv.online")
@ApiOperation("在线投票统计")
/**
* 查询投票活动的选项票数、占比和排名。
*
* @param activityId 投票活动 ID
* @return 投票统计数据
*/
public Result voteStats(@Valid String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
if (activity == null || !"VOTE".equals(activity.getCategory())) {
return Result.error("投票不存在");
}
Result scopeResult = checkUserScope(activity);
if (scopeResult != null) {
return scopeResult;
}
return Result.success(buildVoteStats(activityId));
}
@At
@SaCheckPermission("qsv.online")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("提交在线答题")
/**
* 提交在线答题或投票结果,并写入用户答题记录。
*
* @param qsvAnswerParam 用户提交的题目答案
* @return 提交结果
*/
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
Result scopeResult = checkUserScope(activity);
if (scopeResult != null) {
return scopeResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
if (answerRecord == null || !SecurityUtil.getUserId().equals(answerRecord.getUserId())) {
return Result.error("答题记录不存在");
}
if (Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return Result.error("您已完成该调查");
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
// 提交写入前统一校验多选题最大选择数,避免绕过页面限制提交超量选项。
String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam);
if (maxMultiLimitMessage != null) {
return Result.error(maxMultiLimitMessage);
}
float totalScore = 0;
boolean isQuiz = "QUIZ".equals(activity.getCategory());
for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) {
// 将每道题的选项、填空内容和得分写入 extJson,保持答题记录结构统一。
JSONObject entries = extJson.get(subject.getId(), JSONObject.class);
if (ObjectUtil.isEmpty(entries)) {
entries = new JSONObject();
}
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
if (isQuiz) {
QsvCheckAnswerResult answerResult = qsvQuizService.calcScore(subject.getId(), subject.getUserSelectOptionIds());
entries.set("score", answerResult.getScore());
entries.set("isCorrect", answerResult.isCorrect());
totalScore += answerResult.getScore();
}
extJson.set(subject.getId(), entries);
}
answerRecord.setExtJson(extJson);
if (isQuiz) {
answerRecord.setTotalScore(totalScore);
answerRecord.setAnswerTime(qsvAnswerParam.getAnswerTime());
}
answerRecord.setAttemptDate(new Date());
answerRecord.setSubmitTime(new Date());
answerRecord.setIsFinish(true);
dao.update(answerRecord);
if (isQuiz) {
qsvUserAnswerRecordService.calcByScoreMode(qsvAnswerParam.getActivityId(), SecurityUtil.getUserId());
}
return Result.success("提交成功");
}
private Result checkActivityStatus(QsvActivity activity) {
if (activity == null) {
return Result.error("调查不存在");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("活动已关闭,暂不能参与");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("调查尚未开始,请在开始后再参与");
}
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("调查已结束");
}
return null;
}
private Result checkUserScope(QsvActivity activity) {
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
if (count != 1) {
return Result.error("您无需参加此次调查,感谢您的关注!");
}
return null;
}
private NutMap buildOnlineRow(QsvActivity activity, List<QsvUserAnswerRecord> records) {
boolean isAnswered = records.stream().anyMatch((record) -> Boolean.TRUE.equals(record.getIsFinish()));
boolean hasUnfinished = records.stream().anyMatch((record) -> !Boolean.TRUE.equals(record.getIsFinish()));
boolean canRepeat = "QUIZ".equals(activity.getCategory())
&& Boolean.TRUE.equals(activity.getRepeatable())
&& ("SCHEDULED".equals(activity.getMode()) || ("REGULAR".equals(activity.getMode()) && "ALL".equals(activity.getDisplayMode())));
boolean randomDisplayMode = "QUIZ".equals(activity.getCategory())
&& "REGULAR".equals(activity.getMode())
&& "RANDOM".equals(activity.getDisplayMode());
int maxAttempts = activity.getMaxAttempts() == null || activity.getMaxAttempts() < 1 ? 1 : activity.getMaxAttempts();
int totalRandom = activity.getTotalRandom() == null || activity.getTotalRandom() < 1 ? 1 : activity.getTotalRandom();
long attemptCount = records.size();
if ("SCHEDULED".equals(activity.getMode())) {
attemptCount = records.stream()
.filter((record) -> record.getAttemptDate() != null && DateUtil.isSameDay(record.getAttemptDate(), DateUtil.date()))
.count();
}
Date now = new Date();
boolean notStarted = activity.getStartTime() != null && activity.getStartTime().after(now);
boolean ended = activity.getEndTime() != null && activity.getEndTime().before(now);
boolean activityAvailable = Boolean.TRUE.equals(activity.getEnabled()) && !notStarted && !ended;
boolean canAnswerByAttempt = hasUnfinished || !isAnswered || (canRepeat && attemptCount < maxAttempts) || (randomDisplayMode && attemptCount < totalRandom);
boolean canAnswer = activityAvailable && canAnswerByAttempt;
boolean isVote = "VOTE".equals(activity.getCategory());
String actionText;
String actionType;
String statusText;
String statusType;
if (!activityAvailable && !isAnswered) {
if (!Boolean.TRUE.equals(activity.getEnabled())) {
actionText = "已关闭";
statusText = "已关闭";
} else if (notStarted) {
actionText = isVote ? "未开始投票" : "未开始答题";
statusText = "未开始";
} else {
actionText = isVote ? "投票已结束" : "答题已结束";
statusText = "已结束";
}
actionType = "unavailable";
statusType = "info";
} else if (hasUnfinished && canAnswer) {
actionText = isVote ? "继续投票" : "继续答题";
actionType = "answer";
statusText = isVote ? "投票中" : "答题中";
statusType = "warning";
} else if (!isAnswered && canAnswer) {
actionText = isVote ? "投票" : "答题";
actionType = "answer";
statusText = isVote ? "未投票" : "未答题";
statusType = "warning";
} else if (canAnswer) {
actionText = isVote ? "再次投票" : "再次答题";
actionType = "answer";
statusText = isVote ? "可再次投票" : "可再次答题";
statusType = "warning";
} else {
actionText = isVote ? "查看投票" : "查看答题";
actionType = "view";
statusText = isVote ? "已投票" : "已答题";
statusType = "success";
}
Optional<QsvUserAnswerRecord> latestFinishedRecord = records.stream()
.filter((record) -> Boolean.TRUE.equals(record.getIsFinish()))
.max((first, second) -> {
long firstTime = first.getSubmitTime() == null ? 0 : first.getSubmitTime().getTime();
long secondTime = second.getSubmitTime() == null ? 0 : second.getSubmitTime().getTime();
return Long.compare(firstTime, secondTime);
});
return NutMap.NEW()
.addv("id", activity.getId())
.addv("title", activity.getTitle())
.addv("category", activity.getCategory())
.addv("startTime", activity.getStartTime())
.addv("endTime", activity.getEndTime())
.addv("enabled", activity.getEnabled())
.addv("isAnswered", isAnswered)
.addv("hasUnfinished", hasUnfinished)
.addv("canAnswer", canAnswer)
.addv("notStarted", notStarted)
.addv("ended", ended)
.addv("canRepeat", canRepeat)
.addv("answeredCount", records.stream().filter((record) -> Boolean.TRUE.equals(record.getIsFinish())).count())
.addv("remainingAttempts", canRepeat ? Math.max(maxAttempts - attemptCount, 0) : (randomDisplayMode ? Math.max(totalRandom - attemptCount, 0) : 0))
.addv("actionText", actionText)
.addv("actionType", actionType)
.addv("statusText", statusText)
.addv("statusType", statusType)
.addv("totalScore", latestFinishedRecord.map(QsvUserAnswerRecord::getTotalScore).orElse(null));
}
private QsvUserAnswerRecord findCurrentAnswerRecord(String activityId, boolean viewOnly) {
Cnd cnd = Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId());
if (viewOnly) {
cnd.and("isFinish", "=", true).desc("submitTime").desc("createdAt");
} else {
cnd.and(Cnd.exps("isFinish", "=", false).or("isFinish", "is", null)).desc("createdAt");
}
return dao.fetch(QsvUserAnswerRecord.class, cnd);
}
private QsvUserAnswerRecord prepareAnswerRecord(QsvActivity activity, QsvUserAnswerRecord answerRecord) {
if (answerRecord != null) {
return answerRecord;
}
if ("QUIZ".equals(activity.getCategory())
&& "REGULAR".equals(activity.getMode())
&& "RANDOM".equals(activity.getDisplayMode())) {
return createRandomAnswerRecord(activity);
}
List<QsvSubject> activitySubjects = queryActivitySubjectsForSync(activity);
return qsvUserAnswerRecordService.insertRecord(activity.getId(), activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList()));
}
private QsvUserAnswerRecord createRandomAnswerRecord(QsvActivity activity) {
Integer randomCount = activity.getRandomCount();
Integer totalRandom = activity.getTotalRandom();
if (randomCount == null || randomCount < 1) {
return qsvUserAnswerRecordService.insertRecord(activity.getId(), new ArrayList<>());
}
if (totalRandom == null || totalRandom < 1) {
totalRandom = 1;
}
List<QsvUserAnswerRecord> answerRecords = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activity.getId())
.and("userId", "=", SecurityUtil.getUserId()));
if (answerRecords.size() >= totalRandom) {
return answerRecords.stream()
.max((first, second) -> Integer.compare(first.getRandomNumber() == null ? 0 : first.getRandomNumber(), second.getRandomNumber() == null ? 0 : second.getRandomNumber()))
.orElse(null);
}
List<String> usedSubjectIds = answerRecords.stream()
.filter((record) -> record.getSubjectIds() != null)
.flatMap((record) -> record.getSubjectIds().stream())
.collect(Collectors.toList());
Cnd subjectCnd = Cnd.where("activityId", "=", activity.getId());
if (!usedSubjectIds.isEmpty()) {
subjectCnd.and("id", "not in", usedSubjectIds);
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, subjectCnd.asc("sortNum"));
if (subjects.size() < randomCount) {
throw new RuntimeException("题目数不够,无法生成题目");
}
Collections.shuffle(subjects);
List<String> randomSubjectIds = subjects.subList(0, randomCount).stream().map(QsvSubject::getId).collect(Collectors.toList());
return qsvUserAnswerRecordService.insertRecord(activity.getId(), randomSubjectIds);
}
private List<QsvSubject> queryActivitySubjectsForSync(QsvActivity activity) {
Cnd cnd = Cnd.where("activityId", "=", activity.getId());
if ("QUIZ".equals(activity.getCategory())
&& "SCHEDULED".equals(activity.getMode())) {
cnd.and("displayDate", "=", DateUtil.today());
}
return dao.query(QsvSubject.class, cnd.asc("sortNum"));
}
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, QsvActivity activity, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
List<String> currentSubjectIds = activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
boolean randomDisplayMode = "QUIZ".equals(activity.getCategory())
&& "REGULAR".equals(activity.getMode())
&& "RANDOM".equals(activity.getDisplayMode());
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
if (randomDisplayMode) {
currentSubjectIds = normalizedSubjectIds;
} else {
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
boolean subjectOrderLocked = Boolean.TRUE.equals(extJson.get("_subjectOrderLocked"));
if ("QUIZ".equals(activity.getCategory())
&& Boolean.TRUE.equals(activity.getShuffleSubject())
&& !subjectOrderLocked
&& isAnswerRecordEmpty(extJson, currentSubjectIds)) {
Collections.shuffle(normalizedSubjectIds);
extJson.set("_subjectOrderLocked", true);
changed = true;
}
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
extJson.set(subjectId, Dict.create()
.set("optionIds", new ArrayList<>())
.set("text", null)
.set("optionFillContents", new JSONObject()));
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private boolean isAnswerRecordEmpty(JSONObject extJson, List<String> subjectIds) {
for (String subjectId : subjectIds) {
JSONObject answer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(answer)) {
continue;
}
if (ObjectUtil.isNotEmpty(answer.get("optionIds"))
|| ObjectUtil.isNotEmpty(answer.get("text"))
|| ObjectUtil.isNotEmpty(answer.get("optionFillContents"))) {
return false;
}
}
return true;
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
private NutMap buildVoteStats(String activityId) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
Map<String, NutMap> optionStats = new LinkedHashMap<>();
for (QsvSubject subject : subjects) {
if (subject.getOptions() == null) {
continue;
}
for (QsvOption option : subject.getOptions()) {
optionStats.put(option.getId(), NutMap.NEW()
.addv("optionId", option.getId())
.addv("subjectId", subject.getId())
.addv("text", option.getText())
.addv("imgUrl", option.getImgUrl())
.addv("votes", 0)
.addv("percent", 0D));
}
}
List<QsvUserAnswerRecord> records = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
for (QsvUserAnswerRecord record : records) {
JSONObject extJson = record.getExtJson();
if (extJson == null) {
continue;
}
for (QsvSubject subject : subjects) {
JSONObject entry = extJson.get(subject.getId(), JSONObject.class);
if (entry == null) {
continue;
}
List<String> optionIds = entry.getBeanList("optionIds", String.class);
if (optionIds == null) {
continue;
}
for (String optionId : optionIds) {
NutMap stat = optionStats.get(optionId);
if (stat != null) {
stat.put("votes", stat.getInt("votes", 0) + 1);
}
}
}
}
int totalParticipants = records.size();
List<NutMap> rankList = new ArrayList<>(optionStats.values());
for (NutMap stat : rankList) {
int votes = stat.getInt("votes", 0);
double percent = totalParticipants == 0 ? 0D : votes * 100D / totalParticipants;
stat.put("percent", percent);
}
rankList.sort((left, right) -> Integer.compare(right.getInt("votes", 0), left.getInt("votes", 0)));
return NutMap.NEW()
.addv("totalParticipants", totalParticipants)
.addv("optionCount", optionStats.size())
.addv("optionStats", optionStats)
.addv("rankList", rankList);
}
}
@@ -4,11 +4,14 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvQuizRankPageForm;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvQuizRankService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
@@ -64,5 +67,17 @@ public class QsvQuizRankController {
qsvQuizRankService.exportXlsx(pageForm, response);
}
@At
@SaCheckPermission("qsv.quiz.rank.delete")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除答题记录")
public Result deleteAnswerRecord(@Valid String id) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, id);
if (record == null) {
return Result.error("答题记录不存在");
}
dao.delete(QsvUserAnswerRecord.class, id);
return Result.success("删除成功");
}
}
@@ -1,18 +1,31 @@
package com.budwk.app.zhgh.dayofficework.qsv.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvSurveyService;
import io.swagger.annotations.ApiOperation;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -21,6 +34,10 @@ import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Base64;
import java.util.List;
@IocBean
@@ -43,12 +60,19 @@ public class QsvSurveyController {
}
@At("/reportPage")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/survey/reportPage.html")
@SaCheckPermission("qsv.survey")
public void reportPage() {
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("分页查询")
public Result pageData(@Valid PageForm pageForm, Integer year) {
Cnd cnd = Cnd.where("category", "=", "SURVEY");
Cnd cnd = Cnd.where("category", "in", new String[]{"SURVEY", "VOTE"});
cnd.andEX("YEAR(startTime)", "=", year);
cnd.desc("category");
Pagination pagination = qsvActivityService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
@@ -63,6 +87,43 @@ public class QsvSurveyController {
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("查询调查信息")
public Result activityInfo(@Valid String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
NutMap data = NutMap.NEW();
if (activity != null) {
data.setv("id", activity.getId());
data.setv("title", activity.getTitle());
}
return Result.success(data);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成分类报告")
public Result categoryReport(@Valid String activityId, String conditions) {
List<NutMap> report = qsvSurveyService.categoryReport(activityId, conditions);
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成交叉分析报告")
public Result crossReport(@Valid String activityId, @Valid String xSubjectIds, @Valid String ySubjectIds, String conditions) {
NutMap report = qsvSurveyService.crossReport(activityId, xSubjectIds, ySubjectIds, conditions);
return Result.success(report);
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("生成对比分析报告")
public Result compareReport(@Valid String activityId, @Valid String subjectId, @Valid String optionId) {
NutMap report = qsvSurveyService.compareReport(activityId, subjectId, optionId);
return Result.success(report);
}
@At
@Ok("void")
@@ -72,13 +133,244 @@ public class QsvSurveyController {
qsvSurveyService.exportReportXlsx(activityId, response);
}
@At
@Ok("void")
@SaCheckPermission("qsv.survey")
@ApiOperation("下载PDF报告")
public void exportReportPdf(String reportData, HttpServletResponse response) {
if (StrUtil.isBlank(reportData)) {
throw new IllegalArgumentException("报告数据不能为空");
}
JSONObject report = JSONUtil.parseObj(reportData);
String title = StrUtil.blankToDefault(report.getStr("title"), "分析报告");
String tabLabel = StrUtil.blankToDefault(report.getStr("tabLabel"), "分析报告");
try {
CommonDownloadUtil.download(safeFileName(title + "-" + tabLabel) + ".pdf", buildReportPdf(report), response);
} catch (IOException e) {
throw new RuntimeException("生成PDF报告失败", e);
}
}
private byte[] buildReportPdf(JSONObject report) throws IOException {
try (PDDocument document = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PdfReportWriter writer = new PdfReportWriter(document);
writer.title(StrUtil.blankToDefault(report.getStr("title"), "分析报告"));
writer.text("报告类型:" + StrUtil.blankToDefault(report.getStr("tabLabel"), "分析报告"), 10);
writer.text("生成时间:" + DateUtil.now(), 10);
writer.gap(8);
JSONArray sections = report.getJSONArray("sections");
if (sections == null || sections.isEmpty()) {
writer.text("暂无可下载内容", 12);
} else {
for (int i = 0; i < sections.size(); i++) {
JSONObject section = JSONUtil.parseObj(sections.get(i));
writer.section(StrUtil.blankToDefault(section.getStr("title"), "统计项"));
String description = section.getStr("description");
if (StrUtil.isNotBlank(description)) {
writer.text(description, 10);
}
String image = section.getStr("image");
if (StrUtil.isNotBlank(image)) {
writer.image(image);
}
writer.table(section.getJSONArray("rows"));
writer.gap(8);
}
}
writer.close();
document.save(out);
return out.toByteArray();
}
}
private String safeFileName(String fileName) {
return StrUtil.blankToDefault(fileName, "分析报告").replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_");
}
private PDType0Font loadChineseFont(PDDocument document, boolean bold) throws IOException {
String[] paths = bold
? new String[]{"/usr/share/fonts/chinese/simhei.ttf", "/usr/share/fonts/chinese/msyhbd.ttc", "/usr/share/fonts/chinese/simsun.ttc"}
: new String[]{"/usr/share/fonts/chinese/simfang.ttf", "/usr/share/fonts/chinese/simsun.ttc", "/usr/share/fonts/chinese/msyh.ttc"};
for (String path : paths) {
File file = new File(path);
if (file.exists()) {
return PDType0Font.load(document, file);
}
}
throw new IOException("未找到可用的中文字体");
}
private class PdfReportWriter {
private final PDDocument document;
private final PDType0Font regularFont;
private final PDType0Font boldFont;
private final float margin = 42F;
private final float pageWidth = PDRectangle.A4.getWidth();
private final float pageHeight = PDRectangle.A4.getHeight();
private final float contentWidth = pageWidth - margin * 2;
private final float sectionTopGap = 14F;
private final float sectionMinHeight = 88F;
private final float tableBottomGap = 16F;
private PDPageContentStream content;
private float y;
PdfReportWriter(PDDocument document) throws IOException {
this.document = document;
this.regularFont = loadChineseFont(document, false);
this.boldFont = loadChineseFont(document, true);
newPage();
}
void newPage() throws IOException {
if (content != null) {
content.close();
}
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
content = new PDPageContentStream(document, page);
y = pageHeight - margin;
}
void close() throws IOException {
if (content != null) {
content.close();
content = null;
}
}
void ensure(float height) throws IOException {
if (y - height < margin) {
newPage();
}
}
void gap(float height) throws IOException {
ensure(height);
y -= height;
}
void title(String text) throws IOException {
ensure(36);
drawText(text, margin, y, 18, boldFont);
y -= 32;
}
void section(String text) throws IOException {
// Reserve enough space for the subject title and summary line before drawing the next question.
ensure(sectionMinHeight);
if (y < pageHeight - margin) {
y -= sectionTopGap;
ensure(sectionMinHeight - sectionTopGap);
}
drawText(text, margin, y, 13, boldFont);
y -= 24;
}
void text(String text, float fontSize) throws IOException {
List<String> lines = wrap(StrUtil.blankToDefault(text, ""), fontSize, contentWidth, regularFont);
for (String line : lines) {
ensure(fontSize + 8);
drawText(line, margin, y, fontSize, regularFont);
y -= fontSize + 7;
}
}
void image(String dataUrl) throws IOException {
int commaIndex = dataUrl.indexOf(",");
if (commaIndex < 0) {
return;
}
byte[] bytes = Base64.getDecoder().decode(dataUrl.substring(commaIndex + 1));
PDImageXObject image = PDImageXObject.createFromByteArray(document, bytes, "chart");
float width = contentWidth;
float height = width * image.getHeight() / image.getWidth();
if (height > 260) {
height = 260;
width = height * image.getWidth() / image.getHeight();
}
ensure(height + 12);
content.drawImage(image, margin, y - height, width, height);
y -= height + 12;
}
void table(JSONArray rows) throws IOException {
if (rows == null || rows.isEmpty()) {
// Empty text-question summaries also need bottom spacing before the next question starts.
ensure(42);
text("暂无统计数据", 10);
gap(tableBottomGap);
return;
}
float[] widths = new float[]{contentWidth * 0.46F, contentWidth * 0.15F, contentWidth * 0.17F, contentWidth * 0.22F};
// Keep the table header with at least the first data row when a table starts near the page bottom.
ensure(48);
drawTableRow(new String[]{"选项/内容", "数量", "占比", "备注"}, widths, true);
for (int i = 0; i < rows.size(); i++) {
JSONObject row = JSONUtil.parseObj(rows.get(i));
drawTableRow(new String[]{
StrUtil.blankToDefault(row.getStr("name"), ""),
StrUtil.blankToDefault(row.getStr("count"), ""),
StrUtil.blankToDefault(row.getStr("percent"), ""),
StrUtil.blankToDefault(row.getStr("remark"), "")
}, widths, false);
}
gap(tableBottomGap);
}
void drawTableRow(String[] values, float[] widths, boolean header) throws IOException {
float rowHeight = 24F;
ensure(rowHeight);
float x = margin;
for (int i = 0; i < values.length; i++) {
content.addRect(x, y - rowHeight, widths[i], rowHeight);
content.stroke();
drawText(clip(values[i], header ? 16 : 28), x + 5, y - 16, 9, header ? boldFont : regularFont);
x += widths[i];
}
y -= rowHeight;
}
void drawText(String text, float x, float y, float fontSize, PDType0Font font) throws IOException {
content.beginText();
content.setFont(font, fontSize);
content.newLineAtOffset(x, y);
content.showText(StrUtil.blankToDefault(text, "").replaceAll("[\\r\\n\\t]", " "));
content.endText();
}
List<String> wrap(String text, float fontSize, float maxWidth, PDType0Font font) throws IOException {
List<String> lines = new java.util.ArrayList<>();
StringBuilder line = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
String next = line.toString() + ch;
if (font.getStringWidth(next) / 1000 * fontSize > maxWidth && line.length() > 0) {
lines.add(line.toString());
line.setLength(0);
}
line.append(ch);
}
if (line.length() > 0 || lines.isEmpty()) {
lines.add(line.toString());
}
return lines;
}
String clip(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return StrUtil.blankToDefault(text, "");
}
return text.substring(0, maxLength) + "...";
}
}
@At
@SaCheckPermission("qsv.survey")
@ApiOperation("选项选择详情")
public Result selectOptionUsers(@Valid String activityId, @Valid String subjectId, @Valid String optionId) {
List<QsvUserAnswerRecord> answerRecords = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId));
List<QsvUserAnswerRecord> selectOptionUsers = answerRecords.stream().filter(ext -> ObjectUtil.isNotNull(ext.getExtJson().get(subjectId, JSONObject.class)) && ext.getExtJson().get(subjectId, JSONObject.class).getJSONArray("optionIds").contains(optionId)).toList();
public Result selectOptionUsers(@Valid String activityId, @Valid String subjectId, @Valid String optionId, String conditions) {
List<NutMap> selectOptionUsers = qsvSurveyService.selectOptionUsers(activityId, subjectId, optionId, conditions);
return Result.success(selectOptionUsers);
}
@@ -95,6 +387,7 @@ public class QsvSurveyController {
@At
@SaCheckPermission("qsv.survey")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除用户答题记录")
@SLog(type = "qsv.survey", tag = "删除用户答题记录", msg = "删除用户答题记录")
public Result deleteUserAnswer(@Valid String id) {
@@ -1,19 +1,29 @@
package com.budwk.app.zhgh.dayofficework.qsv.h5controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv")
@@ -23,6 +33,8 @@ public class H5QsvController {
@Inject
private QsvActivityService qsvActivityService;
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/qsv/index.html")
@@ -33,13 +45,69 @@ public class H5QsvController {
@At
@SaCheckPermission("h5.qsv")
public Result pageData(@Valid PageForm pageForm, @Valid String category) {
public Result pageData(@Valid PageForm pageForm, @Valid String category, @Param("title") String title) {
Cnd cnd = Cnd.NEW();
cnd.andEX("category", "=", category);
cnd.and(Cnd.likeEX("title", title));
cnd.and(Cnd.exps("enabled", "=", true).or("enabled", "is", null));
LocalDate currentYearFirstDay = LocalDate.now().withDayOfYear(1);
Date currentYearStart = Date.from(currentYearFirstDay.atStartOfDay(ZoneId.systemDefault()).toInstant());
Date nextYearStart = Date.from(currentYearFirstDay.plusYears(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
cnd.and("startTime", ">=", currentYearStart);
cnd.and("startTime", "<", nextYearStart);
cnd.desc("startTime");
Pagination pagination = qsvActivityService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
fillAnsweredStatus(pagination);
return Result.success(pagination);
}
private void fillAnsweredStatus(Pagination pagination) {
List<NutMap> rows = pagination.getList();
if (rows == null || rows.isEmpty()) {
return;
}
List<String> activityIds = rows.stream()
.map(this::getRowActivityId)
.filter(id -> id != null && !id.isBlank())
.collect(Collectors.toList());
if (activityIds.isEmpty()) {
return;
}
Set<String> answeredActivityIds = dao.query(QsvUserAnswerRecord.class, Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("activityId", "in", activityIds)
.and("isFinish", "=", true))
.stream()
.map(QsvUserAnswerRecord::getActivityId)
.collect(Collectors.toSet());
rows.forEach(row -> {
boolean isAnswered = answeredActivityIds.contains(getRowActivityId(row));
row.put("isAnswered", isAnswered);
row.put("answeredText", isAnswered ? getAnsweredText(row.getString("category")) : "");
});
}
private String getRowActivityId(NutMap row) {
String id = row.getString("id");
if (id == null || id.isBlank()) {
id = row.getString("ID");
}
if (id == null || id.isBlank()) {
id = row.getString("activityId");
}
return id;
}
private String getAnsweredText(String category) {
if ("QUIZ".equals(category)) {
return "已答题";
}
if ("SURVEY".equals(category)) {
return "已填写";
}
if ("VOTE".equals(category)) {
return "已投票";
}
return "已完成";
}
}
@@ -124,7 +124,9 @@ public class H5QsvQuizController {
//首次进来生成答题记录
if ("ALL".equals(displayMode)) {
resultSubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
answerRecordId = qsvUserAnswerRecordService.insertRecord(activityId, resultSubjects.stream().map(QsvSubject::getId).collect(Collectors.toList())).getId();
QsvUserAnswerRecord answerRecord = qsvUserAnswerRecordService.insertRecord(activityId, resultSubjects.stream().map(QsvSubject::getId).collect(Collectors.toList()));
answerRecordId = answerRecord.getId();
resultSubjects = querySubjectsByRecordOrder(answerRecord.getSubjectIds());
} else if ("RANDOM".equals(displayMode)) {
//单次随机抽取题目数量
Integer randomCount = activity.getRandomCount();
@@ -333,8 +335,14 @@ public class H5QsvQuizController {
@At
@ApiOperation("提交答题")
@SLog(type = "qsv.quiz", tag = "提交答题", msg = "问卷调查提交答题")
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
// 提交写入前统一校验多选题最大选择数,避免移动端绕过页面限制提交超量选项。
String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam);
if (maxMultiLimitMessage != null) {
return Result.error(maxMultiLimitMessage);
}
JSONObject extJson = answerRecord.getExtJson();
float totalScore = 0;
@@ -396,4 +404,17 @@ public class H5QsvQuizController {
return Result.success(result);
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, java.util.function.Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
}
@@ -12,6 +12,8 @@ import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -22,7 +24,11 @@ import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv/survey")
@@ -40,8 +46,13 @@ public class H5QsvSurveyController {
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result subjects(String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
@@ -51,20 +62,21 @@ public class H5QsvSurveyController {
String answerRecordId = null;
List<QsvSubject> activitySubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
if (answerRecord == null) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
qsvUserAnswerRecordService.insertRecord(activityId, subjects.stream().map(QsvSubject::getId).toList());
qsvUserAnswerRecordService.insertRecord(activityId, activitySubjects.stream().map(QsvSubject::getId).toList());
}
QsvUserAnswerRecord answerRecord2 = dao.fetch(QsvUserAnswerRecord.class,
Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
syncAnswerRecordSubjects(answerRecord2, activitySubjects);
answerRecordId = answerRecord2.getId();
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", answerRecord2.getSubjectIds()).asc("sortNum"));
List<QsvSubject> subjects = querySubjectsByRecordOrder(answerRecord2.getSubjectIds());
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
for (QsvSubject subject : subjects) {
@@ -77,6 +89,95 @@ public class H5QsvSurveyController {
return Result.success(result);
}
/**
* 校验调查活动是否处于可答题状态。activity 为空表示活动不存在;
* enabled 为 false 表示活动已关闭,当前时间早于 startTime 表示未开始,当前时间晚于 endTime 表示已结束。
*
* @param activity 当前调查活动,包含开启状态、开始时间和结束时间
* @return 状态或时间不允许答题时返回错误结果,允许答题时返回 null
*/
private Result checkActivityStatus(QsvActivity activity) {
if (activity == null) {
return Result.error("调查不存在");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("活动已关闭,暂不能参与");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("调查尚未开始,请在开始后再参与");
}
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("调查已结束");
}
return null;
}
/**
* 同步问卷当前题目到未完成答题记录。后台新增题目后,已进入过问卷的用户记录里没有新题ID,
* 这里补齐 subjectIds 和 extJson,保证手机端能拿到最新题目并正常提交答案。
*
* @param answerRecord 用户当前问卷答题记录
* @param activitySubjects 当前活动下按排序查询出的最新题目列表
*/
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
List<String> currentSubjectIds = activitySubjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
JSONObject entry = new JSONObject();
entry.set("optionIds", new ArrayList<>());
entry.set("text", null);
entry.set("optionFillContents", new JSONObject());
extJson.set(subjectId, entry);
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
@At
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
@@ -84,8 +185,20 @@ public class H5QsvSurveyController {
}
@At
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityStatus(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
// 提交写入前统一校验多选题最大选择数,避免移动端绕过页面限制提交超量选项。
String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam);
if (maxMultiLimitMessage != null) {
return Result.error(maxMultiLimitMessage);
}
JSONObject extJson = answerRecord.getExtJson();
for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) {
@@ -93,6 +206,7 @@ public class H5QsvSurveyController {
if (ObjectUtil.isNotEmpty(entries)) {
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
}
extJson.set(subject.getId(), entries);
}
@@ -0,0 +1,339 @@
package com.budwk.app.zhgh.dayofficework.qsv.h5controller;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@IocBean
@At("/platform/h5/qsv/vote")
@Ok("json:full")
/**
* 移动端投票控制器,负责投票页面、题目读取、投票提交和排行榜统计。
*/
public class H5QsvVoteController {
@Inject
private Dao dao;
@Inject
private QsvUserAnswerRecordService qsvUserAnswerRecordService;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/qsv/vote/index.html")
/**
* 打开移动端投票页面。
*/
public void index() {
}
/**
* 查询投票题目,并为首次进入的用户创建投票记录。
*
* @param activityId 投票活动 ID
* @return 投票活动、题目、答题记录和统计数据
*/
@At
@Aop(TransAop.READ_COMMITTED)
public Result subjects(String activityId) {
QsvActivity activity = dao.fetch(QsvActivity.class, activityId);
Result activityStatusResult = checkActivityReadable(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
Result scopeResult = checkScope(activity);
if (scopeResult != null) {
return scopeResult;
}
List<QsvSubject> activitySubjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("userId", "=", SecurityUtil.getUserId()));
if (answerRecord == null) {
// 首次进入投票时初始化记录,后续提交时复用同一条记录。
List<String> subjectIds = activitySubjects.stream()
.map(QsvSubject::getId)
.collect(Collectors.toList());
answerRecord = qsvUserAnswerRecordService.insertRecord(activityId, subjectIds);
}
syncAnswerRecordSubjects(answerRecord, activitySubjects);
List<QsvSubject> subjects = querySubjectsByRecordOrder(answerRecord.getSubjectIds());
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
for (QsvSubject subject : subjects) {
if (subject.getUserSelectOptionIds() == null) {
subject.setUserSelectOptionIds(new ArrayList<>());
}
}
NutMap result = NutMap.NEW()
.addv("subjects", subjects)
.addv("answerRecordId", answerRecord.getId())
.addv("answerRecord", answerRecord)
.addv("activity", activity)
.addv("voteStats", buildVoteStats(activityId));
return Result.success(result);
}
@At
/**
* 查询投票记录,用于恢复用户已选择的投票项。
*
* @param answerRecordId 投票记录 ID
* @return 投票记录
*/
public Result answerRecord(@Valid String answerRecordId) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, answerRecordId);
return Result.success(record);
}
@At
/**
* 查询投票排行榜和统计数据。
*
* @param activityId 投票活动 ID
* @return 投票统计数据
*/
public Result rank(@Valid String activityId) {
return Result.success(buildVoteStats(activityId));
}
/**
* 提交移动端投票结果,投票成功后标记记录为完成。
*
* @param qsvAnswerParam 用户提交的投票答案
* @return 提交结果
*/
@At
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvActivity activity = dao.fetch(QsvActivity.class, qsvAnswerParam.getActivityId());
Result activityStatusResult = checkActivityWritable(activity);
if (activityStatusResult != null) {
return activityStatusResult;
}
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
if (answerRecord == null || !SecurityUtil.getUserId().equals(answerRecord.getUserId())) {
return Result.error("\u6295\u7968\u8bb0\u5f55\u4e0d\u5b58\u5728");
}
if (Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return Result.error("\u60a8\u5df2\u5b8c\u6210\u6295\u7968");
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
// 提交写入前统一校验多选题最大选择数,避免移动端投票绕过页面限制提交超量选项。
String maxMultiLimitMessage = qsvUserAnswerRecordService.checkMaxMultiLimit(qsvAnswerParam);
if (maxMultiLimitMessage != null) {
return Result.error(maxMultiLimitMessage);
}
for (QsvAnswerParam.Subject subject : qsvAnswerParam.getSubjects()) {
// 逐题保存用户选择和补充内容,便于统计票数与查看详情。
JSONObject entries = extJson.get(subject.getId(), JSONObject.class);
if (entries == null) {
entries = new JSONObject();
}
entries.set("optionIds", subject.getUserSelectOptionIds());
entries.set("text", subject.getUserFillContent());
entries.set("optionFillContents", subject.getOptionFillContents());
extJson.set(subject.getId(), entries);
}
answerRecord.setExtJson(extJson);
answerRecord.setAttemptDate(new Date());
answerRecord.setSubmitTime(new Date());
answerRecord.setIsFinish(true);
dao.update(answerRecord);
return Result.success("\u6295\u7968\u6210\u529f");
}
private Result checkActivityReadable(QsvActivity activity) {
if (activity == null) {
return Result.error("\u6295\u7968\u4e0d\u5b58\u5728");
}
if (Boolean.FALSE.equals(activity.getEnabled())) {
return Result.error("\u6d3b\u52a8\u5df2\u5173\u95ed\uff0c\u6682\u4e0d\u80fd\u53c2\u4e0e");
}
Date now = new Date();
if (activity.getStartTime() != null && activity.getStartTime().after(now)) {
return Result.error("\u6295\u7968\u5c1a\u672a\u5f00\u59cb\uff0c\u8bf7\u5728\u5f00\u59cb\u540e\u518d\u53c2\u4e0e");
}
return null;
}
private Result checkActivityWritable(QsvActivity activity) {
Result readableResult = checkActivityReadable(activity);
if (readableResult != null) {
return readableResult;
}
Date now = new Date();
if (activity.getEndTime() != null && activity.getEndTime().before(now)) {
return Result.error("\u6295\u7968\u5df2\u7ed3\u675f");
}
return null;
}
private Result checkScope(QsvActivity activity) {
if (activity.getGroupId() == null) {
return null;
}
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getGroupId())
.and("userId", "=", SecurityUtil.getUserId()));
if (count != 1) {
return Result.error("\u60a8\u65e0\u9700\u53c2\u52a0\u6b64\u6b21\u6295\u7968\uff0c\u611f\u8c22\u60a8\u7684\u5173\u6ce8\uff01");
}
return null;
}
private void syncAnswerRecordSubjects(QsvUserAnswerRecord answerRecord, List<QsvSubject> activitySubjects) {
if (answerRecord == null || Boolean.TRUE.equals(answerRecord.getIsFinish())) {
return;
}
// 后台调整投票题目后,未完成记录需要同步当前有效题目,避免提交旧题目。
List<String> currentSubjectIds = activitySubjects.stream()
.map(QsvSubject::getId)
.collect(Collectors.toList());
List<String> recordSubjectIds = answerRecord.getSubjectIds();
if (recordSubjectIds == null) {
recordSubjectIds = new ArrayList<>();
}
JSONObject extJson = answerRecord.getExtJson();
if (extJson == null) {
extJson = new JSONObject();
}
LinkedHashSet<String> currentSubjectIdSet = new LinkedHashSet<>(currentSubjectIds);
List<String> normalizedSubjectIds = recordSubjectIds.stream()
.filter(currentSubjectIdSet::contains)
.collect(Collectors.toList());
for (String subjectId : currentSubjectIds) {
if (!normalizedSubjectIds.contains(subjectId)) {
normalizedSubjectIds.add(subjectId);
}
}
boolean changed = !recordSubjectIds.equals(normalizedSubjectIds);
for (String subjectId : currentSubjectIds) {
if (ObjectUtil.isEmpty(extJson.get(subjectId, JSONObject.class))) {
JSONObject entry = new JSONObject();
entry.set("optionIds", new ArrayList<>());
entry.set("text", null);
entry.set("optionFillContents", new JSONObject());
extJson.set(subjectId, entry);
changed = true;
}
}
if (changed) {
answerRecord.setSubjectIds(normalizedSubjectIds);
answerRecord.setExtJson(extJson);
dao.update(answerRecord);
}
}
private List<QsvSubject> querySubjectsByRecordOrder(List<String> subjectIds) {
if (subjectIds == null || subjectIds.isEmpty()) {
return new ArrayList<>();
}
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("id", "in", subjectIds));
Map<String, QsvSubject> subjectMap = subjects.stream()
.collect(Collectors.toMap(QsvSubject::getId, Function.identity(), (oldValue, newValue) -> oldValue));
return subjectIds.stream()
.map(subjectMap::get)
.filter(ObjectUtil::isNotEmpty)
.collect(Collectors.toList());
}
private NutMap buildVoteStats(String activityId) {
List<QsvSubject> subjects = dao.query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
dao.fetchLinks(subjects, "options", Cnd.NEW().asc("sortNum"));
Map<String, NutMap> optionStats = new LinkedHashMap<>();
for (QsvSubject subject : subjects) {
if (subject.getOptions() == null) {
continue;
}
for (QsvOption option : subject.getOptions()) {
optionStats.put(option.getId(), NutMap.NEW()
.addv("optionId", option.getId())
.addv("subjectId", subject.getId())
.addv("text", option.getText())
.addv("imgUrl", option.getImgUrl())
.addv("votes", 0)
.addv("percent", 0D));
}
}
List<QsvUserAnswerRecord> records = dao.query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
for (QsvUserAnswerRecord record : records) {
JSONObject extJson = record.getExtJson();
if (extJson == null) {
continue;
}
for (QsvSubject subject : subjects) {
JSONObject entry = extJson.get(subject.getId(), JSONObject.class);
if (entry == null) {
continue;
}
List<String> optionIds = entry.getBeanList("optionIds", String.class);
if (optionIds == null) {
continue;
}
for (String optionId : optionIds) {
NutMap stat = optionStats.get(optionId);
if (stat != null) {
stat.put("votes", stat.getInt("votes", 0) + 1);
}
}
}
}
int totalParticipants = records.size();
List<NutMap> rankList = new ArrayList<>(optionStats.values());
for (NutMap stat : rankList) {
int votes = stat.getInt("votes", 0);
double percent = totalParticipants == 0 ? 0D : votes * 100D / totalParticipants;
stat.put("percent", percent);
}
rankList.sort((left, right) -> Integer.compare(right.getInt("votes", 0), left.getInt("votes", 0)));
return NutMap.NEW()
.addv("totalParticipants", totalParticipants)
.addv("optionCount", optionStats.size())
.addv("optionStats", optionStats)
.addv("rankList", rankList);
}
}
@@ -1,6 +1,8 @@
package com.budwk.app.zhgh.dayofficework.qsv.models;
import com.budwk.app.base.model.BaseModel;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.sys.services.SysHomeConvert;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
@@ -14,7 +16,7 @@ import java.util.Date;
@Table("qsv_activity")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("问卷调查投票活动表")
public class QsvActivity extends BaseModel implements Serializable {
public class QsvActivity extends BaseModel implements Serializable, SysHomeConvert {
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -46,6 +48,12 @@ public class QsvActivity extends BaseModel implements Serializable {
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("是否开启")
@ColDefine(type = ColType.BOOLEAN)
@Default("1")
private Boolean enabled;
@Column
@Comment("活动分组ID")
@ColDefine(type = ColType.INT)
@@ -107,4 +115,35 @@ public class QsvActivity extends BaseModel implements Serializable {
@Comment("封面图片")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String cover;
@Override
public Sys_home_activity covertToSysHomeActivity() {
Sys_home_activity sysHomeActivity = new Sys_home_activity();
sysHomeActivity.setId(this.getId());
sysHomeActivity.setName(this.getTitle());
sysHomeActivity.setCover(this.getCover());
sysHomeActivity.setContent(this.getDescription());
sysHomeActivity.setUrl("/platform/qsv/online");
sysHomeActivity.setH5Url(getH5Url());
sysHomeActivity.setStartDate(this.getStartTime());
sysHomeActivity.setEndDate(this.getEndTime());
sysHomeActivity.setAllowUserGroupId(this.getGroupId());
sysHomeActivity.setEnable(!Boolean.FALSE.equals(this.getEnabled()));
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
return sysHomeActivity;
}
/**
* 根据活动类型生成移动端首页跳转地址。
* QUIZ 跳转答题页,SURVEY 跳转调查页,VOTE 预留投票页地址,返回值为移动端路由字符串。
*/
private String getH5Url() {
if ("SURVEY".equals(this.getCategory())) {
return "/platform/h5/qsv/survey?id=" + this.getId();
}
if ("VOTE".equals(this.getCategory())) {
return "/platform/h5/qsv/vote?id=" + this.getId();
}
return "/platform/h5/qsv/quiz?id=" + this.getId();
}
}
@@ -51,6 +51,16 @@ public class QsvOption extends BaseModel implements Serializable {
@ColDefine(customType = "longtext")
private String description;
@Column
@Comment("选中后是否需要填写补充内容")
@ColDefine(type = ColType.BOOLEAN)
private Boolean fillRequired;
@Column
@Comment("补充内容输入提示")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String fillPlaceholder;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT)
@@ -34,10 +34,15 @@ public class QsvSubject extends BaseModel implements Serializable {
private String title;
@Column
@Comment("题目类型(single, multi, judge, fill)")
@Comment("题目类型(radio单选,checkbox多选,text填空)")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String type;
@Column
@Comment("题目组件展示类型(RADIO普通单选,DROPDOWN下拉选择)")
@ColDefine(type = ColType.VARCHAR, width = 30)
private String componentType;
@Column
@Comment("正确答案")
@ColDefine(type = ColType.MYSQL_JSON)
@@ -58,11 +63,41 @@ public class QsvSubject extends BaseModel implements Serializable {
@ColDefine(type = ColType.INT, width = 1)
private Integer maxMulti;
@Column
@Comment("投票选项排列方式")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String optionLayout;
@Column
@Comment("投票选项横向列数")
@ColDefine(type = ColType.INT, width = 1)
private Integer optionColumns;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT, width = 1)
private Integer sortNum;
@Column
@Comment("是否启用题目隐显逻辑")
@ColDefine(type = ColType.BOOLEAN)
private Boolean visibleRuleEnabled;
@Column
@Comment("隐显逻辑处理方式(show显示,hide隐藏)")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String visibleRuleAction;
@Column
@Comment("隐显逻辑条件关系(AND并且,OR或者)")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String visibleRuleLogic;
@Column
@Comment("隐显逻辑条件列表")
@ColDefine(type = ColType.MYSQL_JSON)
private List<VisibleRuleCondition> visibleRuleConditions;
/**
* 选项
*/
@@ -79,4 +114,52 @@ public class QsvSubject extends BaseModel implements Serializable {
*/
private String userFillContent;
/**
* 题目隐显逻辑条件,一条条件表示选择指定题目的指定选项后参与显示或隐藏判断。
*/
@Data
public static class VisibleRuleCondition implements Serializable {
/**
* 作为触发条件的题目ID。
*/
private String subjectId;
/**
* 作为触发条件的选项ID。
*/
private String optionId;
/**
* 条件节点类型:group 分组,item 条件项。
*/
private String type;
/**
* 分组内条件关系:and 并且,or 或者。
*/
private String logic;
/**
* 条件项引用的题目ID。
*/
private String field;
/**
* 比较符:==、!=、contains、>、<、>=、<=。
*/
private String operator;
/**
* 比较值,选择题为选项ID,填空题可为文本或数字。
*/
private Object value;
/**
* 子条件节点,支持条件分组嵌套。
*/
private List<VisibleRuleCondition> children;
}
}
@@ -3,6 +3,7 @@ package com.budwk.app.zhgh.dayofficework.qsv.param;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
//答题参数
@@ -21,6 +22,7 @@ public class QsvAnswerParam {
private String id;
private List<String> userSelectOptionIds;
private String userFillContent;
private Map<String, String> optionFillContents;
}
}
@@ -3,6 +3,26 @@ package com.budwk.app.zhgh.dayofficework.qsv.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import org.nutz.lang.util.NutMap;
public interface QsvActivityService extends BaseService<QsvActivity> {
/**
* Save activity with normal mode-change protection.
*/
QsvActivity saveActivity(QsvActivity qsvActivity);
/**
* Save activity and optionally bypass the answer-mode change confirmation after the user confirms.
*/
QsvActivity saveActivity(QsvActivity qsvActivity, Boolean forceSave);
/**
* Check whether changing answer mode needs user confirmation because answer records already exist.
*/
NutMap checkAnswerModeChangeConfirm(QsvActivity qsvActivity);
void deleteActivity(String id);
void updateEnabled(String id, Boolean enabled);
}
@@ -12,6 +12,14 @@ public interface QsvSurveyService extends BaseService<QsvUserAnswerRecord> {
List<NutMap> report(String activityId);
List<NutMap> categoryReport(String activityId, String conditions);
NutMap crossReport(String activityId, String xSubjectIds, String ySubjectIds, String conditions);
NutMap compareReport(String activityId, String subjectId, String optionId);
List<NutMap> selectOptionUsers(String activityId, String subjectId, String optionId, String conditions);
void exportReportXlsx(String activityId, HttpServletResponse response);
void exportUserAnswerXlsx(String activityId, HttpServletResponse response);
@@ -17,6 +17,14 @@ public interface QsvUserAnswerRecordService extends BaseService<QsvUserAnswerRec
*/
float calcScore(QsvAnswerParam qsvAnswerParam);
/**
* 校验多选题提交答案是否超过题目配置的最大选择数,返回空表示校验通过。
*
* @param qsvAnswerParam 用户提交的答题参数
* @return 超限提示文案,空表示不超限
*/
String checkMaxMultiLimit(QsvAnswerParam qsvAnswerParam);
void calcByScoreMode(String activityId, String userId);
}
@@ -1,10 +1,23 @@
package com.budwk.app.zhgh.dayofficework.qsv.service.impl;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvActivityService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"})
public class QsvActivityServiceImpl extends BaseServiceImpl<QsvActivity> implements QsvActivityService {
@@ -12,4 +25,108 @@ public class QsvActivityServiceImpl extends BaseServiceImpl<QsvActivity> impleme
public QsvActivityServiceImpl(Dao dao) {
super(dao);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public QsvActivity saveActivity(QsvActivity qsvActivity) {
return saveActivity(qsvActivity, false);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public QsvActivity saveActivity(QsvActivity qsvActivity, Boolean forceSave) {
NutMap confirmResult = checkAnswerModeChangeConfirm(qsvActivity);
if (!Boolean.TRUE.equals(forceSave) && Boolean.TRUE.equals(confirmResult.get("needConfirm"))) {
throw new IllegalStateException(confirmResult.getString("message"));
}
fillQuizRepeatMode(qsvActivity);
dao().insertOrUpdate(qsvActivity);
syncHomeActivity(qsvActivity);
return qsvActivity;
}
@Override
public NutMap checkAnswerModeChangeConfirm(QsvActivity qsvActivity) {
NutMap result = NutMap.NEW();
result.put("needConfirm", false);
result.put("message", "");
if (qsvActivity == null || Strings.isBlank(qsvActivity.getId())) {
return result;
}
QsvActivity oldActivity = dao().fetch(QsvActivity.class, qsvActivity.getId());
if (oldActivity == null || !isAnswerModeChanged(oldActivity, qsvActivity)) {
return result;
}
int answerRecordCount = dao().count(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", qsvActivity.getId()));
if (answerRecordCount < 1) {
return result;
}
// Answer-mode changes affect unfinished answers, submitted answers and statistics, so the editor must confirm first.
result.put("needConfirm", true);
result.put("message", "\u5f53\u524d\u95ee\u5377\u5df2\u6709\u7528\u6237\u7b54\u9898\u4e2d\u6216\u5df2\u7b54\u9898\u5b8c\u6210\uff0c\u4fee\u6539\u7b54\u9898\u6a21\u5f0f\u53ef\u80fd\u5f71\u54cd\u73b0\u6709\u7b54\u9898\u8bb0\u5f55\u548c\u7edf\u8ba1\u7ed3\u679c\uff0c\u662f\u5426\u786e\u8ba4\u4fdd\u5b58\uff1f");
return result;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void deleteActivity(String id) {
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", id));
List<String> subjectIds = subjects.stream().map(QsvSubject::getId).collect(Collectors.toList());
if (!subjectIds.isEmpty()) {
dao().clear(QsvOption.class, Cnd.where("subjectId", "in", subjectIds));
dao().clear(QsvSubject.class, Cnd.where("id", "in", subjectIds));
}
dao().clear(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", id));
dao().delete(QsvActivity.class, id);
dao().delete(Sys_home_activity.class, id);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void updateEnabled(String id, Boolean enabled) {
QsvActivity activity = dao().fetch(QsvActivity.class, id);
if (activity == null) {
return;
}
activity.setEnabled(enabled);
dao().update(activity);
syncHomeActivity(activity);
}
/**
* 答题活动的重复模式由出题模式决定。定时定题按天统计,常规模式按活动总次数统计。
*/
private void fillQuizRepeatMode(QsvActivity qsvActivity) {
if (!"QUIZ".equals(qsvActivity.getCategory())) {
return;
}
if ("SCHEDULED".equals(qsvActivity.getMode())) {
qsvActivity.setRepeatMode("DAILY");
} else if ("REGULAR".equals(qsvActivity.getMode())) {
qsvActivity.setRepeatMode("TOTAL");
}
}
/**
* 判断编辑前后的答题模式是否变化;问卷、答题等活动只要 mode 发生变化都需要参与风险确认。
*/
private boolean isAnswerModeChanged(QsvActivity oldActivity, QsvActivity newActivity) {
String oldMode = Strings.sNull(oldActivity.getMode()).trim();
String newMode = Strings.sNull(newActivity.getMode()).trim();
if (Strings.isBlank(oldMode) && Strings.isBlank(newMode)) {
return false;
}
return !Objects.equals(oldMode, newMode);
}
/**
* 同步移动端首页活动。问卷活动保存或启停后,都以活动自身 enabled、时间、人员范围生成首页记录。
*/
private void syncHomeActivity(QsvActivity activity) {
Sys_home_activity sysHomeActivity = activity.covertToSysHomeActivity();
dao().insertOrUpdate(sysHomeActivity);
}
}
@@ -90,6 +90,7 @@ public class QsvQuizRankServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord>
private Sql buildQuerySql(String activityId, String mode, String scoreStatisticsMode, QsvQuizRankPageForm pageForm) {
StringBuilder sqlBuilder = new StringBuilder("""
SELECT
t1.id,
t1.userId,
t1.userName,
t1.loginName,
@@ -119,17 +120,11 @@ public class QsvQuizRankServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord>
cnd.andEX("t1.unionId","=",pageForm.getUnionId());
cnd.andEX("t1.unitId","=",pageForm.getUnitId());
if ("SCHEDULED".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore","=",1);
} else if ("LAST".equals(scoreStatisticsMode)) {
cnd.and("t1.isLatestScore","=",1);
}
} else if ("REGULAR".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore","=",1);
} else if ("LAST".equals(scoreStatisticsMode)) {
cnd.and("t1.isLatestScore","=",1);
if ("SCHEDULED".equals(mode) || "REGULAR".equals(mode)) {
if ("HIGH".equals(scoreStatisticsMode) || "HIGHEST".equals(scoreStatisticsMode)) {
cnd.and("t1.isHighestScore", "=", 1);
} else {
cnd.and("t1.isLatestScore", "=", 1);
}
}
@@ -8,6 +8,7 @@ import cn.afterturn.easypoi.excel.export.ExcelExportService;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
@@ -68,47 +69,47 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// 处理每个题目
for (NutMap subject : subjects) {
// 获取当前题目的选项列表
List<NutMap> subjectOptions = Lang.collection2list(optionsGroup.get(subject.getString("id")), NutMap.class);
List<QsvOption> currentOptions = optionsGroup.get(subject.getString("id"));
List<NutMap> subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class);
// 获取题目类型
String subjectType = subject.getString("type");
// 处理文本类型题目
if ("text".equals(subjectType)) {
List<String> texts = answerExtList.stream()
.filter(ext -> ObjectUtil.isNull(ext.get(subject.getString("id"), JSONObject.class)))
.map(ext -> ext.get(subject.getString("id"), JSONObject.class).getStr("text"))
List<NutMap> textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id"));
List<String> texts = textAnswers.stream()
.map(textAnswer -> textAnswer.getString("text"))
.toList();
subject.put("texts", texts);
subject.put("textAnswers", textAnswers);
}
// 处理单选类型题目 处理多选类型题目
else if ("radio".equals(subjectType) || "checkbox".equals(subjectType)) {
long selectTotal = answerExtList.stream()
.filter(ext -> ext.containsKey(subject.getString("id")))
.filter(ext -> ObjectUtil.isNotEmpty(ext) && ext.containsKey(subject.getString("id")))
.count();
subjectOptions.forEach(subjectOption -> {
long selectCount = answerExtList.stream()
.filter(ext ->
{
if (ObjectUtil.isEmpty(ext)) {
return false;
}
JSONObject jsonObject = ext.get(subject.getString("id"), JSONObject.class);
return jsonObject != null
&& jsonObject.getJSONArray("optionIds") != null
&& jsonObject.getJSONArray("optionIds").contains(subjectOption.getString("id"));
})
.count();
long round = Math.round((double) selectCount / selectTotal * 100);
long round = selectTotal == 0 ? 0 : Math.round((double) selectCount / selectTotal * 100);
subjectOption.put("selectPercent", round + "%");
subjectOption.put("selectCount", selectCount);
});
subjectOptions.sort((o1, o2) -> {
int count1 = o1.getInt("selectCount");
int count2 = o2.getInt("selectCount");
if (count1 == count2) {
return Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum"));
}
return Integer.compare(count2, count1);
return Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum"));
});
subject.put("selectTotal", selectTotal);
@@ -125,6 +126,228 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
}
@Override
public List<NutMap> categoryReport(String activityId, String conditions) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
try {
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
return buildReport(activityId, filterAnswerRecords(answerRecords, conditionList));
} catch (Exception e) {
log.error("分类报告生成失败", e);
throw new RuntimeException("分类报告生成失败", e);
}
}
@Override
public NutMap crossReport(String activityId, String xSubjectIds, String ySubjectIds, String conditions) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
List<String> xSubjectIdList = parseSubjectIds(xSubjectIds);
List<String> ySubjectIdList = parseSubjectIds(ySubjectIds);
if (ObjectUtil.isEmpty(xSubjectIdList) || ObjectUtil.isEmpty(ySubjectIdList)) {
throw new IllegalArgumentException("请选择自变量X和因变量Y");
}
if (xSubjectIdList.stream().anyMatch(ySubjectIdList::contains)) {
throw new IllegalArgumentException("自变量X和因变量Y不能选择相同变量");
}
try {
List<NutMap> subjects = querySubjects(activityId);
Map<String, NutMap> subjectMap = subjects.stream()
.collect(Collectors.toMap(subject -> subject.getString("id"), subject -> subject));
if (!subjectMap.keySet().containsAll(xSubjectIdList) || !subjectMap.keySet().containsAll(ySubjectIdList)) {
throw new IllegalArgumentException("题目不存在");
}
List<String> allSubjectIds = new ArrayList<>();
allSubjectIds.addAll(xSubjectIdList);
allSubjectIds.addAll(ySubjectIdList);
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", allSubjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
List<NutMap> xOptionMaps = buildVariableOptionCombinations(xSubjectIdList, subjectMap, optionGroup);
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
List<QsvUserAnswerRecord> filteredRecords = filterAnswerRecords(answerRecords, conditionList);
List<NutMap> reports = new ArrayList<>();
for (String ySubjectId : ySubjectIdList) {
NutMap ySubject = subjectMap.get(ySubjectId);
List<QsvOption> yOptions = optionGroup.get(ySubjectId);
if (ObjectUtil.isEmpty(yOptions)) {
yOptions = new ArrayList<>();
}
List<NutMap> yOptionMaps = ObjectUtil.isEmpty(yOptions) ? new ArrayList<>() : Lang.collection2list(yOptions, NutMap.class);
List<NutMap> rows = new ArrayList<>();
for (NutMap xOption : xOptionMaps) {
List<NutMap> xConditions = xOption.getList("conditions", NutMap.class);
List<QsvUserAnswerRecord> xMatchedRecords = filteredRecords.stream()
.filter(record -> isRecordMatched(record, xConditions))
.toList();
long rowTotal = xMatchedRecords.size();
List<NutMap> cells = new ArrayList<>();
for (QsvOption yOption : yOptions) {
List<NutMap> yCondition = List.of(NutMap.NEW()
.addv("subjectId", ySubjectId)
.addv("optionId", yOption.getId()));
long count = xMatchedRecords.stream()
.filter(record -> isRecordMatched(record, yCondition))
.count();
double percent = rowTotal == 0 ? 0 : Math.round((double) count / rowTotal * 10000) / 100.0;
cells.add(NutMap.NEW()
.addv("yOptionId", yOption.getId())
.addv("yOptionText", yOption.getText())
.addv("count", count)
.addv("percent", percent));
}
rows.add(NutMap.NEW()
.addv("xOptionId", xOption.getString("id"))
.addv("xOptionText", xOption.getString("text"))
.addv("cells", cells)
.addv("total", rowTotal));
}
reports.add(NutMap.NEW()
.addv("ySubject", ySubject)
.addv("yOptions", yOptionMaps)
.addv("rows", rows));
}
return NutMap.NEW()
.addv("xSubject", buildVariableSubject(xSubjectIdList, subjectMap))
.addv("xOptions", xOptionMaps)
.addv("reports", reports)
.addv("total", filteredRecords.size());
} catch (Exception e) {
log.error("交叉分析报告生成失败", e);
throw new RuntimeException("交叉分析报告生成失败", e);
}
}
@Override
public NutMap compareReport(String activityId, String subjectId, String optionId) {
if (activityId == null || activityId.isEmpty()) {
throw new IllegalArgumentException("activityId不能为空");
}
if (ObjectUtil.isEmpty(subjectId) || ObjectUtil.isEmpty(optionId)) {
throw new IllegalArgumentException("请选择对比变量和选项");
}
try {
List<NutMap> subjects = querySubjects(activityId);
Map<String, NutMap> subjectMap = subjects.stream()
.collect(Collectors.toMap(subject -> subject.getString("id"), subject -> subject));
NutMap compareSubject = subjectMap.get(subjectId);
if (compareSubject == null) {
throw new IllegalArgumentException("对比变量不存在");
}
List<String> subjectIds = subjects.stream().map(subject -> subject.getString("id")).toList();
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
QsvOption compareOption = optionGroup.getOrDefault(subjectId, new ArrayList<>()).stream()
.filter(option -> optionId.equals(option.getId()))
.findFirst()
.orElse(null);
if (compareOption == null) {
throw new IllegalArgumentException("对比选项不存在");
}
List<NutMap> compareCondition = List.of(NutMap.NEW()
.addv("subjectId", subjectId)
.addv("optionId", optionId));
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
List<QsvUserAnswerRecord> filteredRecords = filterAnswerRecords(answerRecords, compareCondition);
List<NutMap> reports = new ArrayList<>();
for (NutMap subject : subjects) {
String currentSubjectId = subject.getString("id");
String subjectType = subject.getString("type");
if (subjectId.equals(currentSubjectId) || (!"radio".equals(subjectType) && !"checkbox".equals(subjectType))) {
continue;
}
List<QsvOption> currentOptions = optionGroup.get(currentSubjectId);
if (ObjectUtil.isEmpty(currentOptions)) {
continue;
}
List<NutMap> cells = new ArrayList<>();
long rowTotal = filteredRecords.size();
for (QsvOption option : currentOptions) {
List<NutMap> optionCondition = List.of(NutMap.NEW()
.addv("subjectId", currentSubjectId)
.addv("optionId", option.getId()));
long count = filteredRecords.stream()
.filter(record -> isRecordMatched(record, optionCondition))
.count();
double percent = rowTotal == 0 ? 0 : Math.round((double) count / rowTotal * 10000) / 100.0;
cells.add(NutMap.NEW()
.addv("yOptionId", option.getId())
.addv("yOptionText", option.getText())
.addv("count", count)
.addv("percent", percent));
}
reports.add(NutMap.NEW()
.addv("ySubject", subject)
.addv("yOptions", Lang.collection2list(currentOptions, NutMap.class))
.addv("rows", List.of(NutMap.NEW()
.addv("xOptionId", optionId)
.addv("xOptionText", compareOption.getText())
.addv("cells", cells)
.addv("total", rowTotal))));
}
return NutMap.NEW()
.addv("compareSubject", compareSubject)
.addv("compareOption", Lang.obj2nutmap(compareOption))
.addv("reports", reports)
.addv("total", filteredRecords.size());
} catch (Exception e) {
log.error("对比分析报告生成失败", e);
throw new RuntimeException("对比分析报告生成失败", e);
}
}
@Override
public List<NutMap> selectOptionUsers(String activityId, String subjectId, String optionId, String conditions) {
List<NutMap> conditionList = parseCategoryConditions(conditions);
List<QsvUserAnswerRecord> answerRecords = dao().query(QsvUserAnswerRecord.class, Cnd.where("activityId", "=", activityId)
.and("isFinish", "=", true));
return filterAnswerRecords(answerRecords, conditionList).stream().map(record -> {
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return null;
}
JSONObject subjectAnswer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer)) {
return null;
}
JSONArray optionIds = subjectAnswer.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds) || !optionIds.contains(optionId)) {
return null;
}
JSONObject optionFillContents = subjectAnswer.getJSONObject("optionFillContents");
String fillContent = ObjectUtil.isEmpty(optionFillContents) ? "" : optionFillContents.getStr(optionId);
return NutMap.NEW()
.addv("userName", record.getUserName())
.addv("loginName", record.getLoginName())
.addv("unionName", record.getUnionName())
.addv("unitName", record.getUnitName())
.addv("attemptDate", record.getAttemptDate())
.addv("submitTime", record.getSubmitTime())
.addv("fillContent", fillContent);
})
.filter(ObjectUtil::isNotNull)
.toList();
}
@Override
public void exportReportXlsx(String activityId, HttpServletResponse response) {
List<NutMap> report = report(activityId);
@@ -134,10 +357,8 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
exportEntities.add(new ExcelExportEntity("选择人数", "selectCount", 20));
exportEntities.add(new ExcelExportEntity("选择比例", "selectPercent", 20));
Map<String, List<NutMap>> listMap = report.stream().collect(Collectors.groupingBy(v -> v.getString("id")));
Workbook workbook = new XSSFWorkbook();
listMap.forEach((k, v) -> {
NutMap nutMap = v.get(0);
report.forEach(nutMap -> {
ExcelExportService service = new ExcelExportService();
ExportParams exportParams = new ExportParams();
exportParams.setTitle(nutMap.getString("title"));
@@ -171,7 +392,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
// 查询活动题目列表
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId));
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
// 将题目列表转换为Map
Map<String, QsvSubject> subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v));
// 获取题目ID列表
@@ -185,7 +406,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// 构建Excel导出实体
List<ExcelExportEntity> excelExportEntities = buildExcelExportEntities(subjects);
// 构建答题记录列表
List<NutMap> list = buildAnswerRecords(answerRecords, subjectMap, options);
List<NutMap> list = buildAnswerRecords(answerRecords, subjectMap, optionMap);
// 设置导出参数
ExportParams exportParams = new ExportParams();
@@ -223,7 +444,7 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
// List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
// 查询活动题目列表
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId));
List<QsvSubject> subjects = dao().query(QsvSubject.class, Cnd.where("activityId", "=", activityId).asc("sortNum"));
// 将题目列表转换为Map
Map<String, QsvSubject> subjectMap = subjects.stream().collect(Collectors.toMap(QsvSubject::getId, v -> v));
// 获取题目ID列表
@@ -245,18 +466,15 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
.addv("unitName", record.getString("unitName"))
.addv("unionName", record.getString("unionName"));
JSONObject extJson = record.getAs("extJson", JSONObject.class);
if (ObjectUtil.isEmpty(extJson)) {
return map;
}
extJson.forEach((k, v) -> {
JSONObject jsonVal = (JSONObject) v;
String type = subjectMap.get(k).getType();
if (type.equals("text")) {
map.addv(k, jsonVal.getStr("text"));
} else if (type.equals("radio") || type.equals("checkbox")) {
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
String selectOptionTexts = options.stream().filter(option -> optionIds.contains(option.getId()))
.map(QsvOption::getText).collect(Collectors.joining(";"));
map.addv(k, selectOptionTexts);
QsvSubject subject = subjectMap.get(k);
if (subject == null || !(v instanceof JSONObject jsonVal)) {
return;
}
map.addv(k, buildAnswerText(jsonVal, subject, optionMap));
});
return map;
}).toList();
@@ -278,12 +496,178 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
}
}
private List<NutMap> buildReport(String activityId, List<QsvUserAnswerRecord> answerRecords) {
List<JSONObject> answerExtList = answerRecords.stream().map(QsvUserAnswerRecord::getExtJson).toList();
List<NutMap> subjects = querySubjects(activityId);
List<String> subjectIds = subjects.stream().map(v -> v.getString("id")).toList();
List<QsvOption> options = dao().query(QsvOption.class, Cnd.where("subjectId", "in", subjectIds).asc("sortNum"));
Map<String, List<QsvOption>> optionsGroup = options.stream().collect(Collectors.groupingBy(QsvOption::getSubjectId));
for (NutMap subject : subjects) {
List<QsvOption> currentOptions = optionsGroup.get(subject.getString("id"));
List<NutMap> subjectOptions = ObjectUtil.isEmpty(currentOptions) ? new ArrayList<>() : Lang.collection2list(currentOptions, NutMap.class);
String subjectType = subject.getString("type");
if ("text".equals(subjectType)) {
List<NutMap> textAnswers = buildTextAnswerDetails(answerRecords, subject.getString("id"));
List<String> texts = textAnswers.stream()
.map(textAnswer -> textAnswer.getString("text"))
.toList();
subject.put("texts", texts);
subject.put("textAnswers", textAnswers);
} else if ("radio".equals(subjectType) || "checkbox".equals(subjectType)) {
long selectTotal = answerExtList.stream()
.filter(ext -> ObjectUtil.isNotEmpty(ext) && ext.containsKey(subject.getString("id")))
.count();
subjectOptions.forEach(subjectOption -> {
long selectCount = answerExtList.stream()
.filter(ext -> {
if (ObjectUtil.isEmpty(ext)) {
return false;
}
JSONObject jsonObject = ext.get(subject.getString("id"), JSONObject.class);
return jsonObject != null
&& jsonObject.getJSONArray("optionIds") != null
&& jsonObject.getJSONArray("optionIds").contains(subjectOption.getString("id"));
})
.count();
long round = selectTotal == 0 ? 0 : Math.round((double) selectCount / selectTotal * 100);
subjectOption.put("selectPercent", round + "%");
subjectOption.put("selectCount", selectCount);
});
subjectOptions.sort((o1, o2) -> Integer.compare(o1.getInt("sortNum"), o2.getInt("sortNum")));
subject.put("selectTotal", selectTotal);
}
subject.addv("options", subjectOptions);
}
return subjects;
}
private List<NutMap> parseCategoryConditions(String conditions) {
if (ObjectUtil.isEmpty(conditions)) {
return new ArrayList<>();
}
return JSONUtil.parseArray(conditions).toList(NutMap.class).stream()
.filter(condition -> ObjectUtil.isNotEmpty(condition.getString("subjectId"))
&& ObjectUtil.isNotEmpty(condition.getString("optionId")))
.toList();
}
private List<String> parseSubjectIds(String subjectIds) {
if (ObjectUtil.isEmpty(subjectIds)) {
return new ArrayList<>();
}
String value = subjectIds.trim();
List<String> result = new ArrayList<>();
if (value.startsWith("[")) {
for (Object subjectId : JSONUtil.parseArray(value)) {
if (ObjectUtil.isNotEmpty(subjectId)) {
result.add(String.valueOf(subjectId));
}
}
return result.stream().distinct().toList();
}
for (String subjectId : value.split(",")) {
if (ObjectUtil.isNotEmpty(subjectId)) {
result.add(subjectId.trim());
}
}
return result.stream().distinct().toList();
}
private NutMap buildVariableSubject(List<String> subjectIds, Map<String, NutMap> subjectMap) {
String title = subjectIds.stream()
.map(subjectId -> subjectMap.get(subjectId).getString("title"))
.collect(Collectors.joining(" / "));
return NutMap.NEW()
.addv("id", String.join("|", subjectIds))
.addv("title", title);
}
private List<NutMap> buildVariableOptionCombinations(List<String> subjectIds, Map<String, NutMap> subjectMap, Map<String, List<QsvOption>> optionGroup) {
List<List<NutMap>> subjectOptionGroups = new ArrayList<>();
for (String subjectId : subjectIds) {
NutMap subject = subjectMap.get(subjectId);
List<QsvOption> options = optionGroup.get(subjectId);
if (ObjectUtil.isEmpty(options)) {
continue;
}
List<NutMap> optionMaps = options.stream()
.map(option -> NutMap.NEW()
.addv("subjectId", subjectId)
.addv("subjectTitle", subject.getString("title"))
.addv("optionId", option.getId())
.addv("optionText", option.getText()))
.toList();
subjectOptionGroups.add(optionMaps);
}
if (subjectOptionGroups.size() != subjectIds.size()) {
return new ArrayList<>();
}
List<NutMap> result = new ArrayList<>();
buildVariableOptionCombinations(subjectOptionGroups, 0, new ArrayList<>(), result);
return result;
}
private void buildVariableOptionCombinations(List<List<NutMap>> subjectOptionGroups, int index, List<NutMap> current, List<NutMap> result) {
if (index >= subjectOptionGroups.size()) {
String id = current.stream()
.map(option -> option.getString("subjectId") + ":" + option.getString("optionId"))
.collect(Collectors.joining("|"));
String text = current.stream()
.map(option -> option.getString("optionText"))
.collect(Collectors.joining(" / "));
List<NutMap> conditions = current.stream()
.map(option -> NutMap.NEW()
.addv("subjectId", option.getString("subjectId"))
.addv("optionId", option.getString("optionId")))
.toList();
result.add(NutMap.NEW()
.addv("id", id)
.addv("text", text)
.addv("conditions", conditions));
return;
}
for (NutMap option : subjectOptionGroups.get(index)) {
current.add(option);
buildVariableOptionCombinations(subjectOptionGroups, index + 1, current, result);
current.remove(current.size() - 1);
}
}
private List<QsvUserAnswerRecord> filterAnswerRecords(List<QsvUserAnswerRecord> answerRecords, List<NutMap> conditions) {
if (ObjectUtil.isEmpty(conditions)) {
return answerRecords;
}
return answerRecords.stream()
.filter(record -> isRecordMatched(record, conditions))
.toList();
}
private boolean isRecordMatched(QsvUserAnswerRecord record, List<NutMap> conditions) {
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return false;
}
for (NutMap condition : conditions) {
JSONObject subjectAnswer = extJson.get(condition.getString("subjectId"), JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer)) {
return false;
}
JSONArray optionIds = subjectAnswer.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds) || !optionIds.contains(condition.getString("optionId"))) {
return false;
}
}
return true;
}
private List<NutMap> querySubjects(String activityId) {
Sql subjectSql = Sqls.create("""
SELECT
id,
title,
type,
componentType,
sortNum
FROM
qsv_subject
@@ -309,7 +693,37 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
return excelExportEntities;
}
private List<NutMap> buildAnswerRecords(List<QsvUserAnswerRecord> answerRecords, Map<String, QsvSubject> subjectMap, List<QsvOption> options) {
/**
* 构建填空题填写详情。分析弹窗需要同时展示填写内容和答题人信息,
* 因此这里从已完成答题记录中提取当前题目的文本答案,并携带姓名、工号、所属工会和单位。
*
* @param answerRecords 当前活动已完成的答题记录
* @param subjectId 当前填空题ID,用于从 extJson 中取出该题填写内容
* @return 填空题填写详情列表,每条包含 loginName、userName、unionName、unitName、submitTime、text
*/
private List<NutMap> buildTextAnswerDetails(List<QsvUserAnswerRecord> answerRecords, String subjectId) {
List<NutMap> textAnswers = new ArrayList<>();
for (QsvUserAnswerRecord answerRecord : answerRecords) {
JSONObject extJson = answerRecord.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
continue;
}
JSONObject subjectAnswer = extJson.get(subjectId, JSONObject.class);
if (ObjectUtil.isEmpty(subjectAnswer) || ObjectUtil.isEmpty(subjectAnswer.getStr("text"))) {
continue;
}
textAnswers.add(NutMap.NEW()
.addv("loginName", answerRecord.getLoginName())
.addv("userName", answerRecord.getUserName())
.addv("unionName", answerRecord.getUnionName())
.addv("unitName", answerRecord.getUnitName())
.addv("submitTime", answerRecord.getSubmitTime())
.addv("text", subjectAnswer.getStr("text")));
}
return textAnswers;
}
private List<NutMap> buildAnswerRecords(List<QsvUserAnswerRecord> answerRecords, Map<String, QsvSubject> subjectMap, Map<String, QsvOption> optionMap) {
return answerRecords.stream().map(record -> {
NutMap map = NutMap.NEW()
.addv("id", record.getId())
@@ -318,20 +732,61 @@ public class QsvSurveyServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord> i
.addv("unitName", record.getUnitName())
.addv("unionName", record.getUnionName());
JSONObject extJson = record.getExtJson();
if (ObjectUtil.isEmpty(extJson)) {
return map;
}
extJson.forEach((k, v) -> {
JSONObject jsonVal = (JSONObject) v;
String type = subjectMap.get(k).getType();
if (type.equals("text")) {
map.addv(k, jsonVal.getStr("text"));
} else if (type.equals("radio") || type.equals("checkbox")) {
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
String selectOptionTexts = options.stream().filter(option -> optionIds.contains(option.getId()))
.map(QsvOption::getText).collect(Collectors.joining(";"));
map.addv(k, selectOptionTexts);
QsvSubject subject = subjectMap.get(k);
if (subject == null || !(v instanceof JSONObject jsonVal)) {
return;
}
map.addv(k, buildAnswerText(jsonVal, subject, optionMap));
});
return map;
}).collect(Collectors.toList());
}
/**
* 组装单题答案展示内容。填空题返回填写文本;选择题返回选项文本,若选项配置了补充填写,
* 则追加对应补充内容,保证后台查看答卷和导出能看到用户填写的说明。
*
* @param jsonVal 答题记录 extJson 中当前题目的答案对象,包含 optionIds、text、optionFillContents
* @param subject 当前题目,用于判断题目类型
* @param optionMap 当前活动所有选项,key 为选项ID,value 为选项实体
* @return 当前题目的答案展示文本
*/
private String buildAnswerText(JSONObject jsonVal, QsvSubject subject, Map<String, QsvOption> optionMap) {
String type = subject.getType();
if ("text".equals(type)) {
return jsonVal.getStr("text");
}
if (!"radio".equals(type) && !"checkbox".equals(type)) {
return "";
}
JSONArray optionIds = jsonVal.getJSONArray("optionIds");
if (ObjectUtil.isEmpty(optionIds)) {
return "";
}
JSONObject optionFillContents = jsonVal.getJSONObject("optionFillContents");
List<String> answerTexts = new ArrayList<>();
for (Object optionIdObj : optionIds) {
String optionId = String.valueOf(optionIdObj);
QsvOption option = optionMap.get(optionId);
if (option == null) {
continue;
}
String answerText = option.getText();
if (ObjectUtil.isNotEmpty(optionFillContents)) {
String fillContent = optionFillContents.getStr(optionId);
if (ObjectUtil.isNotEmpty(fillContent)) {
answerText = answerText + ":" + fillContent;
}
}
answerTexts.add(answerText);
}
return String.join(";", answerTexts);
}
}
@@ -26,6 +26,44 @@ public class QsvUserAnswerRecordServiceImpl extends BaseServiceImpl<QsvUserAnswe
super(dao);
}
/**
* 按题目配置校验多选题最大选择数,防止前端限制被绕过后写入超量选项。
*
* @param qsvAnswerParam 用户提交的答题参数
* @return 超限提示文案,空表示校验通过
*/
@Override
public String checkMaxMultiLimit(QsvAnswerParam qsvAnswerParam) {
if (qsvAnswerParam == null || qsvAnswerParam.getSubjects() == null || qsvAnswerParam.getSubjects().isEmpty()) {
return null;
}
List<String> subjectIds = qsvAnswerParam.getSubjects().stream()
.filter(Objects::nonNull)
.map(QsvAnswerParam.Subject::getId)
.filter(Objects::nonNull)
.toList();
if (subjectIds.isEmpty()) {
return null;
}
Map<String, QsvSubject> subjectMap = new HashMap<>();
dao().query(QsvSubject.class, Cnd.where("id", "in", subjectIds)).forEach(subject -> subjectMap.put(subject.getId(), subject));
for (QsvAnswerParam.Subject answerSubject : qsvAnswerParam.getSubjects()) {
if (answerSubject == null) {
continue;
}
QsvSubject configSubject = subjectMap.get(answerSubject.getId());
if (configSubject == null || !"checkbox".equals(configSubject.getType()) || configSubject.getMaxMulti() == null || configSubject.getMaxMulti() <= 0) {
continue;
}
List<String> selectedOptionIds = answerSubject.getUserSelectOptionIds();
int selectedCount = selectedOptionIds == null ? 0 : new LinkedHashSet<>(selectedOptionIds).size();
if (selectedCount > configSubject.getMaxMulti()) {
return "题目“" + configSubject.getTitle() + "”最多选择" + configSubject.getMaxMulti() + "项";
}
}
return null;
}
@Override
public QsvUserAnswerRecord insertRecord(String activityId, List<String> subjectIds) {
QsvActivity activity = dao().fetch(QsvActivity.class, activityId);
@@ -121,11 +159,12 @@ public class QsvUserAnswerRecordServiceImpl extends BaseServiceImpl<QsvUserAnswe
String mode = activity.getMode();
String scoreMode = activity.getScoreMode();
boolean highestScoreMode = "HIGH".equals(scoreMode) || "HIGHEST".equals(scoreMode);
if (mode.equals("SCHEDULED")) {
processScoreRecords(activityId, SecurityUtil.getUserId(), true, scoreMode.equals("HIGH"));
processScoreRecords(activityId, SecurityUtil.getUserId(), true, highestScoreMode);
} else if (mode.equals("REGULAR")) {
processScoreRecords(activityId, SecurityUtil.getUserId(), false, scoreMode.equals("HIGH"));
processScoreRecords(activityId, SecurityUtil.getUserId(), false, highestScoreMode);
}
}
@@ -1,6 +1,7 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
@@ -169,58 +170,184 @@ public class TourGroupController {
return Result.success(sql.getList(NutMap.class));
}
/**
* 查询当前事项线路下已经写入疗休养台账的成团信息,供线路成团查看页展示团标签。
*/
@At
@SaCheckPermission("tour.group")
public Result signupPageData(PageForm pageForm, String matterId, String keyword, String unionId) {
public Result groupOptions(String matterId) {
if (StrUtil.isBlank(matterId)) {
return Result.error("参数错误");
}
Cnd cnd = buildGroupOptionCnd(matterId);
return Result.success(tourLedgerService.queryGroupOptions(cnd));
}
/**
* 自动将当前事项线路下未成团的报名人员按最大成团人数分配成团。
*/
@At
@SaCheckPermission("tour.group")
public Result assignGroup(String matterId) {
if (StrUtil.isBlank(matterId)) {
return Result.error("参数错误");
}
return tourLedgerService.assignGroup(matterId);
}
/**
* 将勾选的教职工报名台账调整到指定成团中,家属随教职工一起调整。
*/
@At
@SaCheckPermission("tour.group")
public Result adjustGroup(String matterId, String groupCode, String ledgerIds) {
if (StrUtil.isBlank(matterId) || StrUtil.isBlank(groupCode) || StrUtil.isBlank(ledgerIds)) {
return Result.error("参数错误");
}
return tourLedgerService.adjustGroup(matterId, groupCode, ledgerIds);
}
@At
@SaCheckPermission(value = {"tour.group", "tour.signup"}, mode = SaMode.OR)
public Result signupPageData(PageForm pageForm, String matterId, String keyword, String unionId, String groupCode) {
if (StrUtil.isBlank(matterId)) {
return Result.error("参数错误");
}
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortDesc("signupTime");
pageForm.defaultSortAsc("groupCode");
}
Cnd cnd = buildSignupQueryCnd(matterId, keyword, unionId);
Cnd cnd = buildSignupQueryCnd(matterId, keyword, unionId, groupCode);
boolean maskOtherUnionPeople = shouldMaskOtherUnionSignupPeople();
String currentUnionId = StrUtil.blankToDefault(SecurityUtil.getUnionId(), "");
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
FROM (
SELECT t.id AS rowId
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
UNION ALL
SELECT f.id AS rowId
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
INNER JOIN tour_ledger_family f ON f.ledgerId = t.id AND f.delFlag = 0
$condition
AND (@maskOtherUnionPeople = 0 OR IFNULL(t.unionId, '') = @currentUnionId)
) p
""");
countSql.setCondition(cnd);
countSql.setParam("currentUnionId", currentUnionId);
countSql.setParam("maskOtherUnionPeople", maskOtherUnionPeople ? 1 : 0);
countSql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT
t.id,
t.jobNo,
t.userName,
'' AS mobile,
t.idCard,
t.unionId,
t.unionName,
t.signupTime
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
ORDER BY $orderColumn $orderBy, t.signupTime DESC, t.createdAt DESC
SELECT *
FROM (
SELECT
t.id,
t.id AS ledgerId,
t.jobNo,
t.userName,
t.gender,
'' AS mobile,
CASE WHEN @maskOtherUnionPeople = 1 AND IFNULL(t.unionId, '') <> @currentUnionId THEN '' ELSE t.idCard END AS idCard,
t.unionId,
t.unionName,
t.signupTime,
t.groupCode,
t.groupName,
CASE WHEN @maskOtherUnionPeople = 1 AND IFNULL(t.unionId, '') <> @currentUnionId THEN 0 ELSE IFNULL(fc.familyCount, 0) END AS familyCount,
CASE WHEN @maskOtherUnionPeople = 1 AND IFNULL(t.unionId, '') <> @currentUnionId THEN 1 ELSE 1 + IFNULL(fc.familyCount, 0) END AS totalPeople,
1 AS staffRow,
1 AS selectable,
'教职工' AS rowTypeName,
'' AS relationship,
t.userName AS relatedStaffName,
0 AS rowSort,
t.createdAt AS ledgerCreatedAt,
t.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) fc ON fc.ledgerId = t.id
$condition
UNION ALL
SELECT
f.id,
t.id AS ledgerId,
'' AS jobNo,
f.familyName AS userName,
f.gender,
'' AS mobile,
f.idCard,
t.unionId,
'' AS unionName,
t.signupTime,
t.groupCode,
t.groupName,
0 AS familyCount,
'' AS totalPeople,
0 AS staffRow,
0 AS selectable,
'家属' AS rowTypeName,
f.relationship,
t.userName AS relatedStaffName,
1 AS rowSort,
t.createdAt AS ledgerCreatedAt,
f.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
INNER JOIN tour_ledger_family f ON f.ledgerId = t.id AND f.delFlag = 0
$condition
AND (@maskOtherUnionPeople = 0 OR IFNULL(t.unionId, '') = @currentUnionId)
) p
ORDER BY $orderColumn $orderBy, p.signupTime ASC, p.ledgerCreatedAt ASC, p.rowSort ASC, p.createdAt ASC
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", getSignupOrderColumn(pageForm.getPageOrderName()));
listSql.setParam("currentUnionId", currentUnionId);
listSql.setParam("maskOtherUnionPeople", maskOtherUnionPeople ? 1 : 0);
listSql.setVar("orderColumn", getSignupUnionOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderBy", "ascending".equals(pageForm.getPageOrderBy()) ? "asc" : "desc");
listSql.setPager(tourLedgerService.dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
tourLedgerService.dao().execute(listSql);
List<NutMap> list = listSql.getList(NutMap.class);
fillSignupMobile(list);
fillSignupFamilies(list);
// 人员列表已在 SQL 层按教职工和家属平铺后分页,这里只补齐教职工手机号,避免分页后再次追加家属行。
fillSignupMobile(list, maskOtherUnionPeople);
Pagination<NutMap> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, list);
return Result.success(pagination);
}
/**
* 构造成团标签查询条件:只取当前事项线路已分配成团编码的台账,并保持与列表一致的数据权限。
*/
private Cnd buildGroupOptionCnd(String matterId) {
Cnd cnd = Cnd.NEW();
cnd.and("t.delFlag", "=", false);
cnd.and("t.matterId", "=", matterId);
cnd.and("t.groupCode", "IS NOT", null);
cnd.and("t.groupCode", "<>", "");
cnd.and("m.delFlag", "=", false);
cnd.and("m.enabled", "=", true);
cnd.and("m.lineId", "IS NOT", null);
cnd.and("m.lineId", "<>", "");
cnd.and("l.delFlag", "=", false);
cnd.and("l.enabled", "=", true);
applyMatterDataScope(cnd);
return cnd;
}
@At
@Ok("void")
@SaCheckPermission("tour.group")
@@ -238,7 +365,7 @@ public class TourGroupController {
CommonDownloadUtil.download(lineName + "参加人员名单.xls", workbook, response);
}
private Cnd buildSignupQueryCnd(String matterId, String keyword, String unionId) {
private Cnd buildSignupQueryCnd(String matterId, String keyword, String unionId, String groupCode) {
Cnd cnd = Cnd.NEW();
cnd.and("t.delFlag", "=", false);
cnd.and("t.matterId", "=", matterId);
@@ -255,7 +382,8 @@ public class TourGroupController {
cnd.and(keywordGroup);
}
cnd.andEX("t.unionId", "=", unionId);
applyMatterDataScope(cnd);
// 团标签联动查询时只筛选当前团;未选择团标签时默认查询全部报名人员。
cnd.andEX("t.groupCode", "=", groupCode);
return cnd;
}
@@ -297,7 +425,7 @@ public class TourGroupController {
}
private List<NutMap> queryExportParticipants(String matterId) {
Cnd cnd = buildSignupQueryCnd(matterId, null, null);
Cnd cnd = buildSignupQueryCnd(matterId, null, null, null);
Sql sql = Sqls.create("""
SELECT
t.id AS ledgerId,
@@ -485,12 +613,16 @@ public class TourGroupController {
}
}
private void fillSignupMobile(List<NutMap> list) {
private void fillSignupMobile(List<NutMap> list, boolean maskOtherUnionPeople) {
if (list == null || list.isEmpty()) {
return;
}
List<String> jobNos = new ArrayList<>();
String currentUnionId = StrUtil.blankToDefault(SecurityUtil.getUnionId(), "");
for (NutMap item : list) {
if (maskOtherUnionPeople && !currentUnionId.equals(item.getString("unionId", ""))) {
continue;
}
String jobNo = item.getString("jobNo", "");
if (StrUtil.isNotBlank(jobNo) && !jobNos.contains(jobNo)) {
jobNos.add(jobNo);
@@ -510,14 +642,18 @@ public class TourGroupController {
mobileMap.put(user.getString("loginname", ""), user.getString("mobile", ""));
}
for (NutMap item : list) {
if (maskOtherUnionPeople && !currentUnionId.equals(item.getString("unionId", ""))) {
item.put("mobile", "");
continue;
}
item.put("mobile", mobileMap.getOrDefault(item.getString("jobNo", ""), ""));
}
}
/**
* 查看成团报名人员时按台账ID带出家属子列表,避免前端逐行请求家属信息。
* 查看成团报名人员时将教职工和家属平铺在同一个列表,便于前端统一展示。
*/
private void fillSignupFamilies(List<NutMap> list) {
private void flattenSignupFamilies(List<NutMap> list) {
if (list == null || list.isEmpty()) {
return;
}
@@ -535,8 +671,9 @@ public class TourGroupController {
Sql sql = Sqls.create("""
SELECT
ledgerId,
staffJobNo AS jobNo,
'' AS jobNo,
familyName AS userName,
gender,
'' AS mobile,
idCard,
relationship
@@ -551,11 +688,35 @@ public class TourGroupController {
Map<String, List<NutMap>> familyMap = new HashMap<>();
for (NutMap family : sql.getList(NutMap.class)) {
family.put("staffRow", false);
family.put("selectable", false);
family.put("rowTypeName", "家属");
family.put("unionName", "");
family.put("groupCode", "");
family.put("groupName", "");
family.put("totalPeople", "");
familyMap.computeIfAbsent(family.getString("ledgerId", ""), key -> new ArrayList<>()).add(family);
}
List<NutMap> flatList = new ArrayList<>();
for (NutMap item : list) {
item.put("families", familyMap.getOrDefault(item.getString("id", ""), List.of()));
// 教职工是后续人员调整的最小可选单位,家属随教职工一起分配。
item.put("staffRow", true);
item.put("selectable", true);
item.put("rowTypeName", "教职工");
item.put("relationship", "");
item.put("relatedStaffName", item.getString("userName", ""));
flatList.add(item);
List<NutMap> families = familyMap.getOrDefault(item.getString("id", ""), List.of());
for (NutMap family : families) {
// 家属行通过关联教职工名称标明所属教职工,便于单层列表中识别家庭关系。
family.put("relatedStaffName", item.getString("userName", ""));
family.put("groupCode", item.getString("groupCode", ""));
family.put("groupName", item.getString("groupName", ""));
}
flatList.addAll(families);
}
list.clear();
list.addAll(flatList);
}
private Cnd buildQueryCnd(Integer year, String lineName, String lineType, String unionId) {
@@ -606,6 +767,15 @@ public class TourGroupController {
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/**
* Branch union chairmen can browse all staff rows, but sensitive fields and family rows outside their union are hidden.
*/
private boolean shouldMaskOtherUnionSignupPeople() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
&& !AuthUtil.hasRole(RoleConstant.SYSADMIN.name())
&& !AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchUnionScope() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
@@ -655,19 +825,28 @@ public class TourGroupController {
return "l.lineName";
}
private String getSignupOrderColumn(String orderName) {
private String getSignupUnionOrderColumn(String orderName) {
if ("jobNo".equals(orderName)) {
return "t.jobNo";
return "p.jobNo";
}
if ("userName".equals(orderName)) {
return "t.userName";
return "p.userName";
}
if ("gender".equals(orderName)) {
return "p.gender";
}
if ("idCard".equals(orderName)) {
return "t.idCard";
return "p.idCard";
}
if ("unionName".equals(orderName)) {
return "t.unionName";
return "p.unionName";
}
return "t.signupTime";
if ("groupCode".equals(orderName) || "groupName".equals(orderName)) {
return "p.groupCode";
}
if ("totalPeople".equals(orderName)) {
return "p.totalPeople";
}
return "p.signupTime";
}
}
@@ -26,6 +26,7 @@ import com.budwk.app.zhgh.dayofficework.tour.models.TourTravelAgency;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerDirectRelativeService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerFamilyService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.BorderStyle;
@@ -90,6 +91,9 @@ public class TourLedgerController {
@Inject
private TourLedgerDirectRelativeService tourLedgerDirectRelativeService;
@Inject
private TourParticipantRosterService tourParticipantRosterService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/Tour/ledger/index.html")
@SaCheckPermission("tour.ledger")
@@ -216,6 +220,8 @@ public class TourLedgerController {
if (ledger == null) {
return Result.error("台账记录不存在");
}
// Reset the participant roster before deleting the ledger record from the admin ledger page.
tourParticipantRosterService.markNotJoinedByLedger(ledger);
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", id));
tourLedgerService.delete(id);
return Result.success();
@@ -313,6 +319,59 @@ public class TourLedgerController {
CommonDownloadUtil.download("参加人员导入模板.xlsx", workbook, response);
}
/**
* 按台账页面当前查询条件导出全部匹配记录。
*
* @param startYear 开始年度,传四位年度数字
* @param endYear 结束年度,传四位年度数字
* @param keyword 姓名或工号关键字,可为空
* @param unionId 所属工会ID,可为空
* @param lineId 线路ID;历史线路使用legacy:线路名称,可为空
* @param travelPeriod 出行时段,格式为开始时间 至 结束时间,可为空
* @param lineType 线路类型字典名称,可为空
* @param directFamilyOnly 是否仅导出参加直系亲属单位线路人员
* @param overCostOnly 是否仅导出申请超出费用由单位承担人员
* @param response HTTP响应,返回XSSF格式的Excel文件流
*/
@At
@Ok("void")
@SaCheckPermission("tour.ledger")
public void exportData(Integer startYear, Integer endYear, String keyword, String unionId, String lineId,
String travelPeriod, String lineType, Boolean directFamilyOnly, Boolean overCostOnly,
HttpServletResponse response) {
Cnd cnd = buildQueryCnd(startYear, endYear, keyword, unionId, lineId, travelPeriod, lineType,
directFamilyOnly, overCostOnly);
List<NutMap> rows = tourLedgerService.queryExportRows(cnd);
for (int i = 0; i < rows.size(); i++) {
NutMap row = rows.get(i);
row.put("index", i + 1);
row.put("groupInfo", formatGroupInfo(row));
row.put("joinedName", row.getBoolean("joined", false) ? "是" : "否");
row.put("overCostReimbursedName", row.getBoolean("overCostReimbursed", false) ? "是" : "否");
}
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("序号", "index", 8));
entities.add(new ExcelExportEntity("年度", "year", 12));
entities.add(new ExcelExportEntity("工号", "jobNo", 18));
entities.add(new ExcelExportEntity("姓名", "userName", 16));
entities.add(new ExcelExportEntity("所在单位", "unitName", 28));
entities.add(new ExcelExportEntity("报名线路", "lineName", 30));
entities.add(new ExcelExportEntity("出行时段", "travelPeriod", 30));
entities.add(new ExcelExportEntity("线路类型", "lineType", 16));
entities.add(new ExcelExportEntity("成团信息", "groupInfo", 20));
entities.add(new ExcelExportEntity("领办工会", "undertakeUnionName", 24));
entities.add(new ExcelExportEntity("报名时间", "signupTime", 24));
entities.add(new ExcelExportEntity("携带家属人数", "familyCount", 16));
entities.add(new ExcelExportEntity("是否参加", "joinedName", 14));
entities.add(new ExcelExportEntity("是否申请超出费用由单位承担", "overCostReimbursedName", 28));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, rows);
CommonDownloadUtil.download("疗休养台账.xlsx", workbook, response);
}
@At
@SaCheckPermission("tour.ledger")
@Ok("void")
@@ -414,6 +473,18 @@ public class TourLedgerController {
return unionRows;
}
/**
* 将成团编码和成团名称合并为列表一致的展示内容,任一字段为空时保留已有信息。
*/
private String formatGroupInfo(NutMap row) {
String groupCode = row.getString("groupCode", "");
String groupName = row.getString("groupName", "");
if (StrUtil.isBlank(groupCode)) {
return groupName;
}
return StrUtil.isBlank(groupName) ? groupCode : groupCode + " " + groupName;
}
private Workbook buildUnionSignupWorkbook(String unionName, List<NutMap> rows) {
HSSFWorkbook workbook = new HSSFWorkbook();
Sheet sheet = workbook.createSheet("报名汇总表");
@@ -1,5 +1,9 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
@@ -7,6 +11,7 @@ import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.sys.views.View_user;
@@ -26,7 +31,10 @@ import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.apache.poi.ss.usermodel.Workbook;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -36,8 +44,6 @@ import java.util.stream.Collectors;
@At("/platform/tour/route")
public class TourLineController {
private static final String SORT_CODE_PATTERN = "^\\d+$";
@Inject
private TourLineService tourLineService;
@@ -52,7 +58,7 @@ public class TourLineController {
@At
@SaCheckPermission("tour.route")
public Result pageData(PageForm pageForm, Integer year, String lineName, String lotId) {
public Result pageData(PageForm pageForm, Integer year, String lineName, String lotId, String lineType) {
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortAsc("lineCode");
}
@@ -62,6 +68,8 @@ public class TourLineController {
cnd.andEX("l.`year`", "=", year);
cnd.and(Cnd.likeEX("l.lineName", lineName));
cnd.andEX("l.lotId", "=", lotId);
// 线路类型查询使用线路表保存的字典名称,和新增/编辑线路时的取值保持一致。
cnd.andEX("l.lineType", "=", lineType);
boolean sysAdmin = AuthUtil.hasRole(RoleConstant.SYSADMIN.name());
boolean branchUnionScope = !sysAdmin && AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name());
applyDataScope(cnd, sysAdmin, branchUnionScope);
@@ -107,6 +115,78 @@ public class TourLineController {
return Result.success(pagination);
}
/**
* 导出线路关联事项数据,导出范围复用线路管理列表的筛选条件和数据权限。
*/
@At
@Ok("void")
@SaCheckPermission("tour.route")
public void exportData(Integer year, String lineName, String lotId, String lineType, HttpServletResponse response) {
Cnd cnd = Cnd.NEW();
cnd.and("l.delFlag", "=", false);
cnd.andEX("l.`year`", "=", year);
cnd.and(Cnd.likeEX("l.lineName", lineName));
cnd.andEX("l.lotId", "=", lotId);
// 导出范围复用列表筛选条件,线路类型也要同步过滤。
cnd.andEX("l.lineType", "=", lineType);
boolean sysAdmin = AuthUtil.hasRole(RoleConstant.SYSADMIN.name());
boolean branchUnionScope = !sysAdmin && AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name());
applyDataScope(cnd, sysAdmin, branchUnionScope);
String creatorJoin = branchUnionScope ? """
LEFT JOIN sys_unit lineUnit ON lineUnit.id = l.unitId
LEFT JOIN sys_user creatorUser ON creatorUser.id = COALESCE(NULLIF(l.creatorUserId, ''), l.createdBy)
LEFT JOIN sys_unit creatorUnit ON creatorUnit.id = creatorUser.unitId
""" : "";
// 线路导出需要带出事项信息,因此以线路为主表左关联事项,未关联事项的线路仍保留导出。
Sql sql = Sqls.create("""
SELECT
l.`year`,
l.lineName,
lot.lotName,
l.lineType,
m.matterName,
CASE
WHEN IFNULL(m.signupStartTime, '') <> '' AND IFNULL(m.signupEndTime, '') <> ''
THEN CONCAT(m.signupStartTime, ' 至 ', m.signupEndTime)
ELSE ''
END AS signupTime,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
ELSE ''
END AS travelTime,
m.contactName,
m.contactPhone
FROM tour_line l
LEFT JOIN tour_setting_lot lot ON lot.id = l.lotId
LEFT JOIN tour_matter m ON l.id = m.lineId AND m.delFlag = 0
$creatorJoin
$condition
ORDER BY CAST(l.lineCode AS UNSIGNED) ASC, l.lineCode ASC, m.matterName ASC, l.lineName ASC
""");
sql.setVar("creatorJoin", creatorJoin);
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.maps());
tourLineService.dao().execute(sql);
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("创建年度", "year", 14));
entities.add(new ExcelExportEntity("线路名称", "lineName", 30));
entities.add(new ExcelExportEntity("时间标段", "lotName", 18));
entities.add(new ExcelExportEntity("路线类型", "lineType", 18));
entities.add(new ExcelExportEntity("事项名称", "matterName", 30));
entities.add(new ExcelExportEntity("报名时间", "signupTime", 34));
entities.add(new ExcelExportEntity("出行时间", "travelTime", 24));
entities.add(new ExcelExportEntity("联系人", "contactName", 16));
entities.add(new ExcelExportEntity("联系方式", "contactPhone", 18));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, sql.getList(NutMap.class));
CommonDownloadUtil.download("线路事项数据.xlsx", workbook, response);
}
@At
@SaCheckPermission("tour.route")
public Result detail(String id) {
@@ -253,11 +333,11 @@ public class TourLineController {
if (StrUtil.isBlank(line.getLineName())) {
return Result.error("线路名称不能为空");
}
if (StrUtil.isBlank(line.getLineCode())) {
if (line.getLineCode() == null) {
return Result.error("排序编号不能为空");
}
if (!line.getLineCode().matches(SORT_CODE_PATTERN)) {
return Result.error("排序编号必须为整数");
if (line.getLineCode() < 0) {
return Result.error("排序编号不能小于0");
}
if (StrUtil.isBlank(line.getLineType())) {
return Result.error("线路类型不能为空");
@@ -1,12 +1,17 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.sys.services.SysDictService;
@@ -27,7 +32,10 @@ import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.apache.poi.ss.usermodel.Workbook;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -53,18 +61,13 @@ public class TourMatterController {
@At
@SaCheckPermission("tour.matter")
public Result pageData(PageForm pageForm, Integer year, String matterName, String unionId, String organizationType) {
Cnd cnd = Cnd.NEW();
cnd.and("m.delFlag", "=", false);
cnd.andEX("m.`year`", "=", year);
cnd.and(Cnd.likeEX("m.matterName", matterName));
cnd.andEX("m.unionId", "=", unionId);
cnd.andEX("m.organizationType", "=", organizationType);
applyMatterDataScope(cnd);
public Result pageData(PageForm pageForm, Integer year, String matterName, String unionId, String organizationType, String lineType) {
Cnd cnd = buildMatterPageCnd(year, matterName, unionId, organizationType, lineType);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM tour_matter m
LEFT JOIN tour_line l ON l.id = m.lineId
$condition
""");
countSql.setCondition(cnd);
@@ -91,7 +94,7 @@ public class TourMatterController {
LEFT JOIN sys_dict d ON d.`code` = m.organizationType
AND d.parentId = (SELECT id FROM sys_dict WHERE `code` = 'organizationType' LIMIT 1)
$condition
ORDER BY $orderColumn $orderBy, m.`year` DESC, m.createdAt DESC
ORDER BY $orderColumn $orderBy, CAST(l.lineCode AS UNSIGNED) ASC, m.`year` DESC, m.createdAt DESC
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", getOrderColumn(pageForm.getPageOrderName()));
@@ -104,6 +107,109 @@ public class TourMatterController {
return Result.success(pagination);
}
/**
* 导出疗休养事项列表,导出范围复用页面查询条件和事项数据权限。
*/
@At
@Ok("void")
@SaCheckPermission("tour.matter")
public void exportData(Integer year, String matterName, String unionId, String organizationType, String lineType,
HttpServletResponse response) {
Cnd cnd = buildMatterPageCnd(year, matterName, unionId, organizationType, lineType);
Sql sql = Sqls.create("""
SELECT
m.`year`,
m.matterName,
l.lineName,
CASE
WHEN IFNULL(m.signupStartTime, '') <> '' AND IFNULL(m.signupEndTime, '') <> ''
THEN CONCAT(m.signupStartTime, ' 至 ', m.signupEndTime)
ELSE ''
END AS signupTime,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
ELSE ''
END AS travelTime,
d.name AS organizationTypeName,
m.contactName,
m.contactPhone,
CASE WHEN m.enabled = 1 THEN '启用' ELSE '禁用' END AS enabledName
FROM tour_matter m
LEFT JOIN tour_line l ON l.id = m.lineId
LEFT JOIN sys_union u ON u.id = m.unionId
LEFT JOIN sys_dict d ON d.`code` = m.organizationType
AND d.parentId = (SELECT id FROM sys_dict WHERE `code` = 'organizationType' LIMIT 1)
$condition
ORDER BY CAST(l.lineCode AS UNSIGNED) ASC, m.`year` DESC, m.createdAt DESC
""");
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.maps());
tourMatterService.dao().execute(sql);
List<NutMap> rows = sql.getList(NutMap.class);
for (int i = 0; i < rows.size(); i++) {
rows.get(i).put("index", i + 1);
}
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("序号", "index", 8));
entities.add(new ExcelExportEntity("年度", "year", 12));
entities.add(new ExcelExportEntity("事项名称", "matterName", 30));
entities.add(new ExcelExportEntity("线路名称", "lineName", 30));
entities.add(new ExcelExportEntity("报名时间", "signupTime", 34));
entities.add(new ExcelExportEntity("出行时间", "travelTime", 24));
entities.add(new ExcelExportEntity("组织形式", "organizationTypeName", 16));
entities.add(new ExcelExportEntity("联系人", "contactName", 16));
entities.add(new ExcelExportEntity("联系方式", "contactPhone", 18));
entities.add(new ExcelExportEntity("事项状态", "enabledName", 12));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, rows);
CommonDownloadUtil.download("疗休养事项数据.xlsx", workbook, response);
}
/**
* 按当前事项列表查询条件统一赋值报名时间,更新前端筛选范围内的全部事项。
*/
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("tour.matter")
@SLog(type = "tour", tag = "疗休养事项", msg = "统一赋值报名时间")
public Result assignSignupTime(Integer year, String matterName, String unionId, String organizationType, String lineType,
String signupStartTime, String signupEndTime) {
if (StrUtil.isBlank(signupStartTime) || StrUtil.isBlank(signupEndTime)) {
return Result.error("报名开始时间和报名结束时间不能为空");
}
if (signupStartTime.compareTo(signupEndTime) >= 0) {
return Result.error("报名开始时间必须小于报名结束时间");
}
// 批量赋值必须严格复用事项列表查询条件,确保“查询哪些数据就赋值哪些事项”。
Cnd cnd = buildMatterPageCnd(year, matterName, unionId, organizationType, lineType);
Sql idSql = Sqls.create("""
SELECT m.id
FROM tour_matter m
LEFT JOIN tour_line l ON l.id = m.lineId
$condition
""");
idSql.setCondition(cnd);
idSql.setCallback(Sqls.callback.maps());
tourMatterService.dao().execute(idSql);
List<String> ids = idSql.getList(NutMap.class).stream()
.map(item -> item.getString("id"))
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
if (ids.isEmpty()) {
return Result.success("当前查询条件下暂无可赋值事项");
}
// 只更新报名时间字段,不影响事项的线路、联系人、成团人数等其他配置。
int updateCount = tourMatterService.assignSignupTime(ids, signupStartTime, signupEndTime);
return Result.success("赋值成功,共更新" + updateCount + "条事项");
}
@At
@SaCheckPermission("tour.matter")
public Result detail(String id) {
@@ -142,17 +248,20 @@ public class TourMatterController {
cnd.and("l.enabled", "=", true);
cnd.and("l.openFlag", "=", true);
// 事项选择线路下拉需要展示线路类型和旅行社,因此这里补充旅行社名称返回给前端拼接显示。
Sql sql = Sqls.create("""
SELECT
l.*,
a.agencyName AS travelAgencyName,
lot.lotName AS lotName,
lot.lotValue AS lotValue,
lot.lotValue AS lotDays,
lot.activityCost AS activityCost
FROM tour_line l
LEFT JOIN tour_travel_agency a ON a.id = l.travelAgencyId
LEFT JOIN tour_setting_lot lot ON lot.id = l.lotId
$condition
ORDER BY l.lineCode ASC, l.lineName ASC
ORDER BY CAST(l.lineCode AS UNSIGNED) ASC, l.lineName ASC
""");
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.maps());
@@ -199,6 +308,17 @@ public class TourMatterController {
return Result.success(list.stream().filter(item -> !item.isDisabled()).collect(Collectors.toList()));
}
@At
@SaCheckPermission("tour.matter")
public Result lineTypeOptions() {
// 事项列表线路类型查询复用线路管理的 lineType 字典,保证筛选值与线路保存值一致。
List<Sys_dict> list = sysDictService.getSubListByCode("lineType");
if (list == null) {
return Result.success(Collections.emptyList());
}
return Result.success(list.stream().filter(item -> !item.isDisabled()).collect(Collectors.toList()));
}
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("tour.matter")
@@ -328,6 +448,7 @@ public class TourMatterController {
oldMatter.setContactPhone(matter.getContactPhone());
oldMatter.setMinGroupPeople(matter.getMinGroupPeople());
oldMatter.setMaxGroupPeople(matter.getMaxGroupPeople());
oldMatter.setMaxSignupPeople(matter.getMaxSignupPeople());
oldMatter.setEstimatedCost(matter.getEstimatedCost());
tourMatterService.update(oldMatter);
return Result.success();
@@ -348,16 +469,14 @@ public class TourMatterController {
if (setting == null) {
return;
}
if (setting.getMinGroupPeople() != null) {
// 配置只作为弹窗缺省值,不能覆盖用户在选择线路页面已经提交的成团人数和报名时间。
if (matter.getMinGroupPeople() == null && setting.getMinGroupPeople() != null) {
matter.setMinGroupPeople(setting.getMinGroupPeople());
}
if (setting.getMaxGroupPeople() != null) {
matter.setMaxGroupPeople(setting.getMaxGroupPeople());
}
if (StrUtil.isNotBlank(setting.getSignupStartTime())) {
if (StrUtil.isBlank(matter.getSignupStartTime()) && StrUtil.isNotBlank(setting.getSignupStartTime())) {
matter.setSignupStartTime(setting.getSignupStartTime());
}
if (StrUtil.isNotBlank(setting.getSignupEndTime())) {
if (StrUtil.isBlank(matter.getSignupEndTime()) && StrUtil.isNotBlank(setting.getSignupEndTime())) {
matter.setSignupEndTime(setting.getSignupEndTime());
}
}
@@ -429,11 +548,9 @@ public class TourMatterController {
if (matter.getMinGroupPeople() == null || matter.getMinGroupPeople() <= 0) {
return Result.error("最少成团人数必须大于0");
}
if (matter.getMaxGroupPeople() == null || matter.getMaxGroupPeople() <= 0) {
return Result.error("最多成团人数必须大于0");
}
if (matter.getMinGroupPeople() > matter.getMaxGroupPeople()) {
return Result.error("最少成团人数不能大于最多成团人数");
// 最多报名人数只控制报名提交容量;为空或0表示不限制,不能影响成团分配人数。
if (matter.getMaxSignupPeople() != null && matter.getMaxSignupPeople() < 0) {
return Result.error("最多报名人数不能小于0");
}
if (matter.getEstimatedCost() == null || matter.getEstimatedCost().signum() < 0) {
return Result.error("预计费用不能小于0");
@@ -462,6 +579,22 @@ public class TourMatterController {
.and(TourLedger::getMatterId, "=", matterId));
}
/**
* 构建事项列表查询条件,批量赋值报名时间时复用该条件保证更新范围与列表查询一致。
*/
private Cnd buildMatterPageCnd(Integer year, String matterName, String unionId, String organizationType, String lineType) {
Cnd cnd = Cnd.NEW();
cnd.and("m.delFlag", "=", false);
cnd.andEX("m.`year`", "=", year);
cnd.and(Cnd.likeEX("m.matterName", matterName));
cnd.andEX("m.unionId", "=", unionId);
cnd.andEX("m.organizationType", "=", organizationType);
// 事项列表和批量赋值报名时间共用该条件,按线路类型筛选时必须关联线路表字段。
cnd.andEX("l.lineType", "=", lineType);
applyMatterDataScope(cnd);
return cnd;
}
/**
* 事项数据范围:SYSADMIN 查看全部;分工会主席查看本工会事项;其它角色仅查看自己创建的事项。
*/
@@ -512,6 +645,10 @@ public class TourMatterController {
if ("lineName".equals(orderName)) {
return "l.lineName";
}
if ("lineCode".equals(orderName)) {
// 线路序号按数字排序,避免 10 排在 2 前面。
return "CAST(l.lineCode AS UNSIGNED)";
}
if ("settingName".equals(orderName)) {
return "s.configName";
}
@@ -521,6 +658,6 @@ public class TourMatterController {
if ("enabled".equals(orderName)) {
return "m.enabled";
}
return "m.`year`";
return "CAST(l.lineCode AS UNSIGNED)";
}
}
@@ -31,6 +31,7 @@ import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerDirectRelativeSer
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerFamilyService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourMatterService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
@@ -83,6 +84,9 @@ public class TourMySignupController {
@Inject
private TourMatterService tourMatterService;
@Inject
private TourParticipantRosterService tourParticipantRosterService;
@Inject
private SysDictService sysDictService;
@@ -145,6 +149,8 @@ public class TourMySignupController {
IFNULL(GROUP_CONCAT(DISTINCT task.displayName), IF(ins.id IS NULL, '', '结束')) AS curTaskName,
IFNULL(f.familyCount, 0) AS familyCount,
COALESCE(m.travelEndTime, tp.travelEndTime) AS travelEndTime,
COALESCE(NULLIF(s.signupStartTime, ''), m.signupStartTime) AS signupStartTime,
COALESCE(NULLIF(s.signupEndTime, ''), m.signupEndTime) AS signupEndTime,
CASE
WHEN IFNULL(NULLIF(m.undertakeStatus, ''), 'unclaimed') = 'claimed' THEN COALESCE(u.name, '校工会')
ELSE ''
@@ -166,6 +172,7 @@ public class TourMySignupController {
GROUP BY ledgerId
) f ON f.ledgerId = t.id
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_setting s ON s.id = m.settingId AND s.delFlag = 0
LEFT JOIN sys_union u ON u.id = m.unionId
LEFT JOIN (
SELECT
@@ -290,6 +297,9 @@ public class TourMySignupController {
m.unionId AS matterUnionId,
m.travelStartTime,
m.travelEndTime,
COALESCE(NULLIF(s.signupStartTime, ''), m.signupStartTime) AS signupStartTime,
COALESCE(NULLIF(s.signupEndTime, ''), m.signupEndTime) AS signupEndTime,
m.maxSignupPeople,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -323,6 +333,10 @@ public class TourMySignupController {
if (isTravelEnded(matter.getString("travelEndTime"))) {
return Result.error("线路出行已结束,不能修改");
}
Result signupEndResult = checkSignupEndTime(ledger);
if (signupEndResult != null) {
return signupEndResult;
}
Cnd familyCnd = Cnd.where(TourLedgerFamily::getDelFlag, "=", false)
.and(TourLedgerFamily::getLedgerId, "=", ledger.getId());
familyCnd.asc(TourLedgerFamily::getCreatedAt);
@@ -440,14 +454,10 @@ public class TourMySignupController {
return familyResult;
}
TourMatter matter = tourMatterService.fetch(ledger.getMatterId());
Result ruleResult = checkSignupRule(ledger, matter, oldLedger);
Result ruleResult = checkSignupRule(ledger, matter, oldLedger, calculateCurrentSignupPeople(familyList));
if (ruleResult != null) {
return ruleResult;
}
Result maxPeopleResult = checkMaxGroupPeople(matter, oldLedger, familyList);
if (maxPeopleResult != null) {
return maxPeopleResult;
}
TourLine line = tourLedgerService.dao().fetch(TourLine.class, matter.getLineId());
boolean directFamilyLine = line != null && Boolean.TRUE.equals(line.getDirectFamilyUnitLine());
TourSetting setting = tourLedgerService.dao().fetch(TourSetting.class, matter.getSettingId());
@@ -501,6 +511,8 @@ public class TourMySignupController {
return workflowResult;
}
}
// Signup modification keeps the staff member joined and refreshes the linked ledger id.
tourParticipantRosterService.markJoinedByLedger(ledger);
return Result.success().addMsg(approvalRequired ? "修改已提交,等待审核" : "修改成功");
}
@@ -519,6 +531,12 @@ public class TourMySignupController {
if (ledger == null) {
return Result.error("报名记录不存在");
}
Result signupEndResult = checkSignupEndTime(ledger);
if (signupEndResult != null) {
return signupEndResult;
}
// Reset the roster before deleting the ledger record, because the ledger still carries setting linkage here.
tourParticipantRosterService.markNotJoinedByLedger(ledger);
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId()));
tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId()));
tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId()));
@@ -534,6 +552,12 @@ public class TourMySignupController {
if (ledger == null) {
return Result.error("报名记录不存在");
}
Result signupEndResult = checkSignupEndTime(ledger);
if (signupEndResult != null) {
return signupEndResult;
}
// Reset the roster before deleting the ledger record, because the ledger still carries setting linkage here.
tourParticipantRosterService.markNotJoinedByLedger(ledger);
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId()));
tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId()));
tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId()));
@@ -604,7 +628,30 @@ public class TourMySignupController {
return null;
}
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger) {
/**
* 修改和取消报名都必须受有效报名结束时间控制,避免前端隐藏按钮后仍可直接调用接口。
*/
private Result checkSignupEndTime(TourLedger ledger) {
if (ledger == null || StrUtil.isBlank(ledger.getMatterId())) {
return null;
}
TourMatter matter = tourMatterService.fetch(ledger.getMatterId());
if (matter == null || Boolean.TRUE.equals(matter.getDelFlag()) || !Boolean.TRUE.equals(matter.getEnabled())) {
return null;
}
TourSetting setting = tourLedgerService.dao().fetch(TourSetting.class, matter.getSettingId());
String signupEndTime = defaultIfBlank(setting == null ? "" : setting.getSignupEndTime(), matter.getSignupEndTime());
if (StrUtil.isBlank(signupEndTime)) {
return null;
}
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
if (now.compareTo(normalizeDateTime(signupEndTime, true)) > 0) {
return Result.error("报名已结束,不能修改或取消报名");
}
return null;
}
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger, int currentPeopleCount) {
if (matter == null || Boolean.TRUE.equals(matter.getDelFlag()) || !Boolean.TRUE.equals(matter.getEnabled())) {
return Result.error("报名出行时段不存在或已停用");
}
@@ -622,6 +669,10 @@ public class TourMySignupController {
if (setting == null || Boolean.TRUE.equals(setting.getDelFlag()) || !Boolean.TRUE.equals(setting.getEnabled())) {
return Result.error("报名事项配置不存在或已停用");
}
Result maxSignupResult = checkMaxSignupPeople(matter, oldLedger, currentPeopleCount);
if (maxSignupResult != null) {
return maxSignupResult;
}
Integer activityGroupId = parseInteger(setting.getActivityGroupId());
if (activityGroupId == null || !activityBasicScopeService.isUserInGroup(activityGroupId, SecurityUtil.getUserId())) {
return Result.error("您不在本次疗休养报名范围内");
@@ -661,6 +712,8 @@ public class TourMySignupController {
if (tourLedgerService.count(outProvinceCnd) > 0) {
return Result.error("您已参加过省外线路,不能报名省外线路,请选择省内线路");
}
// Out-province quota is shared by year; lock only before the quota count calculation.
tourLedgerService.lockTourSignupQuotaYear(matter.getYear());
OutProvinceQuota quota = calculateOutProvinceQuota(matter, setting, oldLedger);
if (!quota.canApply()) {
return Result.error(buildOutProvinceQuotaNotice(quota));
@@ -692,6 +745,61 @@ public class TourMySignupController {
return null;
}
/**
* Checks the matter maximum signup capacity at final submit time.
*/
private Result checkMaxSignupPeople(TourMatter matter, TourLedger oldLedger, int currentPeopleCount) {
int maxSignupPeople = matter == null || matter.getMaxSignupPeople() == null ? 0 : matter.getMaxSignupPeople();
if (maxSignupPeople <= 0) {
return null;
}
// Maximum signup capacity is owned by the current matter, so only the same matter is serialized.
tourLedgerService.lockTourSignupMatter(matter.getId());
String excludeLedgerId = oldLedger == null ? null : oldLedger.getId();
int signedPeople = countMatterSignupPeople(matter.getId(), excludeLedgerId);
if (signedPeople + currentPeopleCount > maxSignupPeople) {
return Result.error("当前线路最多报名人数为" + maxSignupPeople + "人,当前已报名" + signedPeople
+ "人,本次报名" + currentPeopleCount + "人,不能超过最多报名人数");
}
return null;
}
/**
* Counts current submit people as one staff member plus all submitted family members.
*/
private int calculateCurrentSignupPeople(List<TourLedgerFamily> familyList) {
return 1 + (familyList == null ? 0 : familyList.size());
}
/**
* Counts valid signed people for a matter, including staff and their family members.
*/
private int countMatterSignupPeople(String matterId, String excludeLedgerId) {
if (StrUtil.isBlank(matterId)) {
return 0;
}
String excludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND t.id <> @excludeLedgerId";
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(1 + IFNULL(f.familyCount, 0)), 0) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
""" + excludeSql);
sql.setParam("matterId", matterId);
if (StrUtil.isNotBlank(excludeLedgerId)) {
sql.setParam("excludeLedgerId", excludeLedgerId);
}
sql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(sql);
return sql.getInt();
}
private TourLedger fetchLatestLedger(Cnd cnd) {
cnd.desc(TourLedger::getSignupTime);
cnd.desc(TourLedger::getCreatedAt);
@@ -985,46 +1093,6 @@ public class TourMySignupController {
&& idCard.matches("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dX]$");
}
private Result checkMaxGroupPeople(TourMatter matter, TourLedger oldLedger, List<TourLedgerFamily> familyList) {
if (matter == null || matter.getMaxGroupPeople() == null || matter.getMaxGroupPeople() <= 0) {
return null;
}
int currentPeople = 1 + (Lang.isEmpty(familyList) ? 0 : familyList.size());
int signedPeople = countSignupPeople(matter.getId(), oldLedger == null ? null : oldLedger.getId());
int totalPeople = signedPeople + currentPeople;
if (totalPeople > matter.getMaxGroupPeople()) {
return Result.error("当前报名人数 " + signedPeople + " 人,本次报名 " + currentPeople
+ " 人,最多成团人数 " + matter.getMaxGroupPeople() + " 人,报名后将超过人数上限,不能提交");
}
return null;
}
private int countSignupPeople(String matterId, String excludeLedgerId) {
if (StrUtil.isBlank(matterId)) {
return 0;
}
String excludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND t.id <> @excludeLedgerId";
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(1 + IFNULL(f.familyCount, 0)), 0) AS signupPeople
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
""" + excludeSql);
sql.setParam("matterId", matterId);
if (StrUtil.isNotBlank(excludeLedgerId)) {
sql.setParam("excludeLedgerId", excludeLedgerId);
}
sql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(sql);
return sql.getInt();
}
private String buildSelectedLineMessage(TourLedger ledger) {
String selectedLineName = getLedgerLineName(ledger);
return "您已经选择【" + selectedLineName + "】,取消后再重新选择。";
@@ -1650,14 +1718,15 @@ public class TourMySignupController {
private PDType0Font loadChineseFont(PDDocument document, boolean bold) throws IOException {
String[] paths = bold
? new String[]{"C:/Windows/Fonts/simhei.ttf", "C:/Windows/Fonts/msyhbd.ttf", "C:/Windows/Fonts/simsun.ttf"}
: new String[]{"C:/Windows/Fonts/simfang.ttf", "C:/Windows/Fonts/simsun.ttf", "C:/Windows/Fonts/msyh.ttf"};
? new String[]{"/usr/share/fonts/chinese/simhei.ttf", "/usr/share/fonts/chinese/msyhbd.ttc", "/usr/share/fonts/chinese/simsun.ttc"}
: new String[]{"/usr/share/fonts/chinese/simfang.ttf", "/usr/share/fonts/chinese/simsun.ttc", "/usr/share/fonts/chinese/msyh.ttc"};
for (String path : paths) {
File file = new File(path);
if (file.exists()) {
return PDType0Font.load(document, file);
}
}
throw new IOException("未找到中文字体文件");
}
@@ -0,0 +1,367 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 疗休养人员名单统计查询控制器。
* 名单表保存可参与人员基础信息,报名线路、成团等信息通过台账补充读取。
*/
@IocBean
@Ok("json:full")
@At("/platform/tour/participantRoster")
public class TourParticipantRosterController {
@Inject
private TourParticipantRosterService tourParticipantRosterService;
/**
* 打开 PC 端疗休养人员名单查询页面。
*/
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/Tour/participantRoster/index.html")
@SaCheckPermission("tour.participantRoster")
public void index() {
}
/**
* 按年度范围和可选报名维度查询人员名单。
*/
@At
@SaCheckPermission("tour.participantRoster")
public Result pageData(PageForm pageForm, Integer startYear, Integer endYear, String settingId, String matterId,
String keyword, String unitName, String unionId, Boolean joined, String lineType,
String groupCode, String undertakeUnionId) {
Result yearResult = checkYearRange(startYear, endYear);
if (yearResult != null) {
return yearResult;
}
Cnd cnd = buildQueryCnd(startYear, endYear, settingId, matterId, keyword, unitName, unionId, joined,
lineType, groupCode, undertakeUnionId);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM tour_participant_roster r
INNER JOIN tour_setting s ON s.id = r.settingId AND s.delFlag = 0
LEFT JOIN tour_ledger t ON t.id = r.ledgerId AND t.delFlag = 0
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
tourParticipantRosterService.dao().execute(countSql);
Sql listSql = Sqls.create("""
SELECT
r.id,
s.`year`,
s.configName,
r.jobNo,
r.userName,
r.unitId,
r.unitName,
r.unionId,
r.unionName,
r.joined,
r.ledgerId,
t.lineType,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName,
m.matterName,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
ELSE ''
END AS travelPeriod,
t.groupCode,
t.groupName,
t.undertakeUnionId,
t.undertakeUnionName,
t.signupTime
FROM tour_participant_roster r
INNER JOIN tour_setting s ON s.id = r.settingId AND s.delFlag = 0
LEFT JOIN tour_ledger t ON t.id = r.ledgerId AND t.delFlag = 0
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
$condition
ORDER BY $orderColumn $orderBy, s.`year` DESC, r.unionName ASC, r.unitName ASC, r.jobNo ASC
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", getOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderBy", "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc");
listSql.setPager(tourParticipantRosterService.dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
tourParticipantRosterService.dao().execute(listSql);
Pagination<NutMap> pagination = new Pagination<>(
pageForm.getPageNumber(),
pageForm.getPageSize(),
countSql.getInt(),
listSql.getList(NutMap.class)
);
return Result.success(pagination);
}
/**
* 根据年度范围加载疗休养配置下拉选项。
*/
@At
@SaCheckPermission("tour.participantRoster")
public Result settingOptions(Integer startYear, Integer endYear) {
Result yearResult = checkYearRange(startYear, endYear);
if (yearResult != null) {
return yearResult;
}
Sql sql = Sqls.create("""
SELECT id, configName, `year`
FROM tour_setting
WHERE delFlag = 0
AND `year` >= @startYear
AND `year` <= @endYear
ORDER BY `year` DESC, sortNo ASC, createdAt DESC
""");
sql.setParam("startYear", startYear);
sql.setParam("endYear", endYear);
sql.setCallback(Sqls.callback.maps());
tourParticipantRosterService.dao().execute(sql);
return Result.success(sql.getList(NutMap.class));
}
/**
* 加载线路选择器使用的疗休养事项选项。
* 未选择配置时加载年度范围内全部事项,选择配置后只加载当前配置下事项。
*/
@At
@SaCheckPermission("tour.participantRoster")
public Result lineOptions(Integer startYear, Integer endYear, String settingId) {
Result yearResult = checkYearRange(startYear, endYear);
if (yearResult != null) {
return yearResult;
}
Cnd cnd = Cnd.NEW();
cnd.and("s.delFlag", "=", false);
cnd.and("m.delFlag", "=", false);
cnd.and("m.lineId", "IS NOT", null);
cnd.and("m.lineId", "<>", "");
cnd.and("s.`year`", ">=", startYear);
cnd.and("s.`year`", "<=", endYear);
cnd.andEX("s.id", "=", settingId);
Sql sql = Sqls.create("""
SELECT
m.id AS matterId,
m.matterName,
s.id AS settingId,
s.configName,
s.`year`,
l.id AS lineId,
l.lineName,
l.lineType,
CONCAT(s.configName, '【', s.`year`, '】 / ', m.matterName) AS label
FROM tour_matter m
INNER JOIN tour_setting s ON s.id = m.settingId
LEFT JOIN tour_line l ON l.id = m.lineId AND l.delFlag = 0
$condition
ORDER BY s.`year` DESC, s.sortNo ASC, m.travelStartTime ASC, m.createdAt DESC
""");
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.maps());
tourParticipantRosterService.dao().execute(sql);
return Result.success(sql.getList(NutMap.class));
}
/**
* 从年度范围内的人员名单加载工会下拉选项。
*/
@At
@SaCheckPermission("tour.participantRoster")
public Result unionOptions(Integer startYear, Integer endYear) {
Result yearResult = checkYearRange(startYear, endYear);
if (yearResult != null) {
return yearResult;
}
Sql sql = Sqls.create("""
SELECT DISTINCT r.unionId AS id, r.unionName AS name
FROM tour_participant_roster r
INNER JOIN tour_setting s ON s.id = r.settingId AND s.delFlag = 0
WHERE r.delFlag = 0
AND IFNULL(r.unionId, '') <> ''
AND s.`year` >= @startYear
AND s.`year` <= @endYear
ORDER BY r.unionName ASC
""");
sql.setParam("startYear", startYear);
sql.setParam("endYear", endYear);
sql.setCallback(Sqls.callback.maps());
tourParticipantRosterService.dao().execute(sql);
return Result.success(sql.getList(NutMap.class));
}
/**
* 根据参与人员范围和现有报名台账重新生成所选配置的人员名单。
*/
@At
@SaCheckPermission("tour.participantRoster")
public Result rebuildRoster(Integer startYear, Integer endYear, @Param("settingIds") String settingIds) {
Result yearResult = checkYearRange(startYear, endYear);
if (yearResult != null) {
return yearResult;
}
List<String> settingIdList = parseSettingIds(settingIds);
if (settingIdList.isEmpty()) {
return Result.error("请选择需要重新生成的疗休养配置");
}
Result settingResult = checkSettingsInYearRange(startYear, endYear, settingIdList);
if (settingResult != null) {
return settingResult;
}
List<String> warningMessages = tourParticipantRosterService.rebuildSettingRosters(settingIdList);
NutMap data = NutMap.NEW().addv("warnings", warningMessages);
return Result.success(data).addMsg("人员名单重新生成成功");
}
/**
* 校验年度范围是否完整且顺序合法。
*/
private Result checkYearRange(Integer startYear, Integer endYear) {
if (startYear == null || endYear == null) {
return Result.error("年度范围不能为空");
}
if (startYear > endYear) {
return Result.error("开始年度不能大于结束年度");
}
int nextYear = LocalDate.now().getYear() + 1;
if (startYear < 1900 || endYear > nextYear) {
return Result.error("年度范围不合法");
}
return null;
}
/**
* 解析前端多选配置 ID,并过滤空值和重复值。
*/
private List<String> parseSettingIds(String settingIds) {
if (StrUtil.isBlank(settingIds)) {
return new ArrayList<>();
}
List<String> rawList = Json.fromJsonAsList(String.class, settingIds);
Set<String> idSet = new LinkedHashSet<>();
for (String id : rawList) {
if (StrUtil.isNotBlank(id)) {
idSet.add(id.trim());
}
}
return new ArrayList<>(idSet);
}
/**
* 校验所选疗休养配置是否仍属于页面提交的年度范围。
*/
private Result checkSettingsInYearRange(Integer startYear, Integer endYear, List<String> settingIds) {
Sql sql = Sqls.create("""
SELECT COUNT(1)
FROM tour_setting
WHERE delFlag = 0
AND id IN (@settingIds)
AND `year` >= @startYear
AND `year` <= @endYear
""");
sql.setParam("settingIds", settingIds.toArray(new String[0]));
sql.setParam("startYear", startYear);
sql.setParam("endYear", endYear);
sql.setCallback(Sqls.callback.integer());
tourParticipantRosterService.dao().execute(sql);
if (sql.getInt() != settingIds.size()) {
return Result.error("所选疗休养配置与年度范围不匹配,请刷新后重试");
}
return null;
}
/**
* 构造总数查询和列表查询共用的 SQL 条件。
*/
private Cnd buildQueryCnd(Integer startYear, Integer endYear, String settingId, String matterId, String keyword,
String unitName, String unionId, Boolean joined, String lineType,
String groupCode, String undertakeUnionId) {
Cnd cnd = Cnd.NEW();
cnd.and("r.delFlag", "=", false);
cnd.and("s.`year`", ">=", startYear);
cnd.and("s.`year`", "<=", endYear);
cnd.andEX("r.settingId", "=", settingId);
cnd.andEX("t.matterId", "=", matterId);
cnd.andEX("r.unionId", "=", unionId);
cnd.andEX("r.unitName", "like", StrUtil.isBlank(unitName) ? null : "%" + unitName.trim() + "%");
cnd.andEX("t.lineType", "=", lineType);
cnd.andEX("t.undertakeUnionId", "=", undertakeUnionId);
appendKeywordFilter(cnd, keyword);
return cnd;
}
/**
* 增加工号和姓名模糊查询条件。
*/
private void appendKeywordFilter(Cnd cnd, String keyword) {
if (StrUtil.isBlank(keyword)) {
return;
}
SqlExpressionGroup group = new SqlExpressionGroup();
group.orLike("r.jobNo", keyword.trim());
group.orLike("r.userName", keyword.trim());
cnd.and(group);
}
/**
* 将前端排序字段映射为安全的 SQL 排序列。
*/
private String getOrderColumn(String orderName) {
if ("year".equals(orderName)) {
return "s.`year`";
}
if ("jobNo".equals(orderName)) {
return "r.jobNo";
}
if ("userName".equals(orderName)) {
return "r.userName";
}
if ("unitName".equals(orderName)) {
return "r.unitName";
}
if ("unionName".equals(orderName)) {
return "r.unionName";
}
if ("joined".equals(orderName)) {
return "r.joined";
}
if ("lineType".equals(orderName)) {
return "t.lineType";
}
if ("lineName".equals(orderName)) {
return "COALESCE(NULLIF(l.lineName, ''), t.lineName)";
}
if ("signupTime".equals(orderName)) {
return "t.signupTime";
}
return "s.`year`";
}
}
@@ -97,18 +97,7 @@ public class TourSchoolUnionApprovalController {
task.taskParentId,
task.variable AS taskVariable,
IFNULL(GROUP_CONCAT(DISTINCT nt.displayName), '结束') AS curTaskName,
CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS currentLineName,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS currentLineName,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -479,18 +468,7 @@ public class TourSchoolUnionApprovalController {
return "";
}
Sql sql = Sqls.create("""
SELECT CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS lineName
SELECT COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName
FROM tour_ledger t
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
@@ -8,6 +8,7 @@ import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSettingLot;
import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourSettingService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
@@ -32,6 +33,9 @@ public class TourSettingController {
@Inject
private TourSettingService tourSettingService;
@Inject
private TourParticipantRosterService tourParticipantRosterService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/Tour/setting/index.html")
@SaCheckPermission("tour.setting")
@@ -153,9 +157,11 @@ public class TourSettingController {
if (Lang.isNotEmpty(lotDeleteList)) {
tourSettingService.dao().clear(TourSettingLot.class, Cnd.where(TourSettingLot::getId, "in", lotDeleteList));
}
tourSettingService.updateIgnoreNull(tourSetting);
tourSettingService.updateWithNullableSignupTime(tourSetting);
saveLots(tourSetting);
}
// Initialize the participant roster only once; existing roster data is preserved on later setting edits.
tourParticipantRosterService.syncSettingRosterIfEmpty(tourSetting.getId());
return Result.success();
}
@@ -168,6 +174,8 @@ public class TourSettingController {
return Result.error("参数错误");
}
tourSettingService.dao().clear(TourSettingLot.class, Cnd.where(TourSettingLot::getSettingId, "=", id));
// Remove roster rows for the deleted setting so later statistics ignore obsolete data.
tourParticipantRosterService.clearSettingRoster(id);
tourSettingService.delete(id);
return Result.success();
}
@@ -205,14 +213,9 @@ public class TourSettingController {
if (StrUtil.isBlank(tourSetting.getConfigName())) {
return Result.error("配置名称不能为空");
}
if (tourSetting.getMinGroupPeople() != null && tourSetting.getMaxGroupPeople() != null
&& tourSetting.getMinGroupPeople() > tourSetting.getMaxGroupPeople()) {
return Result.error("最少成团人数不能大于最多成团人数");
}
if (StrUtil.isBlank(tourSetting.getSignupStartTime()) || StrUtil.isBlank(tourSetting.getSignupEndTime())) {
return Result.error("报名时间不能为空");
}
if (tourSetting.getSignupStartTime().compareTo(tourSetting.getSignupEndTime()) >= 0) {
// 报名时间允许不配置;仅在开始和结束都填写时校验先后顺序。
if (StrUtil.isNotBlank(tourSetting.getSignupStartTime()) && StrUtil.isNotBlank(tourSetting.getSignupEndTime())
&& tourSetting.getSignupStartTime().compareTo(tourSetting.getSignupEndTime()) >= 0) {
return Result.error("开始报名时间必须小于结束报名时间");
}
if (tourSetting.getCycleStartYear() != null && tourSetting.getCycleEndYear() != null
@@ -1,5 +1,9 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.lang.Dict;
@@ -7,6 +11,7 @@ import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessInstance;
@@ -30,6 +35,7 @@ import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerDirectRelativeSer
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerFamilyService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourMatterService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
@@ -44,10 +50,13 @@ import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import org.apache.poi.ss.usermodel.Workbook;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -71,6 +80,9 @@ public class TourSignupController {
@Inject
private TourLedgerDirectRelativeService tourLedgerDirectRelativeService;
@Inject
private TourParticipantRosterService tourParticipantRosterService;
@Inject
private SysDictService sysDictService;
@@ -136,7 +148,7 @@ public class TourSignupController {
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public Result h5PageData(PageForm pageForm, Integer year, String lineName, String lineType, String unionId, Boolean directFamilyUnitLine) {
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortAsc("lineName");
pageForm.defaultSortAsc("lineCode");
}
Cnd cnd = buildQueryCnd(year, lineName, lineType, unionId);
cnd.andEX("l.directFamilyUnitLine", "=", directFamilyUnitLine);
@@ -171,6 +183,9 @@ public class TourSignupController {
lot.lotName,
m.travelStartTime,
m.travelEndTime,
COALESCE(NULLIF(s.signupStartTime, ''), m.signupStartTime) AS signupStartTime,
COALESCE(NULLIF(s.signupEndTime, ''), m.signupEndTime) AS signupEndTime,
m.maxSignupPeople,
IFNULL(sc.signupCount, 0) AS signupCount,
IFNULL(sc.familyCount, 0) AS familyCount,
my.ledgerId,
@@ -183,6 +198,7 @@ public class TourSignupController {
FROM tour_matter m
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN sys_union u ON u.id = m.unionId
LEFT JOIN tour_setting s ON s.id = m.settingId
LEFT JOIN tour_travel_agency a ON a.id = l.travelAgencyId
LEFT JOIN tour_setting_lot lot ON lot.id = l.lotId
LEFT JOIN (
@@ -222,7 +238,7 @@ public class TourSignupController {
GROUP BY t.matterId, t.jobNo
) my ON my.matterId = m.id
$condition
ORDER BY $orderColumn $orderBy, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, m.createdAt DESC
ORDER BY $orderColumn $orderBy, CAST(l.lineCode AS UNSIGNED) ASC, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, m.createdAt DESC
""");
listSql.setCondition(cnd);
listSql.setParam("currentJobNo", currentJobNo);
@@ -241,7 +257,7 @@ public class TourSignupController {
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm, Integer year, String lineName, String lineType, String unionId) {
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortAsc("lineName");
pageForm.defaultSortAsc("lineCode");
}
Cnd cnd = buildQueryCnd(year, lineName, lineType, unionId);
String currentJobNo = currentJobNo();
@@ -265,6 +281,7 @@ public class TourSignupController {
m.matterName,
m.minGroupPeople,
m.maxGroupPeople,
m.maxSignupPeople,
m.unionId,
COALESCE(u.name, '校工会') AS unionName,
m.lineId,
@@ -325,12 +342,12 @@ public class TourSignupController {
GROUP BY t.matterId, t.jobNo
) my ON my.matterId = m.id
$condition
ORDER BY $orderColumn $orderBy, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, m.createdAt DESC
ORDER BY $orderColumn $orderBy, CAST(l.lineCode AS UNSIGNED) ASC, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, m.createdAt DESC
""");
listSql.setCondition(cnd);
listSql.setParam("currentJobNo", currentJobNo);
listSql.setParam("abandonState", ProcessInstanceStateEnum.ABANDON.getCode());
listSql.setVar("orderColumn", getOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderColumn", tourLedgerService.getTourSignupOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderBy", "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc");
listSql.setPager(tourMatterService.dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
@@ -340,6 +357,28 @@ public class TourSignupController {
return Result.success(pagination);
}
/**
* 导出 PC 端疗休养报名列表,导出范围与当前页面查询条件保持一致。
*/
@At
@SaCheckPermission("tour.signup")
@Ok("void")
public void exportData(PageForm pageForm, Integer year, String lineName, String lineType, String unionId,
HttpServletResponse response) {
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortAsc("lineCode");
}
// 导出不分页,按当前查询条件和排序口径查询全量列表数据。
List<NutMap> rows = querySignupExportRows(pageForm, year, lineName, lineType, unionId);
List<ExcelExportEntity> entities = buildSignupExportEntities();
// 导出文件统一使用 XSSF,避免大数据量时生成旧版 xls 格式。
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, rows);
CommonDownloadUtil.download("疗休养报名.xlsx", workbook, response);
}
@At
@SaCheckPermission(value = {"tour.signup", "h5.tour.signup"}, mode = SaMode.OR)
public Result unionOptions() {
@@ -464,6 +503,7 @@ public class TourSignupController {
m.travelStartTime,
m.travelEndTime,
m.contactName,
m.maxSignupPeople,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -529,19 +569,6 @@ public class TourSignupController {
.and(TourLedger::getJobNo, "=", staff.getString("jobNo"));
ledgerCnd.desc(TourLedger::getCreatedAt);
TourLedger ledger = tourLedgerService.fetch(ledgerCnd);
TourMatter matterEntity = tourMatterService.fetch(matter.getString("matterId"));
TourLedger ruleLedger = new TourLedger();
ruleLedger.setId(ledger == null ? null : ledger.getId());
ruleLedger.setYear(matterEntity == null ? matter.getInt("year") : matterEntity.getYear());
ruleLedger.setMatterId(matter.getString("matterId"));
ruleLedger.setLineId(matter.getString("lineId"));
ruleLedger.setLineName(matter.getString("lineName"));
if (!Boolean.TRUE.equals(noticeChecked)) {
Result ruleResult = checkSignupRule(ruleLedger, matterEntity, ledger);
if (ruleResult != null) {
return ruleResult;
}
}
List<TourLedgerFamily> families = Collections.emptyList();
TourLedgerDirectRelative directRelative = null;
if (ledger != null) {
@@ -583,47 +610,8 @@ public class TourSignupController {
if (activityGroupId == null || !activityBasicScopeService.isUserInGroup(activityGroupId, SecurityUtil.getUserId())) {
return Result.error("您不在本次疗休养报名范围内");
}
TourLedger oldLedger = fetchCurrentUserMatterLedger(matter.getId());
CycleAllowedTimes cycleAllowedTimes = calculateCycleAllowedTimes(matter, setting, oldLedger);
if (!cycleAllowedTimes.canApply()) {
return Result.success(NutMap.NEW()
.addv("canApply", false)
.addv("noticeRequired", true)
.addv("noticeType", "cycleAllowedTimes")
.addv("cycleStartYear", cycleAllowedTimes.startYear())
.addv("cycleEndYear", cycleAllowedTimes.endYear())
.addv("cycleAllowedTimes", cycleAllowedTimes.allowedTimes())
.addv("cycleJoinedTimes", cycleAllowedTimes.joinedTimes())
.addv("message", buildCycleAllowedTimesNotice(cycleAllowedTimes)));
}
CycleTotalCost cycleTotalCost = calculateCycleTotalCost(matter, setting, line, oldLedger);
if (!cycleTotalCost.canApply()) {
return Result.success(NutMap.NEW()
.addv("canApply", false)
.addv("noticeRequired", true)
.addv("noticeType", "cycleTotalCost")
.addv("cycleStartYear", cycleTotalCost.startYear())
.addv("cycleEndYear", cycleTotalCost.endYear())
.addv("cycleTotalCost", cycleTotalCost.totalCost())
.addv("cycleJoinedCost", cycleTotalCost.joinedCost())
.addv("currentLineCost", cycleTotalCost.currentCost())
.addv("message", buildCycleTotalCostNotice(cycleTotalCost)));
}
if (!isOutProvinceLine(line.getLineType())) {
return Result.success(NutMap.NEW().addv("canApply", true).addv("noticeRequired", false));
}
OutProvinceQuota quota = calculateOutProvinceQuota(matter, setting, oldLedger);
return Result.success(NutMap.NEW()
.addv("noticeRequired", true)
.addv("noticeType", "outProvinceQuota")
.addv("canApply", quota.canApply())
.addv("ratioType", quota.ratioType())
.addv("basePeople", quota.basePeople())
.addv("totalSignupPeople", quota.totalSignupPeople())
.addv("outProvinceSignupPeople", quota.outProvinceSignupPeople())
.addv("allowPeople", quota.allowPeople())
.addv("ratioText", quota.ratioText())
.addv("message", buildOutProvinceQuotaNotice(quota)));
// 我要报名入口不再做周期次数、周期费用和省外名额提示,最终报名规则由 doSignup 统一校验。
return Result.success(NutMap.NEW().addv("canApply", true).addv("noticeRequired", false));
}
@At
@@ -740,7 +728,8 @@ public class TourSignupController {
return Result.error("不能修改他人的报名记录");
}
TourMatter matter = tourMatterService.fetch(ledger.getMatterId());
Result ruleResult = checkSignupRule(ledger, matter, update ? oldLedger : null);
TourLedger ruleOldLedger = update ? oldLedger : fetchCurrentUserMatterLedger(ledger.getMatterId());
Result ruleResult = checkSignupRule(ledger, matter, update ? oldLedger : null, ruleOldLedger, calculateCurrentSignupPeople(familyList));
if (ruleResult != null) {
return ruleResult;
}
@@ -751,10 +740,6 @@ public class TourSignupController {
update = true;
}
}
Result maxPeopleResult = checkMaxGroupPeople(matter, oldLedger, familyList);
if (maxPeopleResult != null) {
return maxPeopleResult;
}
TourLine line = tourLedgerService.dao().fetch(TourLine.class, matter.getLineId());
boolean directFamilyLine = line != null && Boolean.TRUE.equals(line.getDirectFamilyUnitLine());
TourSetting setting = tourLedgerService.dao().fetch(TourSetting.class, matter.getSettingId());
@@ -817,6 +802,8 @@ public class TourSignupController {
return workflowResult;
}
}
// Signup and modification both mean the staff member is counted as joined for this setting.
tourParticipantRosterService.markJoinedByLedger(ledger);
return Result.success().addMsg(approvalRequired ? (update ? "修改已提交,等待审核" : "申请已提交,等待审核") : (update ? "修改成功" : "报名成功"));
}
@@ -841,6 +828,14 @@ public class TourSignupController {
if (!currentJobNo().equals(ledger.getJobNo())) {
return Result.error("不能取消他人的报名记录");
}
TourMatter matter = tourMatterService.fetch(ledger.getMatterId());
TourSetting setting = matter == null ? null : tourLedgerService.dao().fetch(TourSetting.class, matter.getSettingId());
Result signupTimeResult = tourLedgerService.checkSignupTimeWindow(matter, setting);
if (signupTimeResult != null) {
return signupTimeResult;
}
// Reset the roster before deleting the ledger record, because the ledger still carries setting linkage here.
tourParticipantRosterService.markNotJoinedByLedger(ledger);
tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId()));
tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId()));
tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId()));
@@ -962,26 +957,12 @@ public class TourSignupController {
return null;
}
private Result checkMaxGroupPeople(TourMatter matter, TourLedger oldLedger, List<TourLedgerFamily> familyList) {
if (matter == null || matter.getMaxGroupPeople() == null || matter.getMaxGroupPeople() <= 0) {
return null;
}
int currentPeople = 1 + (Lang.isEmpty(familyList) ? 0 : familyList.size());
int signedPeople = countSignupPeople(matter.getId(), oldLedger == null ? null : oldLedger.getId());
int totalPeople = signedPeople + currentPeople;
if (totalPeople > matter.getMaxGroupPeople()) {
return Result.error("当前报名人数 " + signedPeople + " 人,本次报名 " + currentPeople
+ " 人,最多成团人数 " + matter.getMaxGroupPeople() + " 人,报名后将超过人数上限,不能提交");
}
return null;
}
private boolean isValidIdCard(String idCard) {
return StrUtil.isNotBlank(idCard)
&& idCard.matches("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dX]$");
}
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger) {
private Result checkSignupRule(TourLedger ledger, TourMatter matter, TourLedger oldLedger, TourLedger maxGroupOldLedger, int currentPeopleCount) {
if (matter == null || Boolean.TRUE.equals(matter.getDelFlag()) || !Boolean.TRUE.equals(matter.getEnabled())) {
return Result.error("报名出行时段不存在或已停用");
}
@@ -996,6 +977,10 @@ public class TourSignupController {
if (setting == null || Boolean.TRUE.equals(setting.getDelFlag()) || !Boolean.TRUE.equals(setting.getEnabled())) {
return Result.error("报名事项配置不存在或已停用");
}
Result maxSignupResult = checkMaxSignupPeople(matter, maxGroupOldLedger, currentPeopleCount);
if (maxSignupResult != null) {
return maxSignupResult;
}
Integer activityGroupId = parseInteger(setting.getActivityGroupId());
if (activityGroupId == null || !activityBasicScopeService.isUserInGroup(activityGroupId, SecurityUtil.getUserId())) {
return Result.error("您不在本次疗休养报名范围内");
@@ -1035,6 +1020,8 @@ public class TourSignupController {
if (tourLedgerService.count(outProvinceCnd) > 0) {
return Result.error("您已参加过省外线路,不能报名省外线路,请选择省内线路");
}
// Out-province quota is shared by year; lock only before the quota count calculation.
tourLedgerService.lockTourSignupQuotaYear(matter.getYear());
OutProvinceQuota quota = calculateOutProvinceQuota(matter, setting, oldLedger);
if (!quota.canApply()) {
return Result.error(buildOutProvinceQuotaNotice(quota));
@@ -1066,6 +1053,61 @@ public class TourSignupController {
return null;
}
/**
* Checks the matter maximum signup capacity at final submit time.
*/
private Result checkMaxSignupPeople(TourMatter matter, TourLedger oldLedger, int currentPeopleCount) {
int maxSignupPeople = matter == null || matter.getMaxSignupPeople() == null ? 0 : matter.getMaxSignupPeople();
if (maxSignupPeople <= 0) {
return null;
}
// Maximum signup capacity is owned by the current matter, so only the same matter is serialized.
tourLedgerService.lockTourSignupMatter(matter.getId());
String excludeLedgerId = oldLedger == null ? null : oldLedger.getId();
int signedPeople = countMatterSignupPeople(matter.getId(), excludeLedgerId);
if (signedPeople + currentPeopleCount > maxSignupPeople) {
return Result.error("当前线路最多报名人数为" + maxSignupPeople + "人,当前已报名" + signedPeople
+ "人,本次报名" + currentPeopleCount + "人,不能超过最多报名人数");
}
return null;
}
/**
* Counts current submit people as one staff member plus all submitted family members.
*/
private int calculateCurrentSignupPeople(List<TourLedgerFamily> familyList) {
return 1 + (familyList == null ? 0 : familyList.size());
}
/**
* Counts valid signed people for a matter, including staff and their family members.
*/
private int countMatterSignupPeople(String matterId, String excludeLedgerId) {
if (StrUtil.isBlank(matterId)) {
return 0;
}
String excludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND t.id <> @excludeLedgerId";
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(1 + IFNULL(f.familyCount, 0)), 0) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
""" + excludeSql);
sql.setParam("matterId", matterId);
if (StrUtil.isNotBlank(excludeLedgerId)) {
sql.setParam("excludeLedgerId", excludeLedgerId);
}
sql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(sql);
return sql.getInt();
}
private TourLedger fetchLatestLedger(Cnd cnd) {
cnd.desc(TourLedger::getSignupTime);
cnd.desc(TourLedger::getCreatedAt);
@@ -1252,32 +1294,6 @@ public class TourSignupController {
return sql.getInt();
}
private int countSignupPeople(String matterId, String excludeLedgerId) {
if (StrUtil.isBlank(matterId)) {
return 0;
}
String excludeSql = StrUtil.isBlank(excludeLedgerId) ? "" : " AND t.id <> @excludeLedgerId";
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(1 + IFNULL(f.familyCount, 0)), 0) AS signupPeople
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
""" + excludeSql);
sql.setParam("matterId", matterId);
if (StrUtil.isNotBlank(excludeLedgerId)) {
sql.setParam("excludeLedgerId", excludeLedgerId);
}
sql.setCallback(Sqls.callback.integer());
tourLedgerService.dao().execute(sql);
return sql.getInt();
}
private String buildSelectedLineMessage(TourLedger ledger) {
String selectedLineName = getLedgerLineName(ledger);
return "您已经选择【" + selectedLineName + "】,取消后再重新选择。";
@@ -1479,6 +1495,79 @@ public class TourSignupController {
return null;
}
/**
* 按 PC 疗休养报名列表同一批查询条件导出全量数据,导出不分页但保留当前排序口径。
*/
private List<NutMap> querySignupExportRows(PageForm pageForm, Integer year, String lineName, String lineType, String unionId) {
Cnd cnd = buildQueryCnd(year, lineName, lineType, unionId);
Sql sql = Sqls.create("""
SELECT
l.lineName,
l.lineType,
m.minGroupPeople,
m.maxGroupPeople,
m.maxSignupPeople,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
ELSE ''
END AS travelPeriod,
IFNULL(sc.signupCount, 0) AS signupCount
FROM tour_matter m
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN sys_union u ON u.id = m.unionId
LEFT JOIN tour_setting_lot lot ON lot.id = l.lotId
LEFT JOIN (
SELECT t.matterId, SUM(1 + IFNULL(f.familyCount, 0)) AS signupCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId IS NOT NULL
AND t.matterId <> ''
GROUP BY t.matterId
) sc ON sc.matterId = m.id
$condition
ORDER BY $orderColumn $orderBy, CAST(l.lineCode AS UNSIGNED) ASC, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, m.createdAt DESC
""");
sql.setCondition(cnd);
sql.setVar("orderColumn", tourLedgerService.getTourSignupOrderColumn(pageForm.getPageOrderName()));
sql.setVar("orderBy", "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc");
sql.setCallback(Sqls.callback.maps());
tourMatterService.dao().execute(sql);
List<NutMap> rows = sql.getList(NutMap.class);
for (int i = 0; i < rows.size(); i++) {
NutMap row = rows.get(i);
row.put("indexNo", i + 1);
row.put("minGroupPeopleText", row.getInt("minGroupPeople", 0) + "人");
row.put("maxGroupPeopleText", row.getInt("maxGroupPeople", 0) + "人");
row.put("maxSignupPeopleText", row.getInt("maxSignupPeople", 0) + "人");
row.put("signupCountText", row.getInt("signupCount", 0) + "人");
}
return rows;
}
/**
* Builds export columns consistent with the PC list; signup count is always included.
*/
private List<ExcelExportEntity> buildSignupExportEntities() {
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("序号", "indexNo", 10));
entities.add(new ExcelExportEntity("线路名称", "lineName", 30));
entities.add(new ExcelExportEntity("出行时段", "travelPeriod", 28));
entities.add(new ExcelExportEntity("线路类型", "lineType", 16));
entities.add(new ExcelExportEntity("最少成团人数", "minGroupPeopleText", 16));
entities.add(new ExcelExportEntity("最多成团人数", "maxGroupPeopleText", 16));
entities.add(new ExcelExportEntity("最多报名人数", "maxSignupPeopleText", 16));
entities.add(new ExcelExportEntity("报名人数", "signupCountText", 16));
return entities;
}
private Result checkSignupApprovalWorkflowCanSubmit(String ledgerId) {
ProcessInstance instance = tourLedgerService.dao().fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", ledgerId)
.and(ProcessInstance::getState, "<>", ProcessInstanceStateEnum.ABANDON.getCode())
@@ -1537,6 +1626,10 @@ public class TourSignupController {
}
private String getOrderColumn(String orderName) {
if ("lineCode".equals(orderName)) {
// 线路序号按数字排序,避免字符串排序导致 10 排在 2 前面。
return "CAST(l.lineCode AS UNSIGNED)";
}
if ("lineName".equals(orderName)) {
return "l.lineName";
}
@@ -1558,9 +1651,12 @@ public class TourSignupController {
if ("maxGroupPeople".equals(orderName)) {
return "m.maxGroupPeople";
}
if ("maxSignupPeople".equals(orderName)) {
return "m.maxSignupPeople";
}
if ("signupCount".equals(orderName)) {
return "signupCount";
}
return "l.lineName";
return "CAST(l.lineCode AS UNSIGNED)";
}
}
@@ -1,17 +1,22 @@
package com.budwk.app.zhgh.dayofficework.tour.controller;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService;
import com.budwk.app.zhgh.dayofficework.tour.service.TourMatterService;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
@@ -22,6 +27,7 @@ import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
@@ -61,7 +67,17 @@ public class TourUndertakeController {
SELECT COUNT(1)
FROM tour_matter m
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN sys_union u ON u.id = m.unionId
INNER JOIN (
SELECT
t.matterId,
t.groupCode,
MAX(t.undertakeUnionId) AS undertakeUnionId
FROM tour_ledger t
WHERE t.delFlag = 0
AND t.groupCode IS NOT NULL
AND t.groupCode <> ''
GROUP BY t.matterId, t.groupCode
) gs ON gs.matterId = m.id
$condition
""");
countSql.setCondition(cnd);
@@ -74,14 +90,14 @@ public class TourUndertakeController {
m.id AS matterId,
m.`year`,
m.matterName,
m.unionId,
gs.undertakeUnionId AS unionId,
COALESCE(NULLIF(gs.undertakeUnionName, ''), uu.name, '') AS unionName,
CASE
WHEN IFNULL(NULLIF(m.undertakeStatus, ''), 'unclaimed') = 'claimed' THEN COALESCE(u.name, '校工会')
ELSE ''
END AS unionName,
IFNULL(NULLIF(m.undertakeStatus, ''), 'unclaimed') AS undertakeStatus,
WHEN IFNULL(NULLIF(gs.undertakeUnionId, ''), '') <> '' THEN 'claimed'
ELSE 'unclaimed'
END AS undertakeStatus,
CASE
WHEN IFNULL(NULLIF(m.undertakeStatus, ''), 'unclaimed') = 'claimed' THEN '已领'
WHEN IFNULL(NULLIF(gs.undertakeUnionId, ''), '') <> '' THEN '已领'
ELSE '未领'
END AS undertakeStatusName,
m.lineId,
@@ -95,25 +111,20 @@ public class TourUndertakeController {
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
ELSE ''
END AS travelPeriod,
m.minGroupPeople,
m.maxGroupPeople,
IFNULL(sc.signupCount, 0) AS signupCount,
IFNULL(usc.currentUnionSignupCount, 0) AS currentUnionSignupCount,
CASE
WHEN m.maxGroupPeople IS NOT NULL AND m.maxGroupPeople > 0 AND IFNULL(sc.signupCount, 0) > m.maxGroupPeople THEN 'over'
WHEN m.minGroupPeople IS NOT NULL AND m.minGroupPeople > 0 AND IFNULL(sc.signupCount, 0) >= m.minGroupPeople THEN 'formed'
ELSE 'unformed'
END AS groupStatus,
CASE
WHEN m.maxGroupPeople IS NOT NULL AND m.maxGroupPeople > 0 AND IFNULL(sc.signupCount, 0) > m.maxGroupPeople THEN '超员'
WHEN m.minGroupPeople IS NOT NULL AND m.minGroupPeople > 0 AND IFNULL(sc.signupCount, 0) >= m.minGroupPeople THEN '已成团'
ELSE '未成团'
END AS groupStatusName
gs.groupCode,
gs.groupName,
gs.signupCount,
IFNULL(ugs.currentUnionSignupCount, 0) AS currentUnionSignupCount
FROM tour_matter m
INNER JOIN tour_line l ON l.id = m.lineId
LEFT JOIN sys_union u ON u.id = m.unionId
LEFT JOIN (
SELECT t.matterId, SUM(1 + IFNULL(f.familyCount, 0)) AS signupCount
INNER JOIN (
SELECT
t.matterId,
t.groupCode,
MAX(t.groupName) AS groupName,
MAX(t.undertakeUnionId) AS undertakeUnionId,
MAX(t.undertakeUnionName) AS undertakeUnionName,
SUM(1 + IFNULL(f.familyCount, 0)) AS signupCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
@@ -122,12 +133,16 @@ public class TourUndertakeController {
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId IS NOT NULL
AND t.matterId <> ''
GROUP BY t.matterId
) sc ON sc.matterId = m.id
AND t.groupCode IS NOT NULL
AND t.groupCode <> ''
GROUP BY t.matterId, t.groupCode
) gs ON gs.matterId = m.id
LEFT JOIN sys_union uu ON uu.id = gs.undertakeUnionId
LEFT JOIN (
SELECT t.matterId, SUM(1 + IFNULL(f.familyCount, 0)) AS currentUnionSignupCount
SELECT
t.matterId,
t.groupCode,
SUM(1 + IFNULL(f.familyCount, 0)) AS currentUnionSignupCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
@@ -136,13 +151,13 @@ public class TourUndertakeController {
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId IS NOT NULL
AND t.matterId <> ''
AND t.groupCode IS NOT NULL
AND t.groupCode <> ''
AND t.unionId = @currentUnionId
GROUP BY t.matterId
) usc ON usc.matterId = m.id
GROUP BY t.matterId, t.groupCode
) ugs ON ugs.matterId = m.id AND ugs.groupCode = gs.groupCode
$condition
ORDER BY $orderColumn $orderBy, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, m.createdAt DESC
ORDER BY $orderColumn $orderBy, m.`year` DESC, m.travelStartTime ASC, l.lineName ASC, gs.groupCode ASC, m.createdAt DESC
""");
listSql.setCondition(cnd);
listSql.setParam("currentUnionId", currentUnionId);
@@ -180,49 +195,35 @@ public class TourUndertakeController {
return Result.success(unionInfo);
}
/**
* 领办当前成团行,接口必须同时接收事项ID和成团编码,避免按事项误领办全部团。
*/
@At
@SaCheckPermission("tour.undertake")
public Result undertakeLine(String matterId) {
if (StrUtil.isBlank(matterId)) {
public Result undertakeLine(String matterId, String groupCode) {
if (StrUtil.isBlank(matterId) || StrUtil.isBlank(groupCode)) {
return Result.error("参数错误");
}
NutMap unionInfo = currentUnionInfo();
if (unionInfo.isEmpty()) {
return Result.error("未获取到当前登录人工会");
}
TourMatter matter = tourMatterService.fetch(matterId);
if (matter == null || Boolean.TRUE.equals(matter.getDelFlag())) {
return Result.error("事项不存在");
}
if ("claimed".equals(matter.getUndertakeStatus())) {
return Result.error("当前线路已领办,不允许修改");
}
matter.setUnionId(unionInfo.getString("unionId"));
matter.setUndertakeStatus("claimed");
tourMatterService.update(matter);
return Result.success(unionInfo);
return tourLedgerService.undertakeGroup(matterId, groupCode, unionInfo.getString("unionId"), unionInfo.getString("unionName"));
}
/**
* 解除当前成团行的领办信息,清空范围只限定到当前事项的当前团。
*/
@At
@SaCheckPermission("tour.undertake")
public Result releaseUndertakeLine(String matterId) {
public Result releaseUndertakeLine(String matterId, String groupCode) {
if (!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())) {
return Result.error("仅系统管理员可以解除领办");
}
if (StrUtil.isBlank(matterId)) {
if (StrUtil.isBlank(matterId) || StrUtil.isBlank(groupCode)) {
return Result.error("参数错误");
}
TourMatter matter = tourMatterService.fetch(matterId);
if (matter == null || Boolean.TRUE.equals(matter.getDelFlag())) {
return Result.error("事项不存在");
}
if (!"claimed".equals(matter.getUndertakeStatus())) {
return Result.error("当前线路未领办,无需解除");
}
matter.setUnionId("");
matter.setUndertakeStatus("unclaimed");
tourMatterService.update(matter);
return Result.success();
return tourLedgerService.releaseUndertakeGroup(matterId, groupCode);
}
@At
@@ -259,20 +260,30 @@ public class TourUndertakeController {
@At
@SaCheckPermission("tour.undertake")
public Result signupPageData(PageForm pageForm, String matterId, String keyword, String unionId) {
if (StrUtil.isBlank(matterId)) {
public Result signupPageData(PageForm pageForm, String matterId, String keyword, String unionId, String groupCode) {
if (StrUtil.isBlank(matterId) || StrUtil.isBlank(groupCode)) {
return Result.error("参数错误");
}
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortDesc("signupTime");
pageForm.defaultSortAsc("signupTime");
}
Cnd cnd = buildSignupQueryCnd(matterId, keyword, unionId);
Cnd cnd = buildSignupQueryCnd(matterId, keyword, unionId, groupCode);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
FROM (
SELECT t.id AS rowId
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
UNION ALL
SELECT f.id AS rowId
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
INNER JOIN tour_ledger_family f ON f.ledgerId = t.id AND f.delFlag = 0
$condition
) p
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
@@ -280,23 +291,63 @@ public class TourUndertakeController {
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT
t.id,
t.jobNo,
t.userName,
'' AS mobile,
t.idCard,
t.unionId,
t.unionName,
t.signupTime
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
ORDER BY $orderColumn $orderBy, t.signupTime DESC, t.createdAt DESC
SELECT *
FROM (
SELECT
t.id,
t.id AS ledgerId,
t.jobNo,
t.userName,
t.gender,
'' AS mobile,
t.idCard,
t.unionId,
t.unionName,
t.signupTime,
t.groupCode,
t.groupName,
1 AS staffRow,
'教职工' AS rowTypeName,
'' AS relationship,
t.userName AS relatedStaffName,
0 AS rowSort,
t.createdAt AS ledgerCreatedAt,
t.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
UNION ALL
SELECT
f.id,
t.id AS ledgerId,
'' AS jobNo,
f.familyName AS userName,
f.gender,
'' AS mobile,
f.idCard,
t.unionId,
'' AS unionName,
t.signupTime,
t.groupCode,
t.groupName,
0 AS staffRow,
'家属' AS rowTypeName,
f.relationship,
t.userName AS relatedStaffName,
1 AS rowSort,
t.createdAt AS ledgerCreatedAt,
f.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
INNER JOIN tour_ledger_family f ON f.ledgerId = t.id AND f.delFlag = 0
$condition
) p
ORDER BY $orderColumn $orderBy, p.signupTime ASC, p.ledgerCreatedAt ASC, p.rowSort ASC, p.createdAt ASC
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", getSignupOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderColumn", getSignupUnionOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderBy", "ascending".equals(pageForm.getPageOrderBy()) ? "asc" : "desc");
listSql.setPager(tourLedgerService.dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
@@ -304,15 +355,138 @@ public class TourUndertakeController {
List<NutMap> list = listSql.getList(NutMap.class);
fillSignupMobile(list);
fillSignupFamilies(list);
Pagination<NutMap> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, list);
return Result.success(pagination);
}
private Cnd buildSignupQueryCnd(String matterId, String keyword, String unionId) {
/**
* 导出领办查看弹窗中的报名人员数据,导出范围复用查看弹窗的事项、成团、关键字和工会条件。
*/
@At
@SaCheckPermission("tour.undertake")
@Ok("void")
public void exportSignupData(PageForm pageForm, String matterId, String keyword, String unionId, String groupCode,
HttpServletResponse response) {
if (StrUtil.isBlank(pageForm.getPageOrderName())) {
pageForm.defaultSortAsc("signupTime");
}
// 导出不分页,保持筛选、排序、手机号补充和家属平铺逻辑与查看弹窗一致。
List<NutMap> rows = querySignupExportRows(pageForm, matterId, keyword, unionId, groupCode);
List<ExcelExportEntity> entities = buildSignupExportEntities();
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, rows);
CommonDownloadUtil.download("领办报名人员.xlsx", workbook, response);
}
/**
* 查询领办查看弹窗导出数据,复用查看列表的权限范围和人员组装逻辑,仅去掉分页限制。
*/
private List<NutMap> querySignupExportRows(PageForm pageForm, String matterId, String keyword, String unionId, String groupCode) {
Cnd cnd = buildSignupQueryCnd(matterId, keyword, unionId, groupCode);
Sql listSql = Sqls.create("""
SELECT *
FROM (
SELECT
t.id,
t.id AS ledgerId,
t.jobNo,
t.userName,
t.gender,
'' AS mobile,
t.idCard,
t.unionId,
t.unionName,
t.signupTime,
t.groupCode,
t.groupName,
1 AS staffRow,
'教职工' AS rowTypeName,
'' AS relationship,
t.userName AS relatedStaffName,
0 AS rowSort,
t.createdAt AS ledgerCreatedAt,
t.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
UNION ALL
SELECT
f.id,
t.id AS ledgerId,
'' AS jobNo,
f.familyName AS userName,
f.gender,
'' AS mobile,
f.idCard,
t.unionId,
'' AS unionName,
t.signupTime,
t.groupCode,
t.groupName,
0 AS staffRow,
'家属' AS rowTypeName,
f.relationship,
t.userName AS relatedStaffName,
1 AS rowSort,
t.createdAt AS ledgerCreatedAt,
f.createdAt
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
INNER JOIN tour_ledger_family f ON f.ledgerId = t.id AND f.delFlag = 0
$condition
) p
ORDER BY $orderColumn $orderBy, p.signupTime ASC, p.ledgerCreatedAt ASC, p.rowSort ASC, p.createdAt ASC
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", getSignupUnionOrderColumn(pageForm.getPageOrderName()));
listSql.setVar("orderBy", "ascending".equals(pageForm.getPageOrderBy()) ? "asc" : "desc");
listSql.setCallback(Sqls.callback.maps());
tourLedgerService.dao().execute(listSql);
List<NutMap> list = listSql.getList(NutMap.class);
fillSignupMobile(list);
// 补齐导出专用序号和成团展示字段,确保 Excel 列显示与查看弹窗一致。
for (int i = 0; i < list.size(); i++) {
NutMap item = list.get(i);
item.put("indexNo", i + 1);
String groupCodeText = item.getString("groupCode", "");
String groupNameText = item.getString("groupName", "");
item.put("groupInfo", StrUtil.isBlank(groupCodeText) ? "未成团" : groupCodeText + (StrUtil.isBlank(groupNameText) ? "" : " " + groupNameText));
}
return list;
}
/**
* 定义领办查看弹窗导出列,字段顺序与弹窗表格保持一致。
*/
private List<ExcelExportEntity> buildSignupExportEntities() {
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("序号", "indexNo", 10));
entities.add(new ExcelExportEntity("工号", "jobNo", 14));
entities.add(new ExcelExportEntity("姓名", "userName", 14));
entities.add(new ExcelExportEntity("性别", "gender", 10));
entities.add(new ExcelExportEntity("手机号码", "mobile", 18));
entities.add(new ExcelExportEntity("身份证号", "idCard", 24));
entities.add(new ExcelExportEntity("所属工会", "unionName", 24));
entities.add(new ExcelExportEntity("人员类型", "rowTypeName", 12));
entities.add(new ExcelExportEntity("亲属关系", "relationship", 14));
entities.add(new ExcelExportEntity("成团信息", "groupInfo", 24));
entities.add(new ExcelExportEntity("关联教职工名称", "relatedStaffName", 18));
return entities;
}
/**
* 领办线路查看以团为单位,报名人员查询必须固定到当前点击的成团编码。
*/
private Cnd buildSignupQueryCnd(String matterId, String keyword, String unionId, String groupCode) {
Cnd cnd = Cnd.NEW();
cnd.and("t.delFlag", "=", false);
cnd.and("t.matterId", "=", matterId);
cnd.and("t.groupCode", "=", groupCode);
cnd.and("m.delFlag", "=", false);
cnd.and("m.enabled", "=", true);
cnd.and("m.lineId", "IS NOT", null);
@@ -360,9 +534,9 @@ public class TourUndertakeController {
}
/**
* 查看成团报名人员时按台账ID带出家属子列表,避免前端逐行请求家属信息。
* 查看单个团报名人员时将教职工和家属平铺到同一列表,便于领办线路复用线路成团的展示方式。
*/
private void fillSignupFamilies(List<NutMap> list) {
private void flattenSignupFamilies(List<NutMap> list) {
if (list == null || list.isEmpty()) {
return;
}
@@ -380,8 +554,9 @@ public class TourUndertakeController {
Sql sql = Sqls.create("""
SELECT
ledgerId,
staffJobNo AS jobNo,
'' AS jobNo,
familyName AS userName,
gender,
'' AS mobile,
idCard,
relationship
@@ -396,11 +571,29 @@ public class TourUndertakeController {
Map<String, List<NutMap>> familyMap = new HashMap<>();
for (NutMap family : sql.getList(NutMap.class)) {
family.put("staffRow", false);
family.put("rowTypeName", "家属");
family.put("unionName", "");
familyMap.computeIfAbsent(family.getString("ledgerId", ""), key -> new ArrayList<>()).add(family);
}
List<NutMap> flatList = new ArrayList<>();
for (NutMap item : list) {
item.put("families", familyMap.getOrDefault(item.getString("id", ""), List.of()));
item.put("staffRow", true);
item.put("rowTypeName", "教职工");
item.put("relationship", "");
item.put("relatedStaffName", item.getString("userName", ""));
flatList.add(item);
List<NutMap> families = familyMap.getOrDefault(item.getString("id", ""), List.of());
for (NutMap family : families) {
// 家属行继承教职工成团信息,并标明关联教职工名称。
family.put("relatedStaffName", item.getString("userName", ""));
family.put("groupCode", item.getString("groupCode", ""));
family.put("groupName", item.getString("groupName", ""));
}
flatList.addAll(families);
}
list.clear();
list.addAll(flatList);
}
private Cnd buildQueryCnd(Integer year, String lineName, String lineType, String unionId, String undertakeStatus) {
@@ -414,14 +607,14 @@ public class TourUndertakeController {
cnd.andEX("m.`year`", "=", year == null ? LocalDate.now().getYear() : year);
cnd.and(Cnd.likeEX("l.lineName", lineName));
cnd.andEX("l.lineType", "=", lineType);
cnd.andEX("m.unionId", "=", unionId);
cnd.andEX("gs.undertakeUnionId", "=", unionId);
if ("claimed".equals(undertakeStatus)) {
cnd.and("m.undertakeStatus", "=", "claimed");
cnd.and("gs.undertakeUnionId", "IS NOT", null);
cnd.and("gs.undertakeUnionId", "<>", "");
} else if ("unclaimed".equals(undertakeStatus)) {
SqlExpressionGroup statusGroup = new SqlExpressionGroup();
statusGroup.or("m.undertakeStatus", "=", "unclaimed");
statusGroup.or("m.undertakeStatus", "IS", null);
statusGroup.or("m.undertakeStatus", "=", "");
statusGroup.or("gs.undertakeUnionId", "IS", null);
statusGroup.or("gs.undertakeUnionId", "=", "");
cnd.and(statusGroup);
}
applyMatterDataScope(cnd);
@@ -432,7 +625,9 @@ public class TourUndertakeController {
* 线路成团按事项归属控制数据范围:校工会查看全部;分工会查看本工会事项;其它角色仅查看自己创建的事项。
*/
private void applyMatterDataScope(Cnd cnd) {
if (hasSchoolUnionScope() || AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())) {
if (hasSchoolUnionScope()
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
|| hasTourApplicantScope()) {
return;
}
if (hasBranchUnionScope()) {
@@ -460,6 +655,13 @@ public class TourUndertakeController {
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/**
* 疗休养领办人只扩大领办线路查询范围,不改变领办、解除等操作权限。
*/
private boolean hasTourApplicantScope() {
return AuthUtil.hasRole(RoleConstant.TOUR_APPLICANT.name());
}
private boolean hasBranchUnionScope() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
@@ -489,7 +691,7 @@ public class TourUndertakeController {
return "m.matterName";
}
if ("unionName".equals(orderName)) {
return "u.name";
return "unionName";
}
if ("undertakeStatusName".equals(orderName)) {
return "undertakeStatus";
@@ -498,33 +700,33 @@ public class TourUndertakeController {
return "l.lineType";
}
if ("signupCount".equals(orderName)) {
return "signupCount";
return "gs.signupCount";
}
if ("minGroupPeople".equals(orderName)) {
return "m.minGroupPeople";
}
if ("maxGroupPeople".equals(orderName)) {
return "m.maxGroupPeople";
}
if ("groupStatusName".equals(orderName)) {
return "groupStatus";
if ("groupName".equals(orderName) || "groupCode".equals(orderName)) {
return "gs.groupCode";
}
return "l.lineName";
}
private String getSignupOrderColumn(String orderName) {
private String getSignupUnionOrderColumn(String orderName) {
if ("jobNo".equals(orderName)) {
return "t.jobNo";
return "p.jobNo";
}
if ("userName".equals(orderName)) {
return "t.userName";
return "p.userName";
}
if ("gender".equals(orderName)) {
return "p.gender";
}
if ("idCard".equals(orderName)) {
return "t.idCard";
return "p.idCard";
}
if ("unionName".equals(orderName)) {
return "t.unionName";
return "p.unionName";
}
return "t.signupTime";
if ("groupCode".equals(orderName) || "groupName".equals(orderName)) {
return "p.groupCode";
}
return "p.signupTime";
}
}
@@ -97,18 +97,7 @@ public class TourUnionApprovalController {
task.taskParentId,
task.variable AS taskVariable,
IFNULL(GROUP_CONCAT(DISTINCT nt.displayName), '结束') AS curTaskName,
CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS currentLineName,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS currentLineName,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
@@ -479,18 +468,7 @@ public class TourUnionApprovalController {
return "";
}
Sql sql = Sqls.create("""
SELECT CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS lineName
SELECT COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName
FROM tour_ledger t
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
@@ -94,18 +94,7 @@ public class TourUnionLedgerController {
ins.processDefineId AS instanceProcessDefineId,
IFNULL(l.directFamilyUnitLine, 0) AS directFamilyUnitLine,
IFNULL(GROUP_CONCAT(DISTINCT task.displayName), IF(ins.id IS NULL, '', '结束')) AS curTaskName,
CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS currentLineName,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS currentLineName,
IFNULL(f.familyCount, 0) AS familyCount,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
@@ -804,18 +793,7 @@ public class TourUnionLedgerController {
return "";
}
Sql sql = Sqls.create("""
SELECT CASE
WHEN m.id IS NULL THEN COALESCE(NULLIF(l.lineName, ''), t.lineName)
ELSE CONCAT(
COALESCE(NULLIF(l.lineName, ''), t.lineName),
'(',
CASE
WHEN IFNULL(m.unionId, '') = '' THEN '校工会'
ELSE IFNULL(mu.name, '')
END,
')'
)
END AS lineName
SELECT COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName
FROM tour_ledger t
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
@@ -101,6 +101,32 @@ public class TourLedger extends BaseModel implements Serializable {
@ColDefine(type = ColType.VARCHAR, width = 50)
private String lineType;
@Column
@Comment("成团编码")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String groupCode;
@Column
@Comment("成团名称")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String groupName;
/**
* 领办工会编码按团回填到台账,避免领办动作再修改事项归属工会。
*/
@Column
@Comment("领办工会编码")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String undertakeUnionId;
/**
* 领办工会名称与编码一起落台账,列表按团展示时直接读取当前团领办信息。
*/
@Column
@Comment("领办工会")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String undertakeUnionName;
@Column
@Comment("报名酒店")
@ColDefine(type = ColType.VARCHAR, width = 100)
@@ -33,8 +33,8 @@ public class TourLine extends BaseModel implements Serializable {
@Column
@Comment("排序编号")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String lineCode;
@ColDefine(type = ColType.INT, width = 11)
private Integer lineCode;
@Column
@Comment("线路名称")
@@ -117,6 +117,11 @@ public class TourMatter extends BaseModel implements Serializable {
@ColDefine(type = ColType.INT)
private Integer maxGroupPeople;
@Column
@Comment("最多报名人数")
@ColDefine(type = ColType.INT)
private Integer maxSignupPeople;
@Column
@Comment("预计费用")
@ColDefine(customType = "decimal(10,2)")
@@ -0,0 +1,81 @@
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;
/**
* Tour participant roster stores every eligible staff member for a tour setting.
* It is used for joined/not-joined statistics and links to tour_ledger only when the user has a signup record.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Table("tour_participant_roster")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("疗休养人员名单")
public class TourParticipantRoster 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("工号")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String jobNo;
@Column
@Comment("姓名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String userName;
@Column
@Comment("所在单位ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String unitId;
@Column
@Comment("所在单位")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String unitName;
@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.BOOLEAN)
@Default("0")
private Boolean joined;
@Column
@Comment("疗休养台账ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String ledgerId;
}
@@ -1,7 +1,101 @@
package com.budwk.app.zhgh.dayofficework.tour.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import org.nutz.dao.Cnd;
import org.nutz.lang.util.NutMap;
import java.util.List;
public interface TourLedgerService extends BaseService<TourLedger> {
/**
* 按查询条件获取疗休养台账中已分配的成团编码和成团名称,返回值已按成团信息去重。
*
* @param cnd 已包含事项、线路、台账状态和数据权限的查询条件
* @return 去重后的成团信息列表
*/
List<NutMap> queryGroupOptions(Cnd cnd);
/**
* 按疗休养台账页面的完整查询条件获取导出数据,不进行分页。
*
* @param cnd 台账查询条件,包含年度范围、姓名或工号、所属工会、线路、出行时段、线路类型、
* 直系亲属单位线路人员、超出费用由单位承担人员及审批完成状态限制
* @return 台账导出数据列表;每项包含年度、人员、线路、成团、领办工会、报名、家属及参加状态信息
*/
List<NutMap> queryExportRows(Cnd cnd);
/**
* 按事项最大成团人数,将当前事项下未成团的报名教职工及家属整体自动分配到成团中。
*
* @param matterId 疗休养事项ID
* @return 分配结果,包含成功提示或具体业务校验错误
*/
Result assignGroup(String matterId);
/**
* 将选中的教职工报名台账调整到指定成团中,并校验调整后目标团人数不超过事项最大成团人数。
*
* @param matterId 当前疗休养事项ID
* @param groupCode 目标成团编码
* @param ledgerIds 选中的教职工台账ID,多个ID使用逗号分隔
* @return 调整结果,包含成功提示或具体业务校验错误
*/
Result adjustGroup(String matterId, String groupCode, String ledgerIds);
/**
* 按事项和成团编码领办当前团,只回填当前团台账的领办工会信息,不修改事项表归属工会。
*
* @param matterId 当前疗休养事项ID
* @param groupCode 当前成团编码
* @param unionId 当前登录人所属工会编码
* @param unionName 当前登录人所属工会名称
* @return 领办结果,包含参数、重复领办和有效团数据校验
*/
Result undertakeGroup(String matterId, String groupCode, String unionId, String unionName);
/**
* 按事项和成团编码解除当前团领办,只清空当前团台账的领办工会信息。
*
* @param matterId 当前疗休养事项ID
* @param groupCode 当前成团编码
* @return 解除结果,包含参数和当前团是否已领办校验
*/
Result releaseUndertakeGroup(String matterId, String groupCode);
/**
* Locks the active yearly tour setting row before out-province quota calculation.
*
* @param year tour signup year that shares one out-province quota pool.
*/
void lockTourSignupQuotaYear(Integer year);
/**
* Locks the current tour matter row before matter-level signup capacity calculation.
*
* @param matterId tour matter id that owns the maximum signup capacity limit.
*/
void lockTourSignupMatter(String matterId);
/**
* Checks whether the current time is inside the effective tour signup window.
* Setting-level signup time keeps the existing priority and falls back to matter-level time when blank.
*
* @param matter selected tour matter that carries the fallback signup window.
* @param setting tour setting that carries the primary signup window.
* @return null when current time is allowed; otherwise a business error result.
*/
Result checkSignupTimeWindow(TourMatter matter, TourSetting setting);
/**
* Resolves the tour signup page order column and keeps frontend sorting within known list fields.
*
* @param orderName requested frontend sort property.
* @return safe SQL order column for the tour signup list.
*/
String getTourSignupOrderColumn(String orderName);
}
@@ -3,5 +3,17 @@ package com.budwk.app.zhgh.dayofficework.tour.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import java.util.List;
public interface TourMatterService extends BaseService<TourMatter> {
/**
* 按事项ID批量更新报名起止时间,仅处理报名时间字段。
*
* @param ids 当前查询条件命中的事项ID集合
* @param signupStartTime 报名开始时间
* @param signupEndTime 报名结束时间
* @return 实际更新数量
*/
int assignSignupTime(List<String> ids, String signupStartTime, String signupEndTime);
}
@@ -0,0 +1,57 @@
package com.budwk.app.zhgh.dayofficework.tour.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
import com.budwk.app.zhgh.dayofficework.tour.models.TourParticipantRoster;
import java.util.List;
/**
* 疗休养人员名单维护服务。
* 用于按配置维护可参与人员基础名单,并同步报名台账中的参加状态。
*/
public interface TourParticipantRosterService extends BaseService<TourParticipantRoster> {
/**
* 将一个疗休养配置的可参与人员同步到人员名单表。
*
* @param settingId 疗休养配置 id,配置中的可参与人员组别决定人员范围。
*/
void syncSettingRoster(String settingId);
/**
* 仅当配置下还没有人员名单数据时,同步配置可参与人员。
*
* @param settingId 需要懒加载人员名单的疗休养配置 id。
*/
void syncSettingRosterIfEmpty(String settingId);
/**
* 根据可参与人员范围和现有疗休养台账,重新生成所选配置的人员名单。
*
* @param settingIds 所选疗休养配置 id。
* @return 已不在可参与人员范围中但存在台账、因此无法剔除的人员提示。
*/
List<String> rebuildSettingRosters(List<String> settingIds);
/**
* 删除疗休养配置时清理对应人员名单。
*
* @param settingId 已删除的疗休养配置 id。
*/
void clearSettingRoster(String settingId);
/**
* 报名成功或修改报名后,将人员名单标记为已参加并关联最新台账。
*
* @param ledger 已创建或修改成功的报名台账。
*/
void markJoinedByLedger(TourLedger ledger);
/**
* 取消报名或删除报名后,将人员名单标记为未参加并清空关联台账。
*
* @param ledger 正在取消或删除的报名台账。
*/
void markNotJoinedByLedger(TourLedger ledger);
}
@@ -4,4 +4,11 @@ import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
public interface TourSettingService extends BaseService<TourSetting> {
/**
* 更新疗休养配置,并允许报名开始时间、报名结束时间被清空。
*
* @param tourSetting 页面提交的疗休养配置
*/
void updateWithNullableSignupTime(TourSetting tourSetting);
}
@@ -1,10 +1,32 @@
package com.budwk.app.zhgh.dayofficework.tour.service.impl;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@IocBean(args = {"refer:dao"})
public class TourLedgerServiceImpl extends BaseServiceImpl<TourLedger> implements TourLedgerService {
@@ -12,4 +34,769 @@ public class TourLedgerServiceImpl extends BaseServiceImpl<TourLedger> implement
public TourLedgerServiceImpl(Dao dao) {
super(dao);
}
/**
* 根据传入条件查询台账中已经存在的成团信息,按成团编码和成团名称去重后返回给页面展示。
*/
@Override
public List<NutMap> queryGroupOptions(Cnd cnd) {
Sql sql = Sqls.create("""
SELECT DISTINCT
t.groupCode,
t.groupName
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
ORDER BY t.groupCode ASC, t.groupName ASC
""");
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
return sql.getList(NutMap.class);
}
/**
* 查询符合台账页面筛选条件的全部导出数据,关联线路、事项和家属数据以保持导出结果与列表一致。
*
* @param cnd 已包含页面全部筛选项和审批完成状态限制的台账查询条件
* @return 未分页的台账导出数据列表,每项为一名教职工的一条有效台账记录
*/
@Override
public List<NutMap> queryExportRows(Cnd cnd) {
Sql sql = Sqls.create("""
SELECT
t.`year`,
t.jobNo,
t.userName,
t.unitName,
COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName,
CASE
WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> ''
THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime)
ELSE tp.travelPeriod
END AS travelPeriod,
t.lineType,
t.groupCode,
t.groupName,
t.undertakeUnionName,
t.signupTime,
IFNULL(f.familyCount, 0) AS familyCount,
t.joined,
t.overCostReimbursed
FROM tour_ledger t
LEFT JOIN wf_process_instance ins ON ins.businessNo = t.id
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0
LEFT JOIN tour_ledger_direct_relative dr ON dr.ledgerId = t.id AND dr.delFlag = 0
LEFT JOIN (
SELECT
`year`,
lineId,
GROUP_CONCAT(
DISTINCT CASE
WHEN IFNULL(travelStartTime, '') <> '' AND IFNULL(travelEndTime, '') <> ''
THEN CONCAT(travelStartTime, ' 至 ', travelEndTime)
ELSE NULL
END
ORDER BY travelStartTime ASC
SEPARATOR ';'
) AS travelPeriod
FROM tour_matter
WHERE delFlag = 0
AND lineId IS NOT NULL
AND lineId <> ''
GROUP BY `year`, lineId
) tp ON (t.matterId IS NULL OR t.matterId = '') AND tp.`year` = t.`year` AND tp.lineId = t.lineId
$condition
GROUP BY t.id
ORDER BY t.`year` DESC, t.signupTime DESC, t.createdAt DESC
""");
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
return sql.getList(NutMap.class);
}
/**
* 自动分配当前事项下未成团的教职工台账,分配过程中出现容量不合法时整体中止。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public Result assignGroup(String matterId) {
if (StrUtil.isBlank(matterId)) {
return Result.error("参数错误");
}
NutMap matter = fetchAssignMatter(matterId);
if (matter == null || matter.isEmpty()) {
return Result.error("事项不存在或线路信息不完整");
}
int maxGroupPeople = mapInt(matter, "maxGroupPeople");
if (maxGroupPeople <= 0) {
return Result.error("未配置最大成团人数,无法分配成团");
}
List<GroupBucket> groups = queryGroupBuckets(matterId);
List<SignupUnit> signupUnits = queryUnassignedSignupUnits(matterId);
if (signupUnits.isEmpty()) {
return Result.success("暂无未成团人员需要分配");
}
for (SignupUnit unit : signupUnits) {
if (unit.peopleCount > maxGroupPeople) {
return Result.error(unit.userName + "含家属共" + unit.peopleCount + "人,超过最大成团人数" + maxGroupPeople + "人,无法分配");
}
}
int nextGroupIndex = nextGroupIndex(groups);
int assignCount = 0;
for (SignupUnit unit : signupUnits) {
GroupBucket targetGroup = findAvailableGroup(groups, unit.peopleCount, maxGroupPeople);
if (targetGroup == null) {
targetGroup = createGroupBucket(nextGroupIndex++);
groups.add(targetGroup);
}
updateLedgerGroup(unit.ledgerId, targetGroup.groupCode, targetGroup.groupName);
targetGroup.peopleCount += unit.peopleCount;
assignCount++;
}
return Result.success("分配成功,共分配" + assignCount + "名教职工");
}
/**
* 将选中的教职工台账调整到目标团,家属随教职工一起计入目标团人数。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public Result adjustGroup(String matterId, String groupCode, String ledgerIds) {
if (StrUtil.isBlank(matterId) || StrUtil.isBlank(groupCode)) {
return Result.error("参数错误");
}
List<String> ledgerIdList = parseLedgerIds(ledgerIds);
if (ledgerIdList.isEmpty()) {
return Result.error("请选择需要调整的教职工");
}
NutMap matter = fetchAssignMatter(matterId);
if (matter == null || matter.isEmpty()) {
return Result.error("事项不存在或线路信息不完整");
}
int maxGroupPeople = mapInt(matter, "maxGroupPeople");
if (maxGroupPeople <= 0) {
return Result.error("未配置最大成团人数,无法调整成团");
}
NutMap targetGroup = fetchTargetGroup(matterId, groupCode);
if (targetGroup == null || targetGroup.isEmpty()) {
return Result.error("请选择有效的成团信息");
}
List<SignupUnit> selectedUnits = querySelectedSignupUnits(matterId, ledgerIdList);
if (selectedUnits.size() != ledgerIdList.size()) {
return Result.error("所选人员信息无效,请刷新后重试");
}
int selectedPeopleCount = selectedUnits.stream().mapToInt(unit -> unit.peopleCount).sum();
int targetPeopleCount = countTargetGroupPeople(matterId, groupCode, ledgerIdList);
if (targetPeopleCount + selectedPeopleCount > maxGroupPeople) {
return Result.error("调整后目标团共" + (targetPeopleCount + selectedPeopleCount)
+ "人,超过最大成团人数" + maxGroupPeople + "人,无法调整");
}
updateLedgerGroup(matterId, ledgerIdList, groupCode, targetGroup.getString("groupName", ""));
return Result.success("调整成功,共调整" + selectedUnits.size() + "名教职工");
}
/**
* 查询事项基础信息,最大成团人数是自动分配的核心容量上限。
*/
/**
* 当前领办以团为单位执行,写入范围限定为同一事项、同一成团编码下的有效台账。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public Result undertakeGroup(String matterId, String groupCode, String unionId, String unionName) {
if (StrUtil.isBlank(matterId) || StrUtil.isBlank(groupCode) || StrUtil.isBlank(unionId)) {
return Result.error("参数错误");
}
NutMap group = fetchUndertakeGroup(matterId, groupCode);
if (group == null || group.isEmpty()) {
return Result.error("当前团不存在或未成团,无法领办");
}
if (StrUtil.isNotBlank(group.getString("undertakeUnionId", ""))) {
return Result.error("当前团已领办,不允许修改");
}
Cnd cnd = buildUndertakeGroupCnd(matterId, groupCode);
Chain chain = Chain.make("undertakeUnionId", unionId)
.add("undertakeUnionName", StrUtil.blankToDefault(unionName, ""));
dao().update(TourLedger.class, chain, cnd);
return Result.success(NutMap.NEW().addv("unionId", unionId).addv("unionName", StrUtil.blankToDefault(unionName, "")));
}
/**
* 解除领办同样按团处理,仅清空当前团台账领办字段,保留事项表原有归属信息。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public Result releaseUndertakeGroup(String matterId, String groupCode) {
if (StrUtil.isBlank(matterId) || StrUtil.isBlank(groupCode)) {
return Result.error("参数错误");
}
NutMap group = fetchUndertakeGroup(matterId, groupCode);
if (group == null || group.isEmpty()) {
return Result.error("当前团不存在或未成团,无法解除");
}
if (StrUtil.isBlank(group.getString("undertakeUnionId", ""))) {
return Result.error("当前团未领办,无需解除");
}
Cnd cnd = buildUndertakeGroupCnd(matterId, groupCode);
dao().update(TourLedger.class, Chain.make("undertakeUnionId", "").add("undertakeUnionName", ""), cnd);
return Result.success();
}
/**
* Locks the yearly setting row so concurrent out-province signup checks read updated quota counts one by one.
*/
@Override
public void lockTourSignupQuotaYear(Integer year) {
if (year == null) {
return;
}
// MySQL/InnoDB holds this row lock until the current signup transaction commits.
Sql sql = Sqls.create("""
SELECT id
FROM tour_setting
WHERE delFlag = 0
AND `year` = @year
ORDER BY id
FOR UPDATE
""");
sql.setParam("year", year);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
}
/**
* Locks one matter row so concurrent submissions for the same travel period check capacity one by one.
*/
@Override
public void lockTourSignupMatter(String matterId) {
if (StrUtil.isBlank(matterId)) {
return;
}
// MySQL/InnoDB holds this matter row lock until the current signup transaction commits.
Sql sql = Sqls.create("""
SELECT id
FROM tour_matter
WHERE delFlag = 0
AND id = @matterId
FOR UPDATE
""");
sql.setParam("matterId", matterId);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
}
/**
* Checks the current time against the effective signup window used by tour signup and cancellation.
*/
@Override
public Result checkSignupTimeWindow(TourMatter matter, TourSetting setting) {
if (matter == null || setting == null) {
return Result.error("报名事项配置不存在或已停用");
}
String signupStartTime = StrUtil.blankToDefault(setting.getSignupStartTime(), matter.getSignupStartTime());
String signupEndTime = StrUtil.blankToDefault(setting.getSignupEndTime(), matter.getSignupEndTime());
if (StrUtil.isBlank(signupStartTime) || StrUtil.isBlank(signupEndTime)) {
return Result.error("报名时间未配置");
}
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
if (now.compareTo(normalizeSignupDateTime(signupStartTime, false)) < 0) {
return Result.error("报名未开始");
}
if (now.compareTo(normalizeSignupDateTime(signupEndTime, true)) > 0) {
return Result.error("报名已结束");
}
return null;
}
/**
* Normalizes date-only signup values so the end date includes the whole day.
*/
private String normalizeSignupDateTime(String value, boolean endOfDay) {
String text = StrUtil.blankToDefault(value, "").trim();
if (text.length() == 10) {
return text + (endOfDay ? " 23:59:59" : " 00:00:00");
}
return text;
}
/**
* Keeps controller SQL ordering safe by resolving only known list fields.
*/
@Override
public String getTourSignupOrderColumn(String orderName) {
if ("lineCode".equals(orderName)) {
return "CAST(l.lineCode AS UNSIGNED)";
}
if ("lineName".equals(orderName)) {
return "l.lineName";
}
if ("travelPeriod".equals(orderName)) {
return "m.travelStartTime";
}
if ("matterName".equals(orderName)) {
return "m.matterName";
}
if ("unionName".equals(orderName)) {
return "u.name";
}
if ("lineType".equals(orderName)) {
return "l.lineType";
}
if ("minGroupPeople".equals(orderName)) {
return "m.minGroupPeople";
}
if ("maxGroupPeople".equals(orderName)) {
return "m.maxGroupPeople";
}
if ("maxSignupPeople".equals(orderName)) {
return "m.maxSignupPeople";
}
if ("signupCount".equals(orderName)) {
return "signupCount";
}
return "CAST(l.lineCode AS UNSIGNED)";
}
private NutMap fetchAssignMatter(String matterId) {
Cnd cnd = buildAssignMatterCnd(matterId);
Sql sql = Sqls.create("""
SELECT
m.id,
m.maxGroupPeople
FROM tour_matter m
INNER JOIN tour_line l ON l.id = m.lineId
$condition
""");
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
return sql.getObject(NutMap.class);
}
/**
* 查询当前事项下已存在的目标成团信息,人员调整只能选择当前线路台账中已有的团。
*/
private NutMap fetchTargetGroup(String matterId, String groupCode) {
Sql sql = Sqls.create("""
SELECT
t.groupCode,
MAX(t.groupName) AS groupName
FROM tour_ledger t
WHERE t.delFlag = 0
AND t.matterId = @matterId
AND t.groupCode = @groupCode
GROUP BY t.groupCode
""");
sql.setParam("matterId", matterId);
sql.setParam("groupCode", groupCode);
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
return sql.getObject(NutMap.class);
}
/**
* 构造自动分配事项查询条件,确保自动分配的数据范围与线路成团查看列表保持一致。
*/
/**
* 查询当前事项下指定团的领办字段,供领办和解除领办前置校验使用。
*/
private NutMap fetchUndertakeGroup(String matterId, String groupCode) {
Cnd cnd = buildAssignMatterCnd(matterId);
cnd.and("t.delFlag", "=", false);
cnd.and("t.groupCode", "=", groupCode);
Sql sql = Sqls.create("""
SELECT
t.groupCode,
MAX(t.groupName) AS groupName,
MAX(t.undertakeUnionId) AS undertakeUnionId,
MAX(t.undertakeUnionName) AS undertakeUnionName
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId
INNER JOIN tour_line l ON l.id = m.lineId
$condition
GROUP BY t.groupCode
""");
sql.setCondition(cnd);
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
return sql.getObject(NutMap.class);
}
/**
* 构造当前团领办字段更新范围,避免同一事项下其它团被误更新。
*/
private Cnd buildUndertakeGroupCnd(String matterId, String groupCode) {
return Cnd.where("matterId", "=", matterId)
.and("groupCode", "=", groupCode)
.and("delFlag", "=", false);
}
private Cnd buildAssignMatterCnd(String matterId) {
Cnd cnd = Cnd.NEW();
cnd.and("m.delFlag", "=", false);
cnd.and("m.enabled", "=", true);
cnd.and("m.id", "=", matterId);
cnd.and("m.lineId", "IS NOT", null);
cnd.and("m.lineId", "<>", "");
cnd.and("l.delFlag", "=", false);
cnd.and("l.enabled", "=", true);
applyMatterDataScope(cnd);
return cnd;
}
/**
* 查询已存在成团及当前占用人数,后续未成团人员优先从最早团开始补足。
*/
private List<GroupBucket> queryGroupBuckets(String matterId) {
Sql sql = Sqls.create("""
SELECT
t.groupCode,
MAX(t.groupName) AS groupName,
SUM(1 + IFNULL(f.familyCount, 0)) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
AND t.groupCode IS NOT NULL
AND t.groupCode <> ''
GROUP BY t.groupCode
ORDER BY t.groupCode ASC
""");
sql.setParam("matterId", matterId);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
List<GroupBucket> groups = new ArrayList<>();
for (NutMap item : sql.getList(NutMap.class)) {
GroupBucket group = new GroupBucket();
group.groupCode = item.getString("groupCode", "");
group.groupName = item.getString("groupName", "");
group.peopleCount = mapInt(item, "peopleCount");
groups.add(group);
}
return groups;
}
/**
* 查询未成团教职工台账,并合并家属人数,保证教职工和家属作为一个整体分配。
*/
private List<SignupUnit> queryUnassignedSignupUnits(String matterId) {
Sql sql = Sqls.create("""
SELECT
t.id AS ledgerId,
t.userName,
1 + IFNULL(f.familyCount, 0) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
AND (t.groupCode IS NULL OR t.groupCode = '')
ORDER BY t.signupTime ASC, t.createdAt ASC
""");
sql.setParam("matterId", matterId);
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
List<SignupUnit> signupUnits = new ArrayList<>();
for (NutMap item : sql.getList(NutMap.class)) {
SignupUnit unit = new SignupUnit();
unit.ledgerId = item.getString("ledgerId", "");
unit.userName = item.getString("userName", "");
unit.peopleCount = mapInt(item, "peopleCount");
signupUnits.add(unit);
}
return signupUnits;
}
/**
* 查询选中教职工台账及其家属人数,确保只调整当前事项下有效的教职工报名记录。
*/
private List<SignupUnit> querySelectedSignupUnits(String matterId, List<String> ledgerIds) {
Sql sql = Sqls.create("""
SELECT
t.id AS ledgerId,
t.userName,
1 + IFNULL(f.familyCount, 0) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
AND t.id IN (@ledgerIds)
ORDER BY t.signupTime ASC, t.createdAt ASC
""");
sql.setParam("matterId", matterId);
sql.setParam("ledgerIds", ledgerIds.toArray(new String[0]));
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
List<SignupUnit> signupUnits = new ArrayList<>();
for (NutMap item : sql.getList(NutMap.class)) {
SignupUnit unit = new SignupUnit();
unit.ledgerId = item.getString("ledgerId", "");
unit.userName = item.getString("userName", "");
unit.peopleCount = mapInt(item, "peopleCount");
signupUnits.add(unit);
}
return signupUnits;
}
/**
* 统计目标团调整前人数,排除本次选中人员,避免原本已在目标团的人员被重复计数。
*/
private int countTargetGroupPeople(String matterId, String groupCode, List<String> excludeLedgerIds) {
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(1 + IFNULL(f.familyCount, 0)), 0) AS peopleCount
FROM tour_ledger t
LEFT JOIN (
SELECT ledgerId, COUNT(1) AS familyCount
FROM tour_ledger_family
WHERE delFlag = 0
GROUP BY ledgerId
) f ON f.ledgerId = t.id
WHERE t.delFlag = 0
AND t.matterId = @matterId
AND t.groupCode = @groupCode
AND t.id NOT IN (@excludeLedgerIds)
""");
sql.setParam("matterId", matterId);
sql.setParam("groupCode", groupCode);
sql.setParam("excludeLedgerIds", excludeLedgerIds.toArray(new String[0]));
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
return mapInt(sql.getObject(NutMap.class), "peopleCount");
}
/**
* 从最早的团开始查找可容纳当前报名单位的团,避免前面团未满但后面新建团的情况。
*/
private GroupBucket findAvailableGroup(List<GroupBucket> groups, int peopleCount, int maxGroupPeople) {
for (GroupBucket group : groups) {
if (group.peopleCount + peopleCount <= maxGroupPeople) {
return group;
}
}
return null;
}
/**
* 根据现有最大团序号生成下一个团序号,兼容已有非标准团编码。
*/
private int nextGroupIndex(List<GroupBucket> groups) {
int maxIndex = 0;
for (GroupBucket group : groups) {
maxIndex = Math.max(maxIndex, parseGroupIndex(group.groupCode));
}
return maxIndex + 1;
}
/**
* 创建新的成团桶,编码使用T001递增,名称使用中文序号加“团”。
*/
private GroupBucket createGroupBucket(int index) {
GroupBucket group = new GroupBucket();
group.groupCode = String.format("T%03d", index);
group.groupName = toChineseNumber(index) + "团";
group.peopleCount = 0;
return group;
}
/**
* 自动分配按事项归属控制数据范围:校工会查看全部;分工会查看本工会事项;其它角色仅处理自己创建的事项。
*/
private void applyMatterDataScope(Cnd cnd) {
if (hasSchoolUnionScope()
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
|| hasTourApplicantScope()) {
return;
}
if (hasBranchUnionScope()) {
cnd.and("m.unionId", "=", SecurityUtil.getUnionId());
return;
}
SqlExpressionGroup creatorGroup = new SqlExpressionGroup();
creatorGroup.or("m.creatorUserId", "=", SecurityUtil.getUserId());
creatorGroup.or("m.createdBy", "=", SecurityUtil.getUserId());
cnd.and(creatorGroup);
}
/**
* 判断当前用户是否具备校工会级别的事项处理范围。
*/
private boolean hasSchoolUnionScope() {
return AuthUtil.hasRole(RoleConstant.SYSADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_CHAIRMAN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_VICE_CHAIRMAN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_WELFARE_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_MEMBER_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_TC_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_WC_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_DC_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_PROPOSAL_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_CLUB_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name());
}
/**
* 疗休养领办人需要与领办列表保持一致,可在领办前查询到全部成团数据。
*/
private boolean hasTourApplicantScope() {
return AuthUtil.hasRole(RoleConstant.TOUR_APPLICANT.name());
}
/**
* 判断当前用户是否具备分工会级别的事项处理范围。
*/
private boolean hasBranchUnionScope() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_VICE_CHAIRMAN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_OPERATOR.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_GROUP_LEADER.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ARTICLE_WRITER.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ZUZHI_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_XUANCHUAN_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_NVGONG_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_QINGNIAN_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_WENTI_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_SHENGGHUO_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_TIAOJIE_WY.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_WENTI_SPORTS.name());
}
/**
* 更新教职工台账成团信息,家属行展示时继承教职工台账的成团信息。
*/
private void updateLedgerGroup(String ledgerId, String groupCode, String groupName) {
dao().update(TourLedger.class, Chain.make("groupCode", groupCode).add("groupName", groupName), Cnd.where("id", "=", ledgerId));
}
/**
* 批量更新选中的教职工台账成团信息,人员调整只写教职工台账,家属展示继续继承教职工。
*/
private void updateLedgerGroup(String matterId, List<String> ledgerIds, String groupCode, String groupName) {
Cnd cnd = Cnd.where("id", "in", ledgerIds)
.and("matterId", "=", matterId)
.and("delFlag", "=", false);
dao().update(TourLedger.class, Chain.make("groupCode", groupCode).add("groupName", groupName), cnd);
}
/**
* 解析前端逗号分隔的台账ID,去重并过滤空值,避免重复计数和重复更新。
*/
private List<String> parseLedgerIds(String ledgerIds) {
Set<String> idSet = new LinkedHashSet<>();
if (StrUtil.isBlank(ledgerIds)) {
return new ArrayList<>();
}
for (String id : ledgerIds.split(",")) {
if (StrUtil.isNotBlank(id)) {
idSet.add(id.trim());
}
}
return new ArrayList<>(idSet);
}
/**
* 解析T001这类标准团编码中的数字序号,非标准编码返回0。
*/
private int parseGroupIndex(String groupCode) {
if (StrUtil.isBlank(groupCode) || !groupCode.startsWith("T")) {
return 0;
}
try {
return Integer.parseInt(groupCode.substring(1));
} catch (NumberFormatException e) {
return 0;
}
}
/**
* 将数字序号转为中文团名序号,支持常见的两位和多位团序号展示。
*/
private String toChineseNumber(int number) {
String[] digits = {"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"};
if (number <= 0) {
return String.valueOf(number);
}
if (number < 10) {
return "第" + digits[number];
}
if (number < 100) {
int ten = number / 10;
int one = number % 10;
String prefix = ten == 1 ? "十" : digits[ten] + "十";
return "第" + prefix + (one == 0 ? "" : digits[one]);
}
String value = String.valueOf(number);
StringBuilder builder = new StringBuilder("第");
for (int i = 0; i < value.length(); i++) {
builder.append(digits[value.charAt(i) - '0']);
}
return builder.toString();
}
/**
* 安全读取SQL返回的整数值,避免不同数据库驱动返回Number或字符串时转换失败。
*/
private int mapInt(NutMap map, String key) {
Object value = map == null ? null : map.get(key);
if (value instanceof Number number) {
return number.intValue();
}
if (value == null || StrUtil.isBlank(String.valueOf(value))) {
return 0;
}
return Integer.parseInt(String.valueOf(value));
}
/**
* 已有成团容量桶,记录每个团当前人数以及后续分配后的占用人数。
*/
private static class GroupBucket {
private String groupCode;
private String groupName;
private int peopleCount;
}
/**
* 待分配报名单位,教职工及其家属作为不可拆分的最小分配单位。
*/
private static class SignupUnit {
private String ledgerId;
private String userName;
private int peopleCount;
}
}
@@ -3,13 +3,35 @@ package com.budwk.app.zhgh.dayofficework.tour.service.impl;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import com.budwk.app.zhgh.dayofficework.tour.service.TourMatterService;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.List;
@IocBean(args = {"refer:dao"})
public class TourMatterServiceImpl extends BaseServiceImpl<TourMatter> implements TourMatterService {
public TourMatterServiceImpl(Dao dao) {
super(dao);
}
/**
* 按事项ID批量更新报名起止时间,供事项列表“一键赋值报名时间”使用。
*
* @param ids 当前查询条件命中的事项ID集合
* @param signupStartTime 报名开始时间
* @param signupEndTime 报名结束时间
* @return 实际更新数量
*/
@Override
public int assignSignupTime(List<String> ids, String signupStartTime, String signupEndTime) {
if (ids == null || ids.isEmpty()) {
return 0;
}
return dao().update(TourMatter.class,
Chain.make("signupStartTime", signupStartTime).add("signupEndTime", signupEndTime),
Cnd.where("id", "in", ids));
}
}
@@ -0,0 +1,358 @@
package com.budwk.app.zhgh.dayofficework.tour.service.impl;
import cn.hutool.core.collection.CollUtil;
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.activity.basic.service.ActivityBasicScopeService;
import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger;
import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter;
import com.budwk.app.zhgh.dayofficework.tour.models.TourParticipantRoster;
import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 疗休养人员名单维护服务实现。
* 负责同步配置可参与人员、回填台账参加状态,并保留已有台账人员的历史统计口径。
*/
@IocBean(args = {"refer:dao"})
public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourParticipantRoster> implements TourParticipantRosterService {
private static final int USER_QUERY_BATCH_SIZE = 500;
@Inject
private ActivityBasicScopeService activityBasicScopeService;
public TourParticipantRosterServiceImpl(Dao dao) {
super(dao);
}
/**
* 读取配置可参与人员组别,并写入或刷新人员名单基础信息。
*/
@Override
public void syncSettingRoster(String settingId) {
if (StrUtil.isBlank(settingId)) {
return;
}
TourSetting setting = dao().fetch(TourSetting.class, settingId);
if (setting == null) {
return;
}
for (View_user user : listEligibleUsers(setting)) {
upsertRosterUser(settingId, user);
}
}
/**
* 配置普通保存时只做懒加载:配置下已有名单数据则不重新拉取人员。
*/
@Override
public void syncSettingRosterIfEmpty(String settingId) {
if (StrUtil.isBlank(settingId)) {
return;
}
int rosterCount = count(Cnd.where("settingId", "=", settingId));
if (rosterCount > 0) {
return;
}
syncSettingRoster(settingId);
}
/**
* 在一个事务内重建人员名单:删除旧名单、重载当前可参与人员、回放有效台账参加状态。
* 若台账人员已不在当前可参与人员中,仍保留在名单中,并返回提示给页面展示。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public List<String> rebuildSettingRosters(List<String> settingIds) {
List<String> warningMessages = new ArrayList<>();
if (CollUtil.isEmpty(settingIds)) {
return warningMessages;
}
for (String settingId : settingIds) {
if (StrUtil.isBlank(settingId)) {
continue;
}
TourSetting setting = dao().fetch(TourSetting.class, settingId);
if (setting == null) {
continue;
}
List<View_user> eligibleUsers = listEligibleUsers(setting);
Set<String> eligibleJobNos = collectEligibleJobNos(eligibleUsers);
clear(Cnd.where("settingId", "=", settingId));
for (View_user user : eligibleUsers) {
upsertRosterUser(settingId, user);
}
List<String> retainedUserNames = replayLedgerJoinedState(settingId, eligibleJobNos);
if (CollUtil.isNotEmpty(retainedUserNames)) {
warningMessages.add(buildRetainedLedgerUserMessage(setting, retainedUserNames));
}
}
return warningMessages;
}
/**
* 删除配置下的人员名单,避免已删除配置继续参与统计查询。
*/
@Override
public void clearSettingRoster(String settingId) {
if (StrUtil.isBlank(settingId)) {
return;
}
clear(Cnd.where("settingId", "=", settingId));
}
/**
* 报名成功和修改报名都会将人员名单标记为已参加,并指向当前有效台账。
*/
@Override
public void markJoinedByLedger(TourLedger ledger) {
String settingId = resolveSettingId(ledger);
if (StrUtil.isBlank(settingId) || ledger == null || StrUtil.isBlank(ledger.getJobNo())) {
return;
}
TourParticipantRoster roster = fetchRoster(settingId, ledger.getJobNo());
if (roster == null) {
roster = new TourParticipantRoster();
roster.setSettingId(settingId);
roster.setJobNo(ledger.getJobNo());
roster.setJoined(true);
fillRosterFromLedger(roster, ledger);
insert(roster);
return;
}
Chain chain = Chain.make("joined", true)
.add("ledgerId", StrUtil.blankToDefault(ledger.getId(), ""))
.add("userName", StrUtil.blankToDefault(ledger.getUserName(), roster.getUserName()))
.add("unitId", StrUtil.blankToDefault(ledger.getUnitId(), roster.getUnitId()))
.add("unitName", StrUtil.blankToDefault(ledger.getUnitName(), roster.getUnitName()))
.add("unionId", StrUtil.blankToDefault(ledger.getUnionId(), roster.getUnionId()))
.add("unionName", StrUtil.blankToDefault(ledger.getUnionName(), roster.getUnionName()));
dao().update(TourParticipantRoster.class, chain, Cnd.where("id", "=", roster.getId()));
}
/**
* 取消报名只重置当前人员名单状态,历史台账删除记录仍保留在 tour_ledger 中。
*/
@Override
public void markNotJoinedByLedger(TourLedger ledger) {
String settingId = resolveSettingId(ledger);
if (StrUtil.isBlank(settingId) || ledger == null || StrUtil.isBlank(ledger.getJobNo())) {
return;
}
TourParticipantRoster roster = fetchRoster(settingId, ledger.getJobNo());
if (roster == null) {
roster = new TourParticipantRoster();
roster.setSettingId(settingId);
roster.setJobNo(ledger.getJobNo());
roster.setJoined(false);
fillRosterFromLedger(roster, ledger);
roster.setLedgerId("");
insert(roster);
return;
}
dao().update(TourParticipantRoster.class,
Chain.make("joined", false).add("ledgerId", ""),
Cnd.where("id", "=", roster.getId()));
}
/**
* 将配置中以文本保存的可参与人员组别 id 转换为人员范围服务需要的整数。
*/
private Integer parseGroupId(String activityGroupId) {
try {
return Integer.parseInt(activityGroupId);
} catch (NumberFormatException e) {
return null;
}
}
/**
* 根据配置的可参与人员组别加载人员基础数据。
* 这里集中处理空配置和无效组别,方便同步和重建流程复用同一口径。
*/
private List<View_user> listEligibleUsers(TourSetting setting) {
if (setting == null || StrUtil.isBlank(setting.getActivityGroupId())) {
return new ArrayList<>();
}
Integer groupId = parseGroupId(setting.getActivityGroupId());
if (groupId == null) {
return new ArrayList<>();
}
List<String> userIds = activityBasicScopeService.listGroupUserIds(groupId);
if (CollUtil.isEmpty(userIds)) {
return new ArrayList<>();
}
List<View_user> users = new ArrayList<>();
for (View_user user : queryUsers(userIds)) {
if (user != null && StrUtil.isNotBlank(user.getLoginname())) {
users.add(user);
}
}
return users;
}
/**
* 提取当前可参与人员工号集合,用于判断已有台账人员是否已被移出可参与范围。
*/
private Set<String> collectEligibleJobNos(List<View_user> users) {
Set<String> jobNos = new HashSet<>();
if (CollUtil.isEmpty(users)) {
return jobNos;
}
for (View_user user : users) {
if (user != null && StrUtil.isNotBlank(user.getLoginname())) {
jobNos.add(user.getLoginname());
}
}
return jobNos;
}
/**
* 分批查询用户视图,避免大范围人员组别生成过长的 SQL IN 条件。
*/
private List<View_user> queryUsers(List<String> userIds) {
List<View_user> users = new ArrayList<>();
for (int i = 0; i < userIds.size(); i += USER_QUERY_BATCH_SIZE) {
int end = Math.min(i + USER_QUERY_BATCH_SIZE, userIds.size());
List<String> batch = userIds.subList(i, end);
users.addAll(dao().query(View_user.class, Cnd.where("id", "in", batch)));
}
return users;
}
/**
* 新增可参与人员,或刷新已存在人员的基础组织信息。
*/
private void upsertRosterUser(String settingId, View_user user) {
TourParticipantRoster roster = fetchRoster(settingId, user.getLoginname());
if (roster == null) {
roster = new TourParticipantRoster();
roster.setSettingId(settingId);
roster.setJobNo(user.getLoginname());
roster.setJoined(false);
fillRosterFromUser(roster, user);
insert(roster);
return;
}
dao().update(TourParticipantRoster.class,
Chain.make("userName", StrUtil.blankToDefault(user.getUsername(), ""))
.add("unitId", StrUtil.blankToDefault(user.getUnitId(), ""))
.add("unitName", StrUtil.blankToDefault(user.getUnitName(), ""))
.add("unionId", StrUtil.blankToDefault(user.getUnionId(), ""))
.add("unionName", StrUtil.blankToDefault(user.getUnionName(), "")),
Cnd.where("id", "=", roster.getId()));
}
/**
* 回放配置下有效疗休养台账,使重建后的名单保持当前报名状态。
* 对已不在可参与人员中但存在台账的人员,继续保留名单记录并返回人员名称用于页面提示。
*/
private List<String> replayLedgerJoinedState(String settingId, Set<String> eligibleJobNos) {
Set<String> retainedUserNames = new LinkedHashSet<>();
Sql sql = Sqls.create("""
SELECT t.*
FROM tour_ledger t
INNER JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0
WHERE t.delFlag = 0
AND m.settingId = @settingId
AND IFNULL(t.jobNo, '') <> ''
ORDER BY t.signupTime DESC, t.createdAt DESC
""");
sql.setParam("settingId", settingId);
sql.setEntity(dao().getEntity(TourLedger.class));
sql.setCallback(Sqls.callback.entities());
dao().execute(sql);
Set<String> handledJobNos = new HashSet<>();
for (TourLedger ledger : sql.getList(TourLedger.class)) {
if (ledger == null || StrUtil.isBlank(ledger.getJobNo()) || handledJobNos.contains(ledger.getJobNo())) {
continue;
}
handledJobNos.add(ledger.getJobNo());
if (eligibleJobNos == null || !eligibleJobNos.contains(ledger.getJobNo())) {
retainedUserNames.add(displayLedgerUserName(ledger));
}
markJoinedByLedger(ledger);
}
return new ArrayList<>(retainedUserNames);
}
/**
* 按配置和工号查找有效人员名单记录,这两个字段是业务逻辑唯一键。
*/
private TourParticipantRoster fetchRoster(String settingId, String jobNo) {
return fetch(Cnd.where("settingId", "=", settingId)
.and("jobNo", "=", jobNo)
.and("delFlag", "=", false));
}
/**
* 通过台账关联的事项解析配置 id,因为 tour_ledger 本身不直接保存 settingId。
*/
private String resolveSettingId(TourLedger ledger) {
if (ledger == null || StrUtil.isBlank(ledger.getMatterId())) {
return "";
}
TourMatter matter = dao().fetch(TourMatter.class, ledger.getMatterId());
return matter == null ? "" : matter.getSettingId();
}
/**
* 从用户视图复制人员基础信息到名单记录。
*/
private void fillRosterFromUser(TourParticipantRoster roster, View_user user) {
roster.setUserName(StrUtil.blankToDefault(user.getUsername(), ""));
roster.setUnitId(StrUtil.blankToDefault(user.getUnitId(), ""));
roster.setUnitName(StrUtil.blankToDefault(user.getUnitName(), ""));
roster.setUnionId(StrUtil.blankToDefault(user.getUnionId(), ""));
roster.setUnionName(StrUtil.blankToDefault(user.getUnionName(), ""));
}
/**
* 当名单同步尚未创建该人员时,从台账复制人员和组织信息补入名单。
*/
private void fillRosterFromLedger(TourParticipantRoster roster, TourLedger ledger) {
roster.setUserName(StrUtil.blankToDefault(ledger.getUserName(), ""));
roster.setUnitId(StrUtil.blankToDefault(ledger.getUnitId(), ""));
roster.setUnitName(StrUtil.blankToDefault(ledger.getUnitName(), ""));
roster.setUnionId(StrUtil.blankToDefault(ledger.getUnionId(), ""));
roster.setUnionName(StrUtil.blankToDefault(ledger.getUnionName(), ""));
roster.setLedgerId(StrUtil.blankToDefault(ledger.getId(), ""));
}
/**
* 构造“有台账但已不在可参与人员范围中”的配置级提示。
*/
private String buildRetainedLedgerUserMessage(TourSetting setting, List<String> userNames) {
return StrUtil.blankToDefault(setting.getConfigName(), "未命名配置")
+ "(" + setting.getYear() + "):【" + String.join("、", userNames)
+ "】已不在“可参与人员”中,但已生成台账,无法剔除这类人员";
}
/**
* 优先显示台账姓名,姓名为空时使用工号,保证提示中始终能定位到具体人员。
*/
private String displayLedgerUserName(TourLedger ledger) {
if (StrUtil.isNotBlank(ledger.getUserName())) {
return ledger.getUserName();
}
return StrUtil.blankToDefault(ledger.getJobNo(), "未知人员");
}
}
@@ -1,8 +1,11 @@
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.zhgh.dayofficework.tour.models.TourSetting;
import com.budwk.app.zhgh.dayofficework.tour.service.TourSettingService;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -12,4 +15,19 @@ public class TourSettingServiceImpl extends BaseServiceImpl<TourSetting> impleme
public TourSettingServiceImpl(Dao dao) {
super(dao);
}
/**
* 更新疗休养配置,并对报名时间做显式空值覆盖。
*
* @param tourSetting 页面提交的疗休养配置
*/
@Override
public void updateWithNullableSignupTime(TourSetting tourSetting) {
updateIgnoreNull(tourSetting);
// updateIgnoreNull 会忽略 null;报名时间允许清空时,需要额外将空值覆盖到数据库。
dao().update(TourSetting.class,
Chain.make("signupStartTime", StrUtil.blankToDefault(tourSetting.getSignupStartTime(), ""))
.add("signupEndTime", StrUtil.blankToDefault(tourSetting.getSignupEndTime(), "")),
Cnd.where(TourSetting::getId, "=", tourSetting.getId()));
}
}
@@ -31,7 +31,7 @@ import javax.validation.Valid;
@At("/platform/TMap")
public class TMapController {
private static String KEY = "MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH";
private static String KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU";
@Inject
private SysConfigService configService;
@@ -99,10 +99,13 @@ public class DifficultHelpMineController {
""");
Cnd cnd = Cnd.NEW();
pageParam.buildSearch(cnd, "info.");
if (StrUtil.isAllBlank(pageParam.getPageOrderName(), pageParam.getPageOrderBy())) {
// 排序字段必须经过 service 白名单校验,避免前端 pageOrderName 直接进入 ORDER BY。
String orderColumn = difficultHelpCommonService.getSafeMineOrderColumn(pageParam.getPageOrderName());
String orderBy = PageUtil.getOrder(pageParam.getPageOrderBy());
if (StrUtil.isBlank(orderColumn) || StrUtil.isBlank(orderBy)) {
cnd.desc("info.applyTime");
} else {
cnd.orderBy("info." + pageParam.getPageOrderName(), PageUtil.getOrder(pageParam.getPageOrderBy()));
cnd.orderBy(orderColumn, orderBy);
}
cnd.groupBy("info.id");
sql.setCondition(cnd);
@@ -11,4 +11,12 @@ import com.budwk.app.zhgh.staffbenefit.difficulthelp.models.DifficultHelpInfo;
* @注释
*/
public interface DifficultHelpCommonService extends BaseService<DifficultHelpInfo> {
/**
* 校验困难帮扶“我的申请”列表排序字段,并返回可用于 SQL 排序的安全字段名。
*
* @param pageOrderName 前端传入的排序字段
* @return 白名单内的 SQL 排序字段;不在白名单时返回空字符串
*/
String getSafeMineOrderColumn(String pageOrderName);
}
@@ -1,5 +1,6 @@
package com.budwk.app.zhgh.staffbenefit.difficulthelp.service.impl;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.models.DifficultHelpInfo;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.service.DifficultHelpCommonService;
@@ -19,4 +20,28 @@ public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHel
public DifficultHelpCommonServiceImpl(Dao dao) {
super(dao);
}
/**
* 校验困难帮扶“我的申请”列表排序字段,避免前端参数直接拼接到 ORDER BY。
*
* @param pageOrderName 前端传入的排序字段
* @return 白名单内的 SQL 排序字段;不在白名单时返回空字符串
*/
@Override
public String getSafeMineOrderColumn(String pageOrderName) {
if (StrUtil.isBlank(pageOrderName)) {
return "";
}
// 仅允许页面表格中存在的可排序字段参与 ORDER BY,未知字段交由 controller 回退默认排序。
return switch (pageOrderName) {
case "loginName" -> "info.loginName";
case "userName" -> "info.userName";
case "unionName" -> "info.unionName";
case "unitName" -> "info.unitName";
case "subsidyStandards" -> "info.subsidyStandards";
case "applyTime" -> "info.applyTime";
default -> "";
};
}
}
@@ -108,10 +108,13 @@ public class MemberApplyMineController {
cnd.and("info.userId", "=", SecurityUtil.getUserId());
}
}
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
// 排序字段必须经过 service 白名单校验,避免前端 pageOrderName 直接进入 ORDER BY。
String orderColumn = memberCommonService.getSafeMineOrderColumn(pageForm.getPageOrderName());
String orderBy = PageUtil.getOrder(pageForm.getPageOrderBy());
if (StrUtil.isBlank(orderColumn) || StrUtil.isBlank(orderBy)) {
cnd.desc("info.applyDateTime");
} else {
cnd.orderBy("info." + pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy()));
cnd.orderBy(orderColumn, orderBy);
}
cnd.groupBy("t.id");
sql.setCondition(cnd);
@@ -111,10 +111,13 @@ public class MemberChangeMineController {
cnd.and("info.userId", "=", SecurityUtil.getUserId());
}
}
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
// 排序字段必须经过 service 白名单校验,避免前端 pageOrderName 直接进入 ORDER BY。
String orderColumn = memberCommonService.getSafeMineOrderColumn(pageForm.getPageOrderName());
String orderBy = PageUtil.getOrder(pageForm.getPageOrderBy());
if (StrUtil.isBlank(orderColumn) || StrUtil.isBlank(orderBy)) {
cnd.desc("info.applyDateTime");
} else {
cnd.orderBy("info." + pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy()));
cnd.orderBy(orderColumn, orderBy);
}
cnd.groupBy("t.id");
sql.setCondition(cnd);
@@ -115,4 +115,12 @@ public interface MemberCommonService extends BaseService<Sys_user> {
* @param type 类型: apply or change
*/
void validateApplyOrChangeIsDoing(String userId, String type);
/**
* 校验会员申请、会员变更“我的申请”列表排序字段,并返回可用于 SQL 排序的安全字段名。
*
* @param pageOrderName 前端传入的排序字段
* @return 白名单内的 SQL 排序字段;不在白名单时返回空字符串
*/
String getSafeMineOrderColumn(String pageOrderName);
}
@@ -546,6 +546,36 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
}
/**
* 校验会员申请、会员变更“我的申请”列表排序字段,避免前端参数直接拼接到 ORDER BY。
*
* @param pageOrderName 前端传入的排序字段
* @return 白名单内的 SQL 排序字段;不在白名单时返回空字符串
*/
@Override
public String getSafeMineOrderColumn(String pageOrderName) {
if (StrUtil.isBlank(pageOrderName)) {
return "";
}
// 仅允许页面表格中存在的可排序字段参与 ORDER BY,未知字段交由 controller 回退默认排序。
return switch (pageOrderName) {
case "loginName" -> "info.loginName";
case "userName" -> "info.userName";
case "loginname" -> "info.loginname";
case "username" -> "info.username";
case "sex" -> "info.sex";
case "userState" -> "info.userState";
case "personType" -> "info.personType";
case "preparedBy" -> "info.preparedBy";
case "unionName" -> "info.unionName";
case "unitName" -> "info.unitName";
case "changeTypes" -> "info.changeTypes";
case "applyDateTime" -> "info.applyDateTime";
default -> "";
};
}
private String booleanVerification(Object value) {
// 检查是否为null或空字符串
@@ -42,6 +42,31 @@ public class WelfareProjectMangeController {
@Inject
private WelfareListService welfareListService;
/** projectId 为福利项目 ID;Result.data 为预计接收人数。 */
@At
@SaCheckPermission("welfare.project.mange")
public Result reminderRecipientCount(@Param("projectId") String projectId) {
try {
return Result.success().addData(projectService.countReminderRecipients(projectId));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
/** projectId 为项目 ID,content 为提醒正文;成功仅表示已提交发送,data 为接收人数。 */
@At
@SaCheckPermission("welfare.project.mange")
@SLog(tag = "福利", msg = "提交未选择人员提醒")
@Aop(TransAop.READ_COMMITTED)
public Result submitSelectionReminder(@Param("projectId") String projectId, @Param("content") String content) {
try {
int count = projectService.submitSelectionReminder(projectId, content);
return Result.success("已提交发送,接收人数:" + count).addData(count);
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@At("")
@Ok("beetl:/platform/zhgh/welfare/projectMange/index.html")
@SaCheckPermission("welfare.project.mange")
@@ -26,6 +26,14 @@ public interface WelfareListService extends BaseService<WelfareList> {
void doWelfareListUser(String[] ids,String id);
/**
* 将单个用户加入福利项目名单,已存在时不重复添加。
*
* @param userId 用户ID
* @param projectId 福利项目ID
*/
void ensureWelfareListUser(String userId, String projectId);
/**
* 分页查询
* @param pageForm
@@ -47,4 +47,10 @@ public interface WelfareProjectService extends BaseService<WelfareProject> {
* @return
*/
List<WelfareProject> getWelfareList(Integer year);
/** projectId 为福利项目 ID,返回名单内尚未选择且有工号的去重人数。 */
int countReminderRecipients(String projectId);
/** projectId 为福利项目 ID,content 为提醒正文;返回提交后台发送的人数。 */
int submitSelectionReminder(String projectId, String content);
}
@@ -33,6 +33,8 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Lang;
@@ -381,6 +383,29 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
insert(welfareLists);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void ensureWelfareListUser(String userId, String projectId) {
if (StrUtil.isBlank(userId) || StrUtil.isBlank(projectId)
|| count(Cnd.where("projectId", "=", projectId).and("userId", "=", userId)) > 0) {
return;
}
View_user user = dao().fetch(View_user.class, userId);
if (user == null) {
throw new IllegalArgumentException("未找到当前用户信息,无法加入福利项目");
}
WelfareList welfareList = new WelfareList();
welfareList.setProjectId(projectId);
welfareList.setUserId(user.getId());
welfareList.setWelfareUnitId(user.getUnitId());
welfareList.setWelfareUnitName(user.getUnitName());
welfareList.setWelfareUnionId(user.getUnionId());
welfareList.setWelfareUnionName(user.getUnionName());
welfareList.setPersonType(user.getPersonType());
welfareList.setUserState(user.getUserState());
insert(welfareList);
}
@Override
public Pagination pageData(WelfareListPageForm pageForm) {
@@ -1,7 +1,11 @@
package com.budwk.app.zhgh.welfare.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.sms.SmsService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.zhgh.welfare.model.WelfareList;
@@ -15,6 +19,7 @@ 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.util.NutMap;
@@ -34,10 +39,70 @@ import java.util.stream.Collectors;
public class WelfareProjectServiceImpl extends BaseServiceImpl<WelfareProject> implements WelfareProjectService {
private static final Logger log = LoggerFactory.getLogger(WelfareProjectServiceImpl.class);
@Inject
private SmsService smsService;
public WelfareProjectServiceImpl(Dao dao) {
super(dao);
}
/** 按福利项目 ID 查询未选择人员,返回去重后的接收人数。 */
@Override
public int countReminderRecipients(String projectId) {
return listReminderRecipients(projectId).size();
}
/** 接收项目 ID 和提醒正文,返回后台任务接收人数,不代表送达结果。 */
@Override
public int submitSelectionReminder(String projectId, String content) {
if (StrUtil.isBlank(content)) {
throw new IllegalArgumentException("请输入提醒内容");
}
// 预览后可能有人完成选择,提交时重新筛选实际接收人。
List<String> recipients = listReminderRecipients(projectId);
if (recipients.isEmpty()) {
throw new IllegalArgumentException("该福利项目没有可提醒的未选择人员");
}
String sender = SecurityUtil.getUserLoginname();
if (StrUtil.isBlank(sender)) {
throw new IllegalArgumentException("无法获取发送人工号");
}
String url = "/platform/h5/welfare/userSelect/index?id=" + projectId;
String message = content.trim();
ThreadUtil.execute(() -> smsService.massSendTextCard(recipients, "福利选择提醒", message, null, url, sender));
return recipients.size();
}
/**
* 返回指定项目福利名单中没有有效选项记录的工号;空名单不扩大查询范围。
* 项目停用或选择截止时拒绝查询及提交,避免邀请进入无法选择的项目。
*/
private List<String> listReminderRecipients(String projectId) {
WelfareProject project = StrUtil.isBlank(projectId) ? null : fetch(projectId);
if (project == null) {
throw new IllegalArgumentException("福利项目不存在");
}
if (Boolean.TRUE.equals(project.getIsDisabled()) || project.getChoiceTimeEnd() == null
|| !new Date().before(project.getChoiceTimeEnd())) {
throw new IllegalArgumentException("福利项目已停用或选择已截止,无法发送提醒");
}
Sql sql = Sqls.create("""
SELECT DISTINCT u.loginname
FROM welfare_list wl
JOIN sys_user u ON u.id = wl.userId
WHERE wl.projectId = @projectId
AND u.loginname IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM welfare_project_user_selection s
WHERE s.welfareId = wl.projectId AND s.selectUserId = wl.userId
AND s.selectOptionId IS NOT NULL
)
""");
sql.setParam("projectId", projectId);
return listMap(sql).stream().map(row -> row.getString("loginname"))
.filter(StrUtil::isNotBlank).map(String::trim).distinct().toList();
}
@Override
public WelfareProject projectInfo(String projectId) {
WelfareProject project = fetch(projectId);
@@ -0,0 +1,137 @@
-- 问卷服务菜单初始化脚本。
-- 使用 permission 做幂等判断,避免同一环境重复执行时插入重复菜单。
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
'8f3d9c78a9f04b5b8b7f0f6a00010001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'问卷服务',
'Qsv',
'menu',
'',
'',
'ti-check-box',
1,
0,
'qsv',
NULL,
992,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'PC',
NULL,
NULL,
'w',
0,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv') t);
-- 新建问卷入口,对应 /platform/qsv/new。
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 '8f3d9c78a9f04b5b8b7f0f6a00010002', p.id, CONCAT(p.path, '0001'), '新建问卷', 'Qsv New', 'menu', '/platform/qsv/new', 'data-pjax', '', 1, 0, 'qsv.new', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'x', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.new') t);
-- 问卷活动管理入口,对应 /platform/qsv/activity。
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 '8f3d9c78a9f04b5b8b7f0f6a00010003', p.id, CONCAT(p.path, '0002'), '问卷管理', 'Qsv Activity', 'menu', '/platform/qsv/activity', 'data-pjax', '', 1, 0, 'qsv.activity', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.activity') t);
-- 在线答题管理入口,对应 /platform/qsv/online。
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 '8f3d9c78a9f04b5b8b7f0f6a00010004', p.id, CONCAT(p.path, '0003'), '在线答题', 'Qsv Online', 'menu', '/platform/qsv/online', 'data-pjax', '', 1, 0, 'qsv.online', NULL, 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'z', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.online') t);
-- 问卷统计入口,对应 /platform/qsv/survey。
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 '8f3d9c78a9f04b5b8b7f0f6a00010005', p.id, CONCAT(p.path, '0004'), '问卷统计', 'Qsv Survey', 'menu', '/platform/qsv/survey', 'data-pjax', '', 1, 0, 'qsv.survey', NULL, 4, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.survey') t);
-- 答题排名入口,对应 /platform/qsv/quizRank。
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 '8f3d9c78a9f04b5b8b7f0f6a00010006', p.id, CONCAT(p.path, '0005'), '答题排名', 'Qsv Quiz Rank', 'menu', '/platform/qsv/quizRank', 'data-pjax', '', 1, 0, 'qsv.quiz.rank', NULL, 5, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'd', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.quiz.rank') t);
-- 题库管理入口,对应 /platform/qsv/bank。
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 '8f3d9c78a9f04b5b8b7f0f6a00010007', p.id, CONCAT(p.path, '0006'), '题库管理', 'Qsv Bank', 'menu', '/platform/qsv/bank', 'data-pjax', '', 1, 0, 'qsv.bank', NULL, 6, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 't', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.bank') t);
-- 答题记录删除按钮权限,对应 qsv.quiz.rank.delete。
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 '8f3d9c78a9f04b5b8b7f0f6a00010008', p.id, CONCAT(p.path, '0001'), '删除答题记录', 'Delete Quiz Record', 'data', '', '', '', 0, 0, 'qsv.quiz.rank.delete', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 's', 0, 0
FROM sys_menu p
WHERE p.permission = 'qsv.quiz.rank'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'qsv.quiz.rank.delete') t);
-- 移动端问卷服务入口,对应 /platform/h5/qsv。
-- @SaCheckPermission 手机端权限需要使用 h5. 前缀。
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
'8f3d9c78a9f04b5b8b7f0f6a00011001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'问卷服务',
'Qsv H5',
'menu',
'/platform/h5/qsv',
'data-pjax',
'records-o',
1,
0,
'h5.qsv',
NULL,
992,
0,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'H5',
NULL,
'records-o',
'w',
1,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.qsv') t);
-- 默认给系统管理员授权问卷服务所有菜单与按钮权限。
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'qsv',
'qsv.new',
'qsv.activity',
'qsv.online',
'qsv.survey',
'qsv.quiz.rank',
'qsv.quiz.rank.delete',
'qsv.bank',
'h5.qsv'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
@@ -136,7 +136,7 @@ JOIN sys_menu m ON m.permission IN (
'tour.undertake'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'BRANCH_UNION_CHAIRMAN'
WHERE r.code IN ('BRANCH_UNION_CHAIRMAN', 'TOUR_APPLICANT')
AND rm.roleId IS NULL;
-- 普惠疗休养移动端菜单。
@@ -0,0 +1,2 @@
ALTER TABLE `qsv_subject`
ADD COLUMN `componentType` VARCHAR(30) NULL COMMENT '题目组件展示类型(RADIO普通单选,DROPDOWN下拉选择)' AFTER `type`;
@@ -0,0 +1,7 @@
-- 疗休养台账增加成团信息字段,用于线路成团自动分配和人员调整。
ALTER TABLE `tour_ledger`
ADD COLUMN `groupCode` varchar(20) DEFAULT NULL COMMENT '成团编码' AFTER `lineType`,
ADD COLUMN `groupName` varchar(50) DEFAULT NULL COMMENT '成团名称' AFTER `groupCode`;
ALTER TABLE `tour_ledger`
ADD KEY `idx_tour_ledger_matter_line_group` (`matterId`, `lineId`, `groupCode`, `delFlag`);
@@ -0,0 +1,5 @@
ALTER TABLE tour_ledger
ADD COLUMN undertakeUnionId VARCHAR(32) NULL COMMENT '领办工会编码' AFTER groupName,
ADD COLUMN undertakeUnionName VARCHAR(100) NULL COMMENT '领办工会' AFTER undertakeUnionId;
CREATE INDEX idx_tour_ledger_undertake_union ON tour_ledger (matterId, groupCode, undertakeUnionId);
@@ -0,0 +1,9 @@
-- Convert tour line sort code to numeric type for stable numeric ascending order.
UPDATE tour_line
SET lineCode = '0'
WHERE lineCode IS NULL
OR TRIM(lineCode) = ''
OR lineCode NOT REGEXP '^[0-9]+$';
ALTER TABLE tour_line
MODIFY COLUMN lineCode INT(11) NOT NULL DEFAULT 0 COMMENT '排序编号';
@@ -0,0 +1,3 @@
-- 疗休养事项选择线路新增最多报名人数,用于报名提交容量控制;为空或0表示不限制。
ALTER TABLE `tour_matter`
ADD COLUMN `maxSignupPeople` int DEFAULT NULL COMMENT '最多报名人数' AFTER `maxGroupPeople`;
+13 -1
View File
@@ -10,6 +10,17 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
`undertakeStatus` varchar(20) DEFAULT 'unclaimed' COMMENT '领办状态 claimed已领 unclaimed未领',
`organizationType` varchar(100) DEFAULT NULL COMMENT '组织形式',
`mobileThumb` varchar(500) DEFAULT NULL COMMENT '移动端缩略图',
`lineId` varchar(32) DEFAULT NULL COMMENT '线路ID',
`signupStartTime` varchar(20) DEFAULT NULL COMMENT '报名开始时间',
`signupEndTime` varchar(20) DEFAULT NULL COMMENT '报名结束时间',
`travelStartTime` varchar(20) DEFAULT NULL COMMENT '出行开始时间',
`travelEndTime` varchar(20) DEFAULT NULL COMMENT '出行结束时间',
`contactName` varchar(30) DEFAULT NULL COMMENT '联系人',
`contactPhone` varchar(30) DEFAULT NULL COMMENT '联系方式',
`minGroupPeople` int DEFAULT NULL COMMENT '最少成团人数',
`maxGroupPeople` int DEFAULT NULL COMMENT '最多成团人数',
`maxSignupPeople` int DEFAULT NULL COMMENT '最多报名人数',
`estimatedCost` decimal(10,2) DEFAULT NULL COMMENT '预计费用',
`enabled` tinyint(1) DEFAULT 1 COMMENT '事项状态',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
@@ -22,5 +33,6 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
KEY `idx_tour_matter_creator` (`creatorUserId`),
KEY `idx_tour_matter_setting` (`settingId`),
KEY `idx_tour_matter_union` (`unionId`),
KEY `idx_tour_matter_org_type` (`organizationType`)
KEY `idx_tour_matter_org_type` (`organizationType`),
KEY `idx_tour_matter_line` (`lineId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养事项';
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS `tour_participant_roster`
(
`id` varchar(32) NOT NULL COMMENT 'ID',
`settingId` varchar(32) NOT NULL COMMENT '疗休养配置ID',
`jobNo` varchar(50) NOT NULL COMMENT '工号',
`userName` varchar(100) DEFAULT NULL COMMENT '姓名',
`unitId` varchar(32) DEFAULT NULL COMMENT '所在单位ID',
`unitName` varchar(100) DEFAULT NULL COMMENT '所在单位',
`unionId` varchar(32) DEFAULT NULL COMMENT '所在工会ID',
`unionName` varchar(100) DEFAULT NULL COMMENT '所在工会',
`joined` tinyint(1) DEFAULT 0 COMMENT '是否参加:0否,1是',
`ledgerId` varchar(32) DEFAULT NULL COMMENT '疗休养台账ID',
`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_participant_roster_setting_job` (`settingId`, `jobNo`),
KEY `idx_tour_participant_roster_setting` (`settingId`),
KEY `idx_tour_participant_roster_job` (`jobNo`),
KEY `idx_tour_participant_roster_joined` (`joined`),
KEY `idx_tour_participant_roster_ledger` (`ledgerId`),
KEY `idx_tour_participant_roster_union` (`unionId`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='疗休养人员名单';
@@ -0,0 +1,15 @@
-- 疗休养人员名单查询菜单。
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 'b7c63f0c73a44a9a8c4f3d3bb1a10014', p.id, CONCAT(p.path, '0013'), '疗休养人员名单', 'Tour Participant Roster', 'menu', '/platform/tour/participantRoster', 'data-pjax', '', 1, 0, 'tour.participantRoster', NULL, 13, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'l', 0, 0
FROM sys_menu p
WHERE p.permission = 'tour'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'tour.participantRoster') t);
-- 默认给系统管理员授权,其它角色可以在角色管理中按需分配。
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission = 'tour.participantRoster'
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
+13 -1
View File
@@ -121,6 +121,17 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
`undertakeStatus` varchar(20) DEFAULT 'unclaimed' COMMENT '领办状态 claimed已领 unclaimed未领',
`organizationType` varchar(100) DEFAULT NULL COMMENT '组织形式',
`mobileThumb` varchar(500) DEFAULT NULL COMMENT '移动端缩略图',
`lineId` varchar(32) DEFAULT NULL COMMENT '线路ID',
`signupStartTime` varchar(20) DEFAULT NULL COMMENT '报名开始时间',
`signupEndTime` varchar(20) DEFAULT NULL COMMENT '报名结束时间',
`travelStartTime` varchar(20) DEFAULT NULL COMMENT '出行开始时间',
`travelEndTime` varchar(20) DEFAULT NULL COMMENT '出行结束时间',
`contactName` varchar(30) DEFAULT NULL COMMENT '联系人',
`contactPhone` varchar(30) DEFAULT NULL COMMENT '联系方式',
`minGroupPeople` int DEFAULT NULL COMMENT '最少成团人数',
`maxGroupPeople` int DEFAULT NULL COMMENT '最多成团人数',
`maxSignupPeople` int DEFAULT NULL COMMENT '最多报名人数',
`estimatedCost` decimal(10,2) DEFAULT NULL COMMENT '预计费用',
`enabled` tinyint(1) DEFAULT 1 COMMENT '事项状态',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
@@ -133,7 +144,8 @@ CREATE TABLE IF NOT EXISTS `tour_matter` (
KEY `idx_tour_matter_creator` (`creatorUserId`),
KEY `idx_tour_matter_setting` (`settingId`),
KEY `idx_tour_matter_union` (`unionId`),
KEY `idx_tour_matter_org_type` (`organizationType`)
KEY `idx_tour_matter_org_type` (`organizationType`),
KEY `idx_tour_matter_line` (`lineId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='普惠疗休养事项';
-- 疗休养事项批次表。
@@ -12,5 +12,5 @@ JOIN sys_menu m ON m.permission IN (
'tour.undertake'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code IN ('SYSADMIN', 'BRANCH_UNION_CHAIRMAN')
WHERE r.code IN ('SYSADMIN', 'BRANCH_UNION_CHAIRMAN', 'TOUR_APPLICANT')
AND rm.roleId IS NULL;
@@ -0,0 +1,67 @@
{
const owners = {}
const stack = []
const popupHandledKey = "__mobilePopupHandled"
let sequence = 0
function pushHistory(ownerId, popupId) {
const token = "popup-" + (++sequence)
stack.push({ownerId: ownerId, popupId: popupId, token: token})
// 保留当前 PJAX 状态,避免关闭弹框回退时被识别为页面切换并重新加载外层页面。
const currentState = window.history.state || {}
const popupState = Object.assign({}, currentState, {mobilePopupToken: token})
window.history.pushState(popupState, "", window.location.href)
}
function closeTopFromHistory() {
const item = stack.pop()
if (!item) return false
const callback = owners[item.ownerId]
if (callback) callback(item.popupId)
return true
}
window.addEventListener("popstate", (event) => {
if (closeTopFromHistory()) {
// 标记本次回退已由弹框处理,供页面中的 PJAX 监听跳过重复处理。
event[popupHandledKey] = true
event.stopImmediatePropagation()
}
}, true)
window.mobilePopupHistory = {
register(ownerId, closeCallback) {
owners[ownerId] = closeCallback
},
unregister(ownerId) {
delete owners[ownerId]
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].ownerId === ownerId) stack.splice(i, 1)
}
},
open(ownerId, popupId) {
if (!stack.some((item) => item.ownerId === ownerId && item.popupId === popupId)) {
pushHistory(ownerId, popupId)
}
},
close(ownerId, popupId) {
const top = stack[stack.length - 1]
if (top && top.ownerId === ownerId && top.popupId === popupId) {
window.history.back()
} else if (owners[ownerId]) {
owners[ownerId](popupId)
}
},
clear(ownerId) {
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].ownerId === ownerId) stack.splice(i, 1)
}
},
hasOpen(ownerId) {
return stack.some((item) => item.ownerId === ownerId)
},
isHandledPopstate(event) {
return Boolean(event && event[popupHandledKey])
}
}
}
@@ -0,0 +1,67 @@
const tourTableHeightMixins = {
data() {
return {
tourTableHeight: Math.max(120, window.innerHeight - 378),
tourTableResizeTimer: null,
tourTableHeightReady: false
}
},
methods: {
updateTourTableHeight() {
this.$nextTick(() => {
const table = this.$refs.tourMainTable || this.$refs.tableRef || this.$refs.table
const tableEl = table && table.$el ? table.$el : null
if (!tableEl) {
return
}
// 根据当前屏幕、表格位置、分页区域动态计算主列表高度,避免整页被数据行撑出滚动条。
const mainContent = document.querySelector(".sub-app-container-main-content")
const viewportBottom = mainContent ? mainContent.getBoundingClientRect().bottom : window.innerHeight
const tableTop = tableEl.getBoundingClientRect().top
const pagination = this.$el.querySelector(".el-pagination-container")
const paginationHeight = pagination ? pagination.getBoundingClientRect().height : 46
const reserveHeight = Number(this.tourTableReserveHeight || 42)
const minHeight = Number(this.tourTableMinHeight || 120)
const height = Math.floor(viewportBottom - tableTop - paginationHeight - reserveHeight)
this.tourTableHeight = height > minHeight ? height : minHeight
this.tourTableHeightReady = true
// Element 表格高度变化后需要重新布局,否则固定列和表头可能错位。
this.$nextTick(() => {
if (table && table.doLayout) {
table.doLayout()
}
})
})
},
handleTourTableResize() {
if (this.tourTableResizeTimer) {
clearTimeout(this.tourTableResizeTimer)
}
this.tourTableResizeTimer = setTimeout(() => {
this.updateTourTableHeight()
this.tourTableResizeTimer = null
}, 120)
}
},
mounted() {
this.updateTourTableHeight()
window.addEventListener("resize", this.handleTourTableResize)
},
beforeDestroy() {
if (this.tourTableResizeTimer) {
clearTimeout(this.tourTableResizeTimer)
this.tourTableResizeTimer = null
}
window.removeEventListener("resize", this.handleTourTableResize)
},
watch: {
tableData() {
this.updateTourTableHeight()
},
"pageForm.pageSize"() {
this.updateTourTableHeight()
}
}
}
@@ -35,7 +35,7 @@
</div>
</div>
<div class="search-item" v-if="is_sysadmin || is_A06">
<div class="search-item" v-if="has_school_scope_view_permission">
<div class="search-item-label">所属工会</div>
<div class="search-item-option">
<el-select
@@ -196,6 +196,12 @@ module.exports = {
is_A06() {
return this.roleData.is_A06
},
is_school_union_activity_admin() {
return this.roleData.is_school_union_activity_admin
},
has_school_scope_view_permission() {
return this.is_sysadmin || this.is_A06 || this.is_school_union_activity_admin
},
is_H04() {
return this.roleData.is_H04
},
@@ -292,7 +298,7 @@ module.exports = {
},
async flushUnits() {
this.$set(this.pageForm, "unitId", "")
if (this.is_A06 || this.is_sysadmin) {
if (this.has_school_scope_view_permission) {
this.units = await this.$businessTool.listUnit(this.pageForm.unionId)
// this.ActivityUnits = await getActivityUnits(this.pageForm.activityUnionId)
} else {
@@ -328,11 +334,11 @@ module.exports = {
}
},
async created() {
await this.getRolesAndUnion()
this.unions = await this.$businessTool.listUnion()
// this.ActivityUnions = await getActivityUnions()
await this.flushUnits()
await this.getActivityGroup()
await this.getRolesAndUnion()
this.viewGroupName()
}
}
@@ -21,7 +21,7 @@
</el-select>
</el-row>
<el-row class="query-row" v-if="is_A06 === true || is_sysadmin === true || is_H04 === true">
<el-row class="query-row" v-if="has_school_scope_view_permission === true || is_H04 === true">
<el-col class="query-title hidden-xs-only">会员类型:</el-col>
<el-col class="query-content">
<el-tag
@@ -53,7 +53,7 @@
</el-col>
</el-row>
<el-row class="query-row" v-if="is_A06 === true || is_sysadmin === true || is_H04 === true">
<el-row class="query-row" v-if="has_school_scope_view_permission === true || is_H04 === true">
<el-col class="query-title">教职工类别:</el-col>
<el-col class="query-content">
<el-tag
@@ -92,7 +92,7 @@
</el-col>
</el-row>
<el-row class="query-row" v-if="is_A06 === true || is_sysadmin === true || is_H04 === true">
<el-row class="query-row" v-if="has_school_scope_view_permission === true || is_H04 === true">
<el-col class="query-title">在职状态:</el-col>
<el-col class="query-content">
<el-tag
@@ -130,7 +130,7 @@
</el-col>
</el-row>
<el-row class="query-row" v-if="is_A06 === true || is_sysadmin === true || is_H04 === true">
<el-row class="query-row" v-if="has_school_scope_view_permission === true || is_H04 === true">
<el-col class="query-title">部门查询:</el-col>
<el-col class="query-content">
<el-row :gutter="20">
@@ -144,7 +144,7 @@
flushUnits()
doSearch()
"
:clearable="is_A06 === true || is_sysadmin === true"
:clearable="has_school_scope_view_permission === true"
filterable
placeholder="所属工会"
style="width: 100%"
@@ -170,7 +170,7 @@
</el-col>
</el-row>
<template v-if="is_A06 === true || is_sysadmin === true">
<template v-if="has_school_scope_view_permission === true">
<!--<el-row class="query-row">
<el-col class="query-title">活动部门:</el-col>
<el-col class="query-content">
@@ -265,7 +265,7 @@
</el-col>
</el-row>
<el-row class="query-row" v-if="is_A06 === true || is_sysadmin === true || is_H04 === true">
<el-row class="query-row" v-if="has_school_scope_view_permission === true || is_H04 === true">
<el-col class="query-title">年龄范围:</el-col>
<el-col class="query-content">
<el-row type="flex" style="align-items: center">
@@ -476,6 +476,12 @@ module.exports = {
is_A06() {
return this.roleData.is_A06
},
is_school_union_activity_admin() {
return this.roleData.is_school_union_activity_admin
},
has_school_scope_view_permission() {
return this.is_sysadmin || this.is_A06 || this.is_school_union_activity_admin
},
is_H04() {
return this.roleData.is_H04
},
@@ -561,7 +567,7 @@ module.exports = {
},
async flushUnits() {
this.$set(this.pageForm, "unitId", "")
if (this.is_sysadmin || this.is_A06) {
if (this.has_school_scope_view_permission) {
this.units = await this.$businessTool.listUnit(this.pageForm.unionId)
// this.activityUnits = await getActivityUnits(this.pageForm.activityUnionId)
} else {
@@ -662,7 +668,7 @@ module.exports = {
this.listSession()
await this.getRolesAndUnion()
this.clubOptions = await this.$businessTool.listCLubByRole()
if ((this.is_sysadmin || this.is_A06 || this.is_H02) === false && this.is_H04 === true) {
if ((this.has_school_scope_view_permission || this.is_H02) === false && this.is_H04 === true) {
this.unions = this.$businessTool.listUnion(this.unionid)
this.$set(this.pageForm, "unionId", this.unions[0].id)
} else {
@@ -0,0 +1,107 @@
<template>
<van-form ref="form">
<template v-for="column in dynamicData">
<van-field
v-if="column.columnFormType === 'STEPPER'"
:key="column.columnCode"
:label="column.columnName"
:required="column.isRequired"
>
<template #input>
<van-stepper v-model="column.columnValue" :min="1"></van-stepper>
</template>
</van-field>
<van-field
v-else-if="column.columnFormType === 'SELECT' && ['DATE', 'DATETIME'].includes(column.columnType)"
:key="column.columnCode"
:label="column.columnName"
:required="column.isRequired"
:rules="[{required: column.isRequired, message: '请选择' + column.columnName}]"
>
<template #input>
<input
v-model="column.columnValue"
:type="column.columnType === 'DATE' ? 'date' : 'datetime-local'"
class="native-input"
>
</template>
</van-field>
<van-field
v-else-if="column.columnFormType !== 'SELECT' && column.columnType !== 'JSON'"
:key="column.columnCode"
v-model="column.columnValue"
:label="column.columnName"
:name="column.columnCode"
:required="column.isRequired"
:type="column.columnType === 'INT' ? 'digit' : 'text'"
:placeholder="'请填写' + column.columnName"
:rules="[{required: column.isRequired, message: '请填写' + column.columnName}]"
></van-field>
<van-field
v-else-if="column.columnFormType === 'SELECT'"
:key="column.columnCode"
:label="column.columnName"
:required="column.isRequired"
:rules="[{required: column.isRequired, message: '请选择' + column.columnName}]"
>
<template #input>
<select v-model="column.columnValue" class="native-select">
<option value="">请选择</option>
<option v-for="option in column.selectValues" :key="option" :value="option">{{option}}</option>
</select>
</template>
</van-field>
<van-field
v-else
:key="column.columnCode"
:label="column.columnName"
:required="column.isRequired"
:rules="[{required: column.isRequired, message: '请上传' + column.columnName}]"
>
<template #input>
<h5-file-upload
:value.sync="column.columnValue"
:upload_number="column.fileNumber"
upload_mode="file"
upload_result_category="array"
upload_result_type="url"
complete_result
></h5-file-upload>
</template>
</van-field>
</template>
</van-form>
</template>
<script>
module.exports = {
name: "TrainSignDynamicForm",
props: {
dynamicData: {
type: Array,
default: () => []
}
},
methods: {
validate() {
return this.$refs.form.validate()
}
}
}
</script>
<style scoped>
.native-select {
width: 100%;
height: 32px;
border: 0;
background: transparent;
color: #323233;
}
.native-input {
width: 100%;
border: 0;
background: transparent;
color: #323233;
}
</style>
@@ -1,6 +1,7 @@
<template>
<div style="position: relative !important">
<div style="margin-bottom: 10px">
<!-- 点位搜索暂时隐藏,保留现有实现便于后续恢复。 -->
<div v-if="false" style="margin-bottom: 10px">
<el-autocomplete
v-model="addressValue"
style="width: 100%"
@@ -21,37 +21,63 @@ module.exports = {
},
},
watch: {
content: {
immediate: true,
handler() {
// 详情数据通常在组件创建后异步回填,每次内容变化都重新识别并渲染 PDF。
this.init()
}
}
},
data() {
return {
pdf: true,
pdfObj: null,
previewVersion: 0,
}
},
methods: {
init() {
this.destroyPdf()
const currentVersion = ++this.previewVersion
try {
const parser = new DOMParser();
const doc = parser.parseFromString(this.content, 'text/html');
const link = doc.querySelector('a');
const href = link.getAttribute('href');
if(!href) {
const parser = new DOMParser()
const doc = parser.parseFromString(this.content || '', 'text/html')
const link = doc.querySelector('a')
const href = link ? link.getAttribute('href') : ''
if (!href) {
this.pdf = false
return
}
const parsedUrl = new URL(href, window.location.origin)
const linkText = (link.textContent || '').trim().toLowerCase()
const isPdf = linkText.endsWith('.pdf') || parsedUrl.pathname.toLowerCase().endsWith('.pdf')
if (!isPdf) {
this.pdf = false
return
}
// 系统上传文件可能保存了其他环境的绝对域名,内部下载地址统一使用当前站点。
const pdfUrl = parsedUrl.pathname === '/platform/sys/file/download'
? parsedUrl.pathname + parsedUrl.search : parsedUrl.href
this.pdf = true
this.$nextTick(() => {
if (currentVersion !== this.previewVersion || !this.pdf) return
this.pdfObj = new Pdfh5('#pdf-container', {
pdfurl: href,
});
this.pdfObj.on("complete", function () {
const elements = document.querySelectorAll('[class*="canvasImg"]')
let classArray = []
pdfurl: pdfUrl,
// 使用二进制数据直接交给 PDF.js,避免 Blob 转 Base64 后被 CSP 的 connect-src 拦截。
responseType: 'arraybuffer',
})
this.pdfObj.on("complete", () => {
if (currentVersion !== this.previewVersion) return
const elements = this.$el.querySelectorAll('[class*="canvasImg"]')
const classArray = []
elements.forEach(element => {
classArray.push(element.getAttribute('src'))
})
elements.forEach((element,index) => {
element.addEventListener('click', function() {
element.addEventListener('click', () => {
vant.ImagePreview({
images: classArray,
startPosition: index,
@@ -65,9 +91,17 @@ module.exports = {
this.pdf = false
}
},
destroyPdf() {
// 内容切换或组件销毁时释放 PDF 渲染实例,避免重复打开详情产生资源残留。
if (this.pdfObj && this.pdfObj.destroy) {
this.pdfObj.destroy()
}
this.pdfObj = null
},
},
created() {
this.init()
beforeDestroy() {
this.previewVersion += 1
this.destroyPdf()
},
}
</script>
@@ -1,74 +1,128 @@
<template>
<div>
<div ref="editorRef"></div>
<input type="file" style="display: none" />
</div>
</template>
<script>
const { $, BtnMenu, Panel, Tooltip } = wangEditor
const showImportLoading = () => {
if (window.ELEMENT) {
return ELEMENT.Loading.service({
lock: true,
text: "文件读取中",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.7)"
})
}
if (window.vant) {
return vant.Toast.loading({
message: "文件读取中",
forbidClick: true,
overlay: true,
duration: 0
})
}
return null
}
const closeImportLoading = (loading) => {
if (loading && loading.close) {
loading.close()
} else if (window.vant && vant.Toast.clear) {
vant.Toast.clear()
}
}
const showImportError = (message) => {
if (window.ELEMENT && ELEMENT.Message) {
ELEMENT.Message.error(message)
} else if (window.vant) {
vant.Toast(message)
} else {
alert(message)
}
}
const chooseImportFile = (accept, fileHandler) => {
const input = document.createElement("input")
input.type = "file"
input.accept = accept
input.style.display = "none"
input.addEventListener("change", (event) => {
const files = event.target.files
if (!files || files.length === 0) {
input.remove()
return
}
fileHandler(files[0]).finally(() => {
input.remove()
})
}, {once: true})
document.body.appendChild(input)
input.click()
}
const importEditorDocument = (editor, file, url, errorMessage) => {
const loading = showImportLoading()
const formData = new FormData()
formData.append("file", file)
return axios.post(url, formData, {
headers: {"Content-Type": "multipart/form-data", "x-requested-with": "XMLHttpRequest"}
}).then((response) => {
if (response.data.code === 0) {
editor.txt.html(response.data.data || "")
} else {
showImportError(response.data.msg || errorMessage)
}
}).catch(() => {
showImportError(errorMessage)
}).finally(() => {
closeImportLoading(loading)
})
}
class PDFMenu extends BtnMenu {
constructor(editor) {
const $elem = wangEditor.$(
`<div class="w-e-menu" data-title="上传pdf">
<i class="fa fa-file-pdf-o" aria-hidden="true" /></i>
</div>`
'<div class="w-e-menu" data-title="上传PDF"><i class="fa fa-file-pdf-o" aria-hidden="true"></i></div>'
)
super($elem, editor)
}
clickHandler() {
const _this = this
const inputFileElement = document.querySelector('#'+this.editor.textElemId).parentElement.parentElement.nextElementSibling
inputFileElement.click()
inputFileElement.addEventListener('change', function () {
const file = this.files[0];
const fileName = file.name
if (!fileName.toLowerCase().endsWith('.pdf')) {
alert('请上传pdf格式的文件')
_this.clearInputFile()
return
chooseImportFile("application/pdf,.pdf", (file) => {
if (!file.name.toLowerCase().endsWith(".pdf")) {
showImportError("请上传PDF文件")
return Promise.resolve()
}
if (this.editor.config.officeImageImport) {
return importEditorDocument(this.editor, file,
"/platform/sys/file/convertPdfToImageHtml", "PDF导入失败,请联系管理员")
}
const loading = showImportLoading()
const formData = new FormData()
formData.append('file', file, file.name)
/*axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {
headers: {
"Content-Type": "multipart/form-data"
}
})*/
axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {}).then(response => {
console.log(response.data)
if (response.data.code === 0) {
const html = `<a target="_blank" href="${window.location.origin}${response.data.data}">
${file.name}
</a>`
_this.editor.txt.html(html)
} else {
alert(response.data.msg + ',上传pdf失败,请联系管理员!')
_this.clearInputFile()
}
}).catch(error => {
console.log(error)
_this.clearInputFile()
alert('上传pdf失败,请联系管理员!')
});
});
}
clearInputFile() {
const obj = document.getElementById('fileInput');
if(obj) {
obj.outerHTML = obj.outerHTML
}
}
tryChangeActive() {
this.active()
formData.append("file", file, file.name)
return axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {})
.then((response) => {
if (response.data.code === 0) {
const html = '<a target="_blank" href="' + window.location.origin
+ response.data.data + '">' + file.name + '</a>'
this.editor.txt.html(html)
} else {
showImportError(response.data.msg + ",上传PDF失败,请联系管理员")
}
}).catch(() => {
showImportError("上传PDF失败,请联系管理员")
}).finally(() => {
closeImportLoading(loading)
})
})
}
tryChangeActive() {}
}
//注册上传word按钮
@@ -83,70 +137,38 @@ class DocMenu extends BtnMenu {
}
clickHandler() {
const fileInputId = this.editor.textElemId + "input-file"
const existingInput = document.getElementById(fileInputId)
if (existingInput) {
existingInput.remove()
}
chooseImportFile("application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.doc,.docx", (file) => {
return importEditorDocument(this.editor, file,
"/platform/sys/file/convertHtml", "Word导入失败,请联系管理员")
})
}
tryChangeActive() {}
}
const input = document.createElement("input")
input.type = "file"
input.accept = "application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
input.id = fileInputId
input.style.display = "none"
input.addEventListener("change", (event) => {
const files = event.target.files
if (!files || files.length === 0) {
return
}
class ExcelMenu extends BtnMenu {
constructor(editor) {
const displayStyle = editor.config.officeImageImport ? "" : "display:none;"
const $elem = wangEditor.$(
'<div class="w-e-menu" data-title="上传Excel" style="' + displayStyle
+ '"><i class="fa fa-file-excel-o" aria-hidden="true"></i></div>'
)
super($elem, editor)
}
let loading = null
if (window.ELEMENT) {
loading = ELEMENT.Loading.service({
lock: true,
text: "文件读取中",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.7)"
})
} else if (window.vant) {
loading = vant.Toast.loading({
message: "文件读取中",
forbidClick: true,
overlay: true,
duration: 0
})
clickHandler() {
if (!this.editor.config.officeImageImport) {
return
}
chooseImportFile("application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,.xls,.xlsx", (file) => {
const fileName = file.name.toLowerCase()
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
showImportError("请上传xls或xlsx文件")
return Promise.resolve()
}
const formData = new FormData()
formData.append("file", files[0])
axios
.post("/platform/sys/file/convertHtml", formData, {
headers: { "Content-Type": "multipart/form-data", "x-requested-with": "XMLHttpRequest" }
})
.then((response) => {
loading.close()
if (response.data.code === 0) {
this.editor.txt.html(response.data.data)
} else {
if (window.ELEMENT) {
this.$message.error(response.data.msg)
} else if (window.vant) {
vant.Toast(response.data.msg)
} else {
alert(response.data.msg)
}
}
})
.catch((error) => {
console.log(error)
alert("上传word失败,请联系管理员!")
})
.finally(() => {
input.remove()
})
return importEditorDocument(this.editor, file,
"/platform/sys/file/convertExcelToHtml", "Excel导入失败,请联系管理员")
})
input.click()
document.body.appendChild(input)
}
tryChangeActive() {}
@@ -154,6 +176,7 @@ class DocMenu extends BtnMenu {
wangEditor.registerMenu("docMenu", DocMenu)
wangEditor.registerMenu('pdfMenuKey', PDFMenu)
wangEditor.registerMenu("excelMenu", ExcelMenu)
module.exports = {
name: "textEditor",
props: {
@@ -170,6 +193,10 @@ module.exports = {
type: Number,
default: 300
},
officeImageImport: {
type: Boolean,
default: false
},
menus: {
type: Array,
default: () => {
@@ -214,7 +241,7 @@ module.exports = {
watch: {
value: {
handler: function (newValue) {
handler(newValue) {
this.$nextTick(() => {
const content = newValue || ""
if (this.editor.txt.html() !== content) {
@@ -237,6 +264,8 @@ module.exports = {
let _this = this
this.editor = new wangEditor(this.$refs.editorRef)
this.editor.config.focus = false
// 仅指定业务启用PDF转图片和Excel转表格,其他富文本页面保持原有导入行为。
this.editor.config.officeImageImport = this.officeImageImport
//内容change回调
this.editor.config.onchange = (newHtml) => {
this.cursorPos = this.editor.selection.getCursorPos()
@@ -244,13 +273,13 @@ module.exports = {
range.setStart(range.startContainer, Math.max(this.cursorPos, range.endOffset))
this.$emit("input", newHtml)
}
this.editor.config.linkImgCheck = function (imgSrc, alt, href) {
this.editor.config.linkImgCheck = (imgSrc, alt, href) => {
// 1. 返回 true ,说明检查通过
return true
}
//配置图片上传
this.editor.config.customUploadImg = function (resultFiles, insertImgFn) {
this.editor.config.customUploadImg = (resultFiles, insertImgFn) => {
// resultFiles 是 input 中选中的文件列表
// insertImgFn 是获取图片 url 后,插入到编辑器的方法
Promise.all(_this.uploadFiles(resultFiles)).then((res) => {
@@ -269,7 +298,7 @@ module.exports = {
this.editor.create()
},
uploadFiles(resultFiles) {
return resultFiles.map(async (file) => {
return resultFiles.map((file) => {
const formData = new FormData()
formData.append("file", file)
return axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {
@@ -347,6 +347,19 @@ module.exports = {
// 预览图片
handlePreview(file) {
if (this.upload_mode === "image") {
// 回显记录的名称可能是无扩展名的下载地址;图片模式直接预览缩略图地址。
const url = file.url || (this.upload_result_type === "url" && file.response?.data)
if (!url) {
this.$message.warning("暂无可预览的图片")
return
}
const image = new Image()
image.src = url
const viewer = new Viewer(image, {zIndex: 99999, title: false, hidden: () => viewer.destroy()})
viewer.show()
return
}
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1).toLowerCase()
const fileId = file.response.data.substring(file.response.data.lastIndexOf("=") + 1)
const buildFile = {

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