diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index 456c27da..f20a80dd 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -27,6 +27,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Duration; import java.time.LocalDate; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -353,8 +354,8 @@ public class AihrOrgSyncService { jdbcTemplate.batchUpdate(""" insert into aihr_org_snapshot (tenant_id, project_code, project_name, dept_name, ext_party_id, person_phone, person_name, - position_name, position_level, employment_status, snapshot_date, create_time) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + position_name, position_level, employment_status, hire_date, snapshot_date, create_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) on duplicate key update project_code = values(project_code), project_name = values(project_name), @@ -364,6 +365,7 @@ public class AihrOrgSyncService { position_name = values(position_name), position_level = values(position_level), employment_status = values(employment_status), + hire_date = values(hire_date), snapshot_date = values(snapshot_date), create_time = now() """, rows.stream().map(row -> row.args(tenantId())).toList()); @@ -397,6 +399,8 @@ public class AihrOrgSyncService { String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", "post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工"); String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName)); + LocalDate hireDate = parseDate(firstNonBlank( + text(employee, "hire_date", "hireDate", "entry_date", "entryDate", "employment_date", "employmentDate"))); boolean suspectText = hasQuestionMark(projectName) || hasQuestionMark(deptName) || hasQuestionMark(personName) @@ -411,6 +415,7 @@ public class AihrOrgSyncService { positionName, positionLevel(positionLevel, positionName), status(text(employee, "status", "employment_status", "employmentStatus")), + hireDate, LocalDate.now(), suspectText ); @@ -530,6 +535,7 @@ public class AihrOrgSyncService { throw new IllegalArgumentException("请先执行 aihr_org_snapshot_mysql8.sql 初始化组织人员快照表"); } ensureSnapshotColumn("person_phone", "ALTER TABLE aihr_org_snapshot ADD COLUMN `person_phone` varchar(30) DEFAULT NULL COMMENT '手机号(仅用于登录身份映射)' AFTER `ext_party_id`"); + ensureSnapshotColumn("hire_date", "ALTER TABLE aihr_org_snapshot ADD COLUMN `hire_date` date DEFAULT NULL COMMENT '入职日期(用于新员工三个月资格判断)' AFTER `employment_status`"); ensureSnapshotIndex("idx_aihr_org_phone", "ALTER TABLE aihr_org_snapshot ADD KEY `idx_aihr_org_phone` (`tenant_id`, `person_phone`)"); } catch (DataAccessException e) { throw new IllegalStateException("组织人员快照表检查失败(处理错误已隐藏)", e); @@ -705,6 +711,19 @@ public class AihrOrgSyncService { return value == null ? "" : value.trim(); } + private static LocalDate parseDate(String value) { + String text = clean(value); + if (text.isBlank()) { + return null; + } + String dateText = text.length() >= 10 ? text.substring(0, 10) : text; + try { + return LocalDate.parse(dateText); + } catch (DateTimeParseException ignored) { + return null; + } + } + private static String truncate(String value, int maxChars) { String text = clean(value); return text.length() <= maxChars ? text : text.substring(0, maxChars); @@ -726,13 +745,14 @@ public class AihrOrgSyncService { String positionName, String positionLevel, String employmentStatus, + LocalDate hireDate, LocalDate snapshotDate, boolean suspectText ) { Object[] args(String tenantId) { return new Object[] { tenantId, projectCode, projectName, deptName, extPartyId, personPhone, personName, - positionName, positionLevel, employmentStatus, snapshotDate + positionName, positionLevel, employmentStatus, hireDate, snapshotDate }; } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 21171d60..09806f6f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -1858,7 +1858,11 @@ public class AihrPracticeSeedService { if (orgSnapshotTableExists() && orgSnapshotColumnExists("hire_date")) { return findHireDate(extPartyId) .map(hireDate -> isWithinNewHireWindow(hireDate, LocalDate.now())) - .orElse(false); + .orElseGet(() -> isLegacyDailyDrillEligible( + isLegacyDailyDrillFallbackEnabled(allowLegacyDailyDrillFallback, activeProfiles), + extPartyId, + countMobileRecords(extPartyId) + )); } // Formal production data must provide hire_date. The record-count fallback is // intentionally opt-in for the local Demo only; it must not infer employment diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java index 2ba9b467..a9dec52d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java @@ -12,6 +12,7 @@ import org.springframework.transaction.support.TransactionTemplate; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -62,18 +63,20 @@ public class AihrOrgSyncServiceTest { ObjectMapper mapper = new ObjectMapper(); AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); var employee = mapper.readTree(""" - {"id":"EMP-1","name":"测试员工","department_name":"项目管理","project_name":"翡翠湾","position_name":"主管","phone":"13900001111"} + {"id":"EMP-1","name":"测试员工","department_name":"项目管理","project_name":"翡翠湾","position_name":"主管","phone":"13900001111","hire_date":"2026-07-01"} """); ReflectionTestUtils.setField(service, "storeDisplayFields", false); Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); assertEquals("", ReflectionTestUtils.getField(masked, "personName")); assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); + assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate")); ReflectionTestUtils.setField(service, "storeDisplayFields", true); Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); assertEquals("测试员工", ReflectionTestUtils.getField(demo, "personName")); assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName")); + assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate")); } @Test diff --git a/backend/script/sql/aihr_org_snapshot_mysql8.sql b/backend/script/sql/aihr_org_snapshot_mysql8.sql index 8767b0b3..4548716a 100644 --- a/backend/script/sql/aihr_org_snapshot_mysql8.sql +++ b/backend/script/sql/aihr_org_snapshot_mysql8.sql @@ -16,6 +16,7 @@ CREATE TABLE `aihr_org_snapshot` ( `position_name` varchar(100) DEFAULT NULL COMMENT '岗位', `position_level` varchar(30) DEFAULT NULL COMMENT '岗位层级(一线/主管/项目经理)', `employment_status` varchar(20) DEFAULT 'active' COMMENT '在职状态', + `hire_date` date DEFAULT NULL COMMENT '入职日期(用于新员工三个月资格判断)', `snapshot_date` date DEFAULT NULL COMMENT '快照日期', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), diff --git a/backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql b/backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql new file mode 100644 index 00000000..ca26af5f --- /dev/null +++ b/backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql @@ -0,0 +1,17 @@ +-- AIHR 组织快照入职日期迁移(MySQL 8.x) +-- 仅补齐结构,不写入业务数据;可重复执行。 +SET @aihr_hire_date_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'aihr_org_snapshot' + AND COLUMN_NAME = 'hire_date' +); +SET @aihr_hire_date_ddl := IF( + @aihr_hire_date_exists = 0, + 'ALTER TABLE aihr_org_snapshot ADD COLUMN `hire_date` date DEFAULT NULL COMMENT ''入职日期(用于新员工三个月资格判断)'' AFTER `employment_status`', + 'SELECT 1' +); +PREPARE aihr_hire_date_stmt FROM @aihr_hire_date_ddl; +EXECUTE aihr_hire_date_stmt; +DEALLOCATE PREPARE aihr_hire_date_stmt; diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 3b37e35e..9fcf915b 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -291,3 +291,4 @@ - 2026-07-14 BRD 4.1 面试证据输入边界修复:`answer/finish` 此前接受不存在于服务端题目列表的 `questionId`,可能把伪造字段写入回答快照;现统一拒绝未知题目 ID,评分和持久化只接受本次服务端生成的题目集合。新增 answer/finish 回归测试和 `demo-check` marker,未修改业务数据。 - 2026-07-14 BRD 生产迁移资产补齐:`aihr_practice_audio`、`aihr_practice_calibration` 此前只有 reset SQL 和运行时懒建表,生产曾依赖人工建表;现新增可重复执行的 MySQL 8 版本化迁移脚本,仅补齐结构、不写入业务数据。仍需由运维在正式发布窗口执行并核对生产 schema,不能把脚本存在视为迁移已执行。 - 2026-07-14 最新线上只读复核:生产根站、`/h5/` 和 `/prod-api/auth/tenant/list` 均返回 `200`;线上仍加载管理端 `assets/index-CJZ3Ax3Z.js`(SHA-256 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`)与 H5 `assets/index-D4-NrEpb.js`(SHA-256 `ab15bfd17cabe58e2f34b0ac61ee198a837128ed85dc6bb0ddf77bc3e22dcd9c`),本地最新提交 `e0da5f0a` 尚未发布;本轮未执行生产同步、后端重启或业务数据写入。 +- 2026-07-14 BRD 4.4 入职日期数据链路补齐:组织快照初始化 DDL、外部组织同步和旧库懒迁移现在统一保存 `hire_date`,每日三题在存在该字段但记录缺日期时仍只允许本地 `dev/local` 的显式训练次数兼容回退,生产不会用训练次数推断新员工;新增 MySQL 8 版本迁移与严格预检字段/身份门禁。未写入试点业务数据,正式环境仍需外部组织同步实际提供入职日期并执行迁移。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index c2b2b9b7..5849b0a4 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -81,7 +81,7 @@ check_pilot_samples() { [[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end" [[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date" - local scenario_count scenario_service_promotion_count scenario_daily_service_count completed_people pilot_people org_active org_phone_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count prompt_version_schema_count sop_document_count case_stored_count case_unmasked_count + local scenario_count scenario_service_promotion_count scenario_daily_service_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count prompt_version_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average local org_identity_cte=" WITH active_org AS ( @@ -129,6 +129,7 @@ check_pilot_samples() { scenario_daily_service_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = '$pilot_tenant' AND position = '生活顾问' AND enabled = 1 AND (scenario_type LIKE '%日常%' OR scenario_name LIKE '%日常%')")" audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")" prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")" + hire_date_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' AND column_name = 'hire_date'")" sop_document_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_knowledge_attach WHERE tenant_id = '$pilot_tenant' AND type = 'sop' AND status = 2")" case_stored_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_case_record WHERE tenant_id = '$pilot_tenant' AND status = '已入库'")" case_unmasked_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_case_record WHERE tenant_id = '$pilot_tenant' AND status = '已入库' AND CONCAT_WS(' ', title, transcript, summary) REGEXP '1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\\.[[:alpha:]]{2,}|[0-9]{1,2}(栋|幢|号楼|单元)[0-9]{3,4}|[0-9]{1,2}[--][0-9]{3,4}'")" @@ -162,6 +163,15 @@ check_pilot_samples() { org_phone_linked="$(pilot_sql_scalar "$org_identity_cte SELECT COUNT(DISTINCT ext_party_id) FROM safe_org")" + if (( hire_date_schema_count == 1 )); then + org_hire_date_linked="$(pilot_sql_scalar "$org_identity_cte + SELECT COUNT(DISTINCT s.ext_party_id) + FROM safe_org s + JOIN aihr_org_snapshot o ON o.tenant_id = '$pilot_tenant' AND o.ext_party_id = s.ext_party_id + WHERE o.employment_status = 'active' AND o.hire_date IS NOT NULL")" + else + org_hire_date_linked=0 + fi org_pilot_people="$(pilot_sql_scalar " $org_identity_cte, pilot_identity AS ( @@ -273,7 +283,7 @@ check_pilot_samples() { AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) ) SELECT COALESCE(ROUND(AVG(satisfaction_score), 1), 0) FROM formal_sessions WHERE satisfaction_score IS NOT NULL")" - echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} prompt_version_schema=${prompt_version_schema_count}" + echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} prompt_version_schema=${prompt_version_schema_count} hire_date_schema=${hire_date_schema_count}" [[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || { (( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12" @@ -284,7 +294,9 @@ check_pilot_samples() { (( case_unmasked_count == 0 )) || fail "pilot stored cases contain unmasked known PII (${case_unmasked_count})" (( audio_table_count == 1 )) || fail "pilot audio schema unavailable (aihr_practice_audio missing)" (( prompt_version_schema_count == 1 )) || fail "pilot RAG traceability schema unavailable (aihr_sop_answer_review.prompt_version missing)" + (( hire_date_schema_count == 1 )) || fail "pilot org hire_date schema unavailable" (( org_phone_linked >= 20 )) || fail "pilot mobile-login org members ${org_phone_linked}/20 (active org ${org_active})" + (( org_hire_date_linked >= 20 )) || fail "pilot org hire_date identities ${org_hire_date_linked}/20" (( org_pilot_people >= 20 )) || fail "pilot activity matched to active org ${org_pilot_people}/20" (( org_pilot_projects >= 1 && org_pilot_projects <= 2 )) || fail "pilot project scope must be 1-2 projects (actual ${org_pilot_projects})" (( org_completed_people * 100 >= org_pilot_people * 80 )) || fail "pilot org members with at least 10 completed sessions below 80% (${org_completed_people}/${org_pilot_people})" @@ -497,6 +509,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_calibration" contains backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql "aihr_practice_audio" contains backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql "aihr_practice_calibration" +contains backend/script/sql/aihr_org_snapshot_mysql8.sql "hire_date" +contains backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql "hire_date" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "hireDate" contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled' contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/scenarios/{id}/enabled" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrModelController.java "@SaCheckRole" diff --git a/scripts/tests/demo-check-pilot-gates.test.sh b/scripts/tests/demo-check-pilot-gates.test.sh index 6944b34c..c86943da 100644 --- a/scripts/tests/demo-check-pilot-gates.test.sh +++ b/scripts/tests/demo-check-pilot-gates.test.sh @@ -65,7 +65,7 @@ CREATE TABLE aihr_practice_session (tenant_id varchar(20), session_id varchar(10 CREATE TABLE aihr_practice_assignment (tenant_id varchar(20), ext_party_id varchar(100), create_time datetime); CREATE TABLE aihr_org_snapshot ( tenant_id varchar(20), employment_status varchar(20), ext_party_id varchar(100), - project_code varchar(50), person_phone varchar(30) + project_code varchar(50), person_phone varchar(30), hire_date date ); CREATE TABLE aihr_practice_calibration ( id bigint auto_increment primary key, tenant_id varchar(20), session_id varchar(100), @@ -104,12 +104,12 @@ INSERT INTO aihr_case_record VALUES ('000000','已转写','pending','',''), ('other','已入库','other','',''); INSERT INTO aihr_org_snapshot VALUES - ('000000','active','A','P1','13900000001'), - ('000000','active','B','P1','13900000001'), - ('000000','active','C','P2','D'), - ('000000','active','D','P2','13900000004'), - ('000000','active','E','P3','13900000005'), - ('000000','active','F','P3','13900000006'); + ('000000','active','A','P1','13900000001','2026-01-01'), + ('000000','active','B','P1','13900000001','2026-01-01'), + ('000000','active','C','P2','D','2026-01-01'), + ('000000','active','D','P2','13900000004','2026-01-01'), + ('000000','active','E','P3','13900000005','2026-01-01'), + ('000000','active','F','P3','13900000006','2026-01-01'); INSERT INTO aihr_practice_assignment VALUES ('000000','D','2026-07-08 08:00:00'), ('000000','13900000001','2026-07-08 08:00:00'),