diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md new file mode 100644 index 0000000..2f9108f --- /dev/null +++ b/changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md @@ -0,0 +1,50 @@ +# T1 任务简报:V38/V39 迁移与通用清单种子 + +## Where This Fits + +- Wave: W1 +- Depends on: 无 +- Contract: `changes/n04-onboard/execution-contract.md` +- Specs: `changes/n04-onboard/specs/onboarding-checklist/spec.md`、`changes/n04-onboard/specs/company-knowledge/spec.md` +- Design: `changes/n04-onboard/design.md` 的 Data Model 与 Architecture 部分 + +## Objective + +新增 V38 建表迁移和 V39 通用清单种子,并先用失败测试锁定表结构、约束、通用项数量和稳定排序。 + +## Files In Scope + +- `mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql` +- `mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql` +- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java` +- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java` +- 如项目迁移测试需要共享测试配置,仅可修改同一测试包内的辅助文件。 + +## Required Behavior + +- 创建 `onboarding_checklist_items`、`student_onboarding_progress`、`company_knowledge`。 +- `onboarding_checklist_items` 至少包含合同规定的字段、唯一键、活动/排序索引、`company_id` 对 `companies(id)` 的外键。 +- `student_onboarding_progress` 必须对学生与条目唯一,并建立 `users(id)`、`onboarding_checklist_items(id)` 外键。 +- `company_knowledge.company_id` 唯一,并建立 `companies(id)` 外键。 +- V39 必须写入稳定的通用清单;每一步使用明确 `item_key`、分类、排序和必填标记,不得使用随机值。 +- 迁移必须兼容 H2 `MODE=MySQL` 与 MySQL 8.4。 + +## TDD Requirement + +1. 先写 `OnboardingMigrationTest`、`OnboardingSeedDataIntegrationTest`,确认因表或种子不存在而失败。 +2. 再写 V38/V39 让测试通过。 +3. 不得先写迁移再补测试。 + +## Evidence Commands + +```powershell +cd D:\1\zhi\zhilai\mydreamjob-server +mvn -Dtest=OnboardingMigrationTest,OnboardingSeedDataIntegrationTest test +``` + +## Done Definition + +- 两条证据测试通过。 +- 测试断言三张表存在、关键唯一约束/外键/索引可验证、通用种子数量大于 0 且排序稳定。 +- 不修改 V1-V37。 +- 不创建 W2 实体、Repository、Service 或 Controller。 \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md new file mode 100644 index 0000000..9b1be25 --- /dev/null +++ b/changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md @@ -0,0 +1,53 @@ +# T2 任务简报:Onboarding 实体、Repository 与评价聚合查询 + +## Where This Fits + +- Wave: W1 +- Depends on: T1 +- Contract: `changes/n04-onboard/execution-contract.md` +- Design: `changes/n04-onboard/design.md` 的 Architecture、Data Model、Decisions + +## Objective + +在 V38/V39 表结构之上新增实体、Repository 和公司评价聚合查询,先写持久化失败测试,再实现最小映射。 + +## Files In Scope + +- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java` +- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java` +- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java` +- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java` +- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java` +- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java` +- 必要时只读扩展 `mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java` 和 `JobReviewDimensionScoreRepository.java` 的公司级聚合查询。 +- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java` + +## Required Behavior + +- `OnboardingChecklistItem` 映射 `onboarding_checklist_items`,含 `scopeType`、`scopeId`、`companyId`、`itemKey`、title、description、category、sortOrder、required、active、idempotencyKey、requestHash、version、审计时间。 +- `StudentOnboardingProgress` 映射 `student_onboarding_progress`,含 studentUserId、itemId、completed、completedAt、version、审计时间。 +- `CompanyKnowledge` 映射 `company_knowledge`,含 companyId、culture、workPolicies、onboardingGuide、version、审计时间。 +- 清单 Repository 支持按 `companyId` 查询活动通用项+活动企业项,并按 `sortOrder ASC, id ASC` 稳定排序。 +- 进度 Repository 支持按学生和多个 itemId 批量读取,并按学生+条目唯一查询。 +- 知识 Repository 支持按 companyId 查询。 +- 评价聚合查询支持公司下全部 `PUBLISHED` 且 `AUTO_APPROVED/APPROVED` 的评价评分分布、tags 原始列和八维汇总;不得逐岗位或逐评价产生 N+1。 + +## TDD Requirement + +1. 先写 `OnboardingRepositoryIntegrationTest`,确认新实体/Repository 不存在或查询失败。 +2. 再实现最小实体和 Repository 使测试通过。 +3. 不创建 Service、Controller、DTO 或业务状态机。 + +## Evidence Commands + +```powershell +cd D:\1\zhi\zhilai\mydreamjob-server +mvn -Dtest=OnboardingRepositoryIntegrationTest test +``` + +## Done Definition + +- 新实体与三张表映射通过 Hibernate `ddl-auto=validate`。 +- 合并查询只返回活动通用项和当前企业活动项。 +- 多学生、多企业、软删除项和排序测试通过。 +- 公司评分/tags/八维聚合仅统计公开评价。 \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql b/mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql new file mode 100644 index 0000000..30fe9ad --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql @@ -0,0 +1,57 @@ +-- Flyway V38: 入职清单与企业知识表结构 +-- 本迁移只建立三张业务表;通用清单内容由 V39 导入。 + +CREATE TABLE onboarding_checklist_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + scope_type VARCHAR(20) NOT NULL COMMENT 'STANDARD 或 COMPANY', + scope_id BIGINT NOT NULL COMMENT '通用项为 0,企业项为 company_id', + company_id BIGINT NULL COMMENT '企业项所属企业;通用项为空', + item_key VARCHAR(80) NOT NULL COMMENT '清单项稳定业务键', + title VARCHAR(120) NOT NULL COMMENT '标题', + description VARCHAR(1000) NULL COMMENT '说明', + category VARCHAR(30) NOT NULL COMMENT 'DOCUMENT/ADMIN/EQUIPMENT/TRAINING/LOGISTICS/OTHER', + sort_order INT NOT NULL DEFAULT 1000 COMMENT '排序值', + required BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否必填', + active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否生效', + idempotency_key VARCHAR(64) NULL COMMENT '企业创建幂等键', + request_hash CHAR(64) NULL COMMENT '规范化创建请求 SHA-256', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT ck_onboarding_checklist_scope_type CHECK (scope_type IN ('STANDARD', 'COMPANY')), + CONSTRAINT ck_onboarding_checklist_category CHECK ( + category IN ('DOCUMENT', 'ADMIN', 'EQUIPMENT', 'TRAINING', 'LOGISTICS', 'OTHER') + ), + CONSTRAINT ck_onboarding_checklist_sort_order CHECK (sort_order BETWEEN 0 AND 9999), + CONSTRAINT uk_onboarding_checklist_scope_item UNIQUE (scope_type, scope_id, item_key), + CONSTRAINT uk_onboarding_checklist_company_idempotency UNIQUE (company_id, idempotency_key), + CONSTRAINT fk_onboarding_checklist_company FOREIGN KEY (company_id) REFERENCES companies (id), + INDEX idx_onboarding_checklist_scope_active_sort (scope_type, scope_id, active, sort_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职清单项'; + +CREATE TABLE student_onboarding_progress ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL COMMENT '学生用户', + item_id BIGINT NOT NULL COMMENT '清单项', + completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成', + completed_at DATETIME NULL COMMENT '完成时间;取消完成时为空', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_student_onboarding_progress_student_item UNIQUE (student_user_id, item_id), + CONSTRAINT fk_student_onboarding_progress_student FOREIGN KEY (student_user_id) REFERENCES users (id), + CONSTRAINT fk_student_onboarding_progress_item FOREIGN KEY (item_id) REFERENCES onboarding_checklist_items (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生入职清单完成状态'; + +CREATE TABLE company_knowledge ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + company_id BIGINT NOT NULL COMMENT '企业', + culture VARCHAR(2000) NULL COMMENT '企业文化', + work_policies VARCHAR(4000) NULL COMMENT '工作制度', + onboarding_guide VARCHAR(4000) NULL COMMENT '入职指南', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_company_knowledge_company UNIQUE (company_id), + CONSTRAINT fk_company_knowledge_company FOREIGN KEY (company_id) REFERENCES companies (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业知识介绍'; diff --git a/mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql b/mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql new file mode 100644 index 0000000..e249093 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql @@ -0,0 +1,31 @@ +-- Flyway V39: 通用入职准备清单种子 +-- 通用项使用 scope_id=0、company_id=NULL,内容、分类、顺序和必填标记均为固定值。 + +INSERT INTO onboarding_checklist_items + (scope_type, scope_id, company_id, item_key, title, description, category, sort_order, + required, active, idempotency_key, request_hash, version) +VALUES + ('STANDARD', 0, NULL, 'standard-id-card', '准备身份证件', + '准备身份证原件或有效身份证明,用于入职登记。', 'DOCUMENT', 100, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-education-proof', '准备学历学位证明', + '按公司要求准备学历、学位证书或学信网证明。', 'DOCUMENT', 110, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-id-photo', '准备证件照', + '准备符合公司要求的近期证件照。', 'DOCUMENT', 120, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-bank-account', '提供工资卡信息', + '按公司要求提供本人工资卡账户信息。', 'ADMIN', 200, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-emergency-contact', '填写紧急联系人', + '填写必要的紧急联系人和联系方式。', 'ADMIN', 210, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-background-check', '完成背景核验', + '按公司通知配合完成背景信息核验。', 'ADMIN', 220, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-medical-check', '确认入职体检要求', + '确认岗位是否需要体检及指定机构要求。', 'ADMIN', 230, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-equipment-request', '确认办公设备需求', + '提前确认工位、电脑或其他办公设备需求。', 'EQUIPMENT', 300, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-report-location', '确认报到地址和时间', + '与 HR 确认入职日期、报到地址和接待人。', 'LOGISTICS', 400, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-commute-plan', '规划通勤路线', + '提前规划到岗路线和交通时间。', 'LOGISTICS', 410, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-compliance-training', '完成合规与保密培训', + '完成公司要求的合规、安全和保密培训。', 'TRAINING', 500, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-onboarding-guide', '阅读入职指南', + '阅读公司入职指南及相关准备说明。', 'TRAINING', 510, FALSE, TRUE, NULL, NULL, 0); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java new file mode 100644 index 0000000..91f78ec --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java @@ -0,0 +1,157 @@ +package com.mydreamjob.onboarding; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_migration;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class OnboardingMigrationTest { + + @Autowired + private DataSource dataSource; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void migrationCreatesOnboardingTablesColumnsConstraintsAndIndexes() throws SQLException { + assertTableAndColumns("onboarding_checklist_items", + "id", "scope_type", "scope_id", "company_id", "item_key", "title", + "description", "category", "sort_order", "required", "active", + "idempotency_key", "request_hash", "version", "created_at", "updated_at"); + assertTableAndColumns("student_onboarding_progress", + "id", "student_user_id", "item_id", "completed", "completed_at", + "version", "created_at", "updated_at"); + assertTableAndColumns("company_knowledge", + "id", "company_id", "culture", "work_policies", "onboarding_guide", + "version", "created_at", "updated_at"); + + assertUniqueIndex("onboarding_checklist_items", "scope_type", "scope_id", "item_key"); + assertUniqueIndex("onboarding_checklist_items", "company_id", "idempotency_key"); + assertUniqueIndex("student_onboarding_progress", "student_user_id", "item_id"); + assertUniqueIndex("company_knowledge", "company_id"); + + assertForeignKey("onboarding_checklist_items", "company_id", "companies", "id"); + assertForeignKey("student_onboarding_progress", "student_user_id", "users", "id"); + assertForeignKey("student_onboarding_progress", "item_id", "onboarding_checklist_items", "id"); + assertForeignKey("company_knowledge", "company_id", "companies", "id"); + + assertIndex("onboarding_checklist_items", + "scope_type", "scope_id", "active", "sort_order", "id"); + } + + private void assertTableAndColumns(String table, String... columns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + throw new AssertionError("Missing table " + table); + } + List actualColumns = new ArrayList<>(); + try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) { + while (resultSet.next()) { + actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + for (String column : columns) { + assertTrue(actualColumns.contains(column.toLowerCase()), + "Missing column " + table + "." + column + "; actual=" + actualColumns); + } + } + } + + private void assertUniqueIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, true, columns), + "Missing unique index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private void assertIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, false, columns), + "Missing index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private boolean hasIndex(String table, boolean unique, String... expectedColumns) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + return false; + } + Map> indexes = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) { + while (resultSet.next()) { + String indexName = resultSet.getString("INDEX_NAME"); + if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) { + continue; + } + indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>()) + .put(resultSet.getShort("ORDINAL_POSITION"), + resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + List expected = new ArrayList<>(); + for (String column : expectedColumns) { + expected.add(column.toLowerCase()); + } + return indexes.values().stream() + .map(TreeMap::values) + .map(values -> new ArrayList<>(values)) + .anyMatch(expected::equals); + } + } + + private void assertForeignKey(String childTable, String childColumn, + String parentTable, String parentColumn) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualChildTable = findTable(metadata, childTable); + String actualParentTable = findTable(metadata, parentTable); + if (actualChildTable == null || actualParentTable == null) { + throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn); + } + try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) { + while (resultSet.next()) { + if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME")) + && childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME")) + && parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) { + return; + } + } + } + } + throw new AssertionError("Missing foreign key " + childTable + "." + childColumn + + " -> " + parentTable + "." + parentColumn); + } + + private String findTable(DatabaseMetaData metadata, String expected) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (expected.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return resultSet.getString("TABLE_NAME"); + } + } + } + return null; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java new file mode 100644 index 0000000..856f2b4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java @@ -0,0 +1,105 @@ +package com.mydreamjob.onboarding; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_seed;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class OnboardingSeedDataIntegrationTest { + + private static final Set SUPPORTED_CATEGORIES = Set.of( + "DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); + + private static final List EXPECTED_ITEMS = List.of( + new ExpectedStandardItem("standard-id-card", "准备身份证件", "DOCUMENT", 100, true), + new ExpectedStandardItem("standard-education-proof", "准备学历学位证明", "DOCUMENT", 110, true), + new ExpectedStandardItem("standard-id-photo", "准备证件照", "DOCUMENT", 120, false), + new ExpectedStandardItem("standard-bank-account", "提供工资卡信息", "ADMIN", 200, true), + new ExpectedStandardItem("standard-emergency-contact", "填写紧急联系人", "ADMIN", 210, true), + new ExpectedStandardItem("standard-background-check", "完成背景核验", "ADMIN", 220, false), + new ExpectedStandardItem("standard-medical-check", "确认入职体检要求", "ADMIN", 230, false), + new ExpectedStandardItem("standard-equipment-request", "确认办公设备需求", "EQUIPMENT", 300, false), + new ExpectedStandardItem("standard-report-location", "确认报到地址和时间", "LOGISTICS", 400, true), + new ExpectedStandardItem("standard-commute-plan", "规划通勤路线", "LOGISTICS", 410, false), + new ExpectedStandardItem("standard-compliance-training", "完成合规与保密培训", "TRAINING", 500, true), + new ExpectedStandardItem("standard-onboarding-guide", "阅读入职指南", "TRAINING", 510, false) + ); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void standardChecklistSeedIsStableAndComplete() { + List> rows = jdbcTemplate.queryForList( + "SELECT item_key, title, category, sort_order, required, active, " + + "scope_type, scope_id, company_id " + + "FROM onboarding_checklist_items " + + "WHERE scope_type = 'STANDARD' ORDER BY sort_order ASC, id ASC"); + + assertEquals(EXPECTED_ITEMS.size(), rows.size()); + for (int index = 0; index < EXPECTED_ITEMS.size(); index++) { + ExpectedStandardItem expected = EXPECTED_ITEMS.get(index); + Map actual = rows.get(index); + + assertEquals(expected.itemKey(), actual.get("item_key")); + assertEquals(expected.title(), actual.get("title")); + assertEquals(expected.category(), actual.get("category")); + assertEquals(expected.sortOrder(), ((Number) actual.get("sort_order")).intValue()); + assertEquals(expected.required(), asBoolean(actual.get("required"))); + assertTrue(asBoolean(actual.get("active"))); + assertEquals("STANDARD", actual.get("scope_type")); + assertEquals(0L, ((Number) actual.get("scope_id")).longValue()); + assertNull(actual.get("company_id")); + assertTrue(SUPPORTED_CATEGORIES.contains(expected.category())); + assertNotNull(actual.get("title")); + } + + assertEquals(EXPECTED_ITEMS.size(), count( + "SELECT COUNT(DISTINCT item_key) FROM onboarding_checklist_items " + + "WHERE scope_type = 'STANDARD'")); + } + + private int count(String sql) { + Integer count = jdbcTemplate.queryForObject(sql, Integer.class); + return count == null ? 0 : count; + } + + private boolean asBoolean(Object value) { + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + if (value instanceof Number numberValue) { + return numberValue.intValue() != 0; + } + return Boolean.parseBoolean(String.valueOf(value)); + } + + private record ExpectedStandardItem( + String itemKey, + String title, + String category, + int sortOrder, + boolean required + ) { + } +}