From 3a28dbd4c621f4088a9e38dfbe25f98f6bb86c9a Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 13:44:31 +0900 Subject: [PATCH 01/26] =?UTF-8?q?feat:=20Shared=20Kernel=20=E3=81=AB=20Aff?= =?UTF-8?q?iliation=20=E5=80=A4=E3=82=AA=E3=83=96=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=81=A8=20StudentId=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 静岡大学の全学部・研究科の組織構造を型レベルで表現し、 ValueObject パターンでランタイムバリデーション付きの Affiliation クラス群を実装。 - UndergraduateAffiliation / MasterAffiliation / DoctoralAffiliation / ProfessionalAffiliation - 学部ごとに異なる分類子(学科・課程・専攻・コース・専修)を discriminated union で表現 - StudentId 値オブジェクト(旧形式8桁 / 新形式3桁+英字+4桁) - 関連するドメイン例外を追加 Co-Authored-By: Claude Opus 4.6 --- src/domain/exceptions/DomainExceptions.ts | 21 ++ src/domain/shared/affiliation/Affiliation.ts | 35 ++- .../affiliation/affiliationValidator.ts | 288 ++++++++++++++++++ .../shared/affiliation/universityStructure.ts | 79 +---- 4 files changed, 347 insertions(+), 76 deletions(-) create mode 100644 src/domain/shared/affiliation/affiliationValidator.ts diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 0168334..d3da874 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -105,6 +105,27 @@ export class ExhibitHasMemberException extends DomainException { } } +export class InvalidAffiliationException extends DomainException { + constructor(detail: string) { + super(`無効な所属情報です: ${detail}`); + this.name = "InvalidAffiliationException"; + } +} + +export class InvalidDepartmentForFacultyException extends DomainException { + constructor(faculty: string, department: string) { + super(`学部「${faculty}」に学科「${department}」は存在しません`); + this.name = "InvalidDepartmentForFacultyException"; + } +} + +export class InvalidMajorForSchoolException extends DomainException { + constructor(school: string, major: string) { + super(`研究科「${school}」に専攻「${major}」は存在しません`); + this.name = "InvalidMajorForSchoolException"; + } +} + export class InvalidStudentIdException extends DomainException { constructor(studentId: string) { super( diff --git a/src/domain/shared/affiliation/Affiliation.ts b/src/domain/shared/affiliation/Affiliation.ts index 610446f..c216bbc 100644 --- a/src/domain/shared/affiliation/Affiliation.ts +++ b/src/domain/shared/affiliation/Affiliation.ts @@ -1,4 +1,10 @@ import { ValueObject } from "#domain/base/ValueObject"; +import { + validateDoctoralValue, + validateMasterValue, + validateProfessionalValue, + validateUndergraduateValue, +} from "./affiliationValidator"; import type { DoctoralAffiliationValue, MasterAffiliationValue, @@ -6,29 +12,32 @@ import type { UndergraduateAffiliationValue, } from "./universityStructure"; -/** 学部所属 */ export class UndergraduateAffiliation extends ValueObject { - protected validate(): void {} + protected validate(): void { + validateUndergraduateValue(this.value); + } } -/** 修士課程所属 */ export class MasterAffiliation extends ValueObject { - protected validate(): void {} + protected validate(): void { + validateMasterValue(this.value); + } } -/** 博士課程所属 */ export class DoctoralAffiliation extends ValueObject { - protected validate(): void {} + protected validate(): void { + validateDoctoralValue(this.value); + } } -/** 専門職学位課程所属 */ export class ProfessionalAffiliation extends ValueObject { - protected validate(): void {} + protected validate(): void { + validateProfessionalValue(this.value); + } } -/** 所属 — 学生が大学組織のどこに在籍しているかを表す */ export type Affiliation = - | UndergraduateAffiliation // 学部 - | MasterAffiliation // 修士 - | DoctoralAffiliation // 博士 - | ProfessionalAffiliation; // 専門職 + | UndergraduateAffiliation + | MasterAffiliation + | DoctoralAffiliation + | ProfessionalAffiliation; diff --git a/src/domain/shared/affiliation/affiliationValidator.ts b/src/domain/shared/affiliation/affiliationValidator.ts new file mode 100644 index 0000000..cc67c49 --- /dev/null +++ b/src/domain/shared/affiliation/affiliationValidator.ts @@ -0,0 +1,288 @@ +/** + * Affiliation値オブジェクトのランタイムバリデーション + * + * universityStructure.ts の型定義と対になるランタイム検証ロジック。 + * 外部入力(DB、API)から構築する際に、型レベルでは保証できない + * フィールド組み合わせの妥当性を検証する。 + */ +import { InvalidAffiliationException } from "#domain/exceptions"; + +// ── ヘルパー ── + +function fail(detail: string): never { + throw new InvalidAffiliationException(detail); +} + +function assertOneOf( + value: unknown, + valid: readonly string[], + label: string, +): asserts value is string { + if (typeof value !== "string" || !valid.includes(value)) { + fail(`無効な${label}です: ${value}`); + } +} + +function assertYearRange(year: unknown, max: number): void { + if ( + typeof year !== "number" || + !Number.isInteger(year) || + year < 1 || + year > max + ) { + fail(`無効な年次です: ${year} (1〜${max})`); + } +} + +// ── 学部バリデーションデータ ── + +const HUMANITIES_DEPTS: Readonly> = { + 昼間コース: ["社会学科", "言語文化学科", "法学科", "経済学科"], + 夜間主コース: ["法学科", "経済学科"], +}; + +const EDUCATION_SUBSPECIALTIES: Readonly< + Record +> = { + 発達教育学専攻: ["教育実践学専修", "教育心理学専修", "幼児教育専修"], + 初等学習開発学専攻: null, + 養護教育専攻: null, + 特別支援教育専攻: null, + 教科教育学専攻: [ + "国語教育専修", + "社会科教育専修", + "数学教育専修", + "理科教育専修", + "音楽教育専修", + "美術教育専修", + "保健体育教育専修", + "技術教育専修", + "家庭科教育専修", + "英語教育専修", + ], +}; + +const INFORMATICS_DEPTS = [ + "情報科学科", + "行動情報学科", + "情報社会学科", +] as const; + +const SCIENCE_DEPTS = [ + "数学科", + "物理学科", + "化学科", + "生物科学科", + "地球科学科", +] as const; + +const ENGINEERING_COURSES: Readonly> = + { + 機械工学科: [ + "宇宙・環境コース", + "知能・材料コース", + "電気機械システムコース", + ], + 電気電子工学科: [ + "情報エレクトロニクスコース", + "エネルギー・電子制御コース", + ], + 電子物質科学科: ["電子物理デバイスコース", "材料エネルギー化学コース"], + 化学バイオ工学科: ["環境応用化学コース", "バイオ応用工学コース"], + 数理システム工学科: null, + }; + +const AGRICULTURE_COURSES: Readonly> = + { + 生物資源科学科: ["バイオサイエンスコース", "環境サイエンスコース"], + 応用生命科学科: null, + }; + +const GLOBAL_COURSES = [ + "国際地域共生学コース", + "生命圏循環共生学コース", + "総合人間科学コース", +] as const; + +// ── 修士バリデーションデータ ── + +const HUMANITIES_MASTER_COURSES: Readonly> = { + 臨床人間科学専攻: ["臨床心理学コース", "臨床人間科学コース"], + 比較地域文化専攻: ["歴史・文化論コース", "言語文化論コース"], + 経済専攻: ["国際経営コース", "地域公共政策コース"], +}; + +const INTEGRATED_MASTER_COURSES: Readonly> = { + 情報学専攻: ["基盤情報学コース", "領域情報学コース"], + 理学専攻: [ + "数学コース", + "物理学コース", + "化学コース", + "生物科学コース", + "地球科学コース", + ], + 工学専攻: [ + "機械工学コース", + "電気電子工学コース", + "電子物質科学コース", + "化学バイオ工学コース", + "数理システム工学コース", + "事業開発マネジメントコース", + ], + 農学専攻: ["生物資源科学コース", "応用生命科学コース", "環境森林科学コース"], +}; + +// ── 博士バリデーションデータ ── + +const CREATIVE_DOCTORAL_MAJORS = [ + "ナノビジョン工学専攻", + "光・ナノ物質機能専攻", + "情報科学専攻", + "環境・エネルギーシステム専攻", + "バイオサイエンス専攻", +] as const; + +// ── バリデーション関数 ── + +export function validateUndergraduateValue(v: unknown): void { + const val = v as Record; + assertYearRange(val.year, 4); + + switch (val.faculty) { + case "人文社会科学部": { + assertOneOf( + val.enrollmentType, + Object.keys(HUMANITIES_DEPTS), + "課程区分", + ); + assertOneOf( + val.department, + HUMANITIES_DEPTS[val.enrollmentType as string], + "学科", + ); + break; + } + case "教育学部": { + if (val.program !== "学校教育教員養成課程") { + fail(`無効な課程です: ${val.program}`); + } + assertOneOf(val.major, Object.keys(EDUCATION_SUBSPECIALTIES), "専攻"); + const subs = EDUCATION_SUBSPECIALTIES[val.major as string]; + if (subs !== null) { + assertOneOf(val.subspecialty, subs, "専修"); + } + break; + } + case "情報学部": { + assertOneOf(val.department, [...INFORMATICS_DEPTS], "学科"); + break; + } + case "理学部": { + if ("department" in val && val.department !== undefined) { + assertOneOf(val.department, [...SCIENCE_DEPTS], "学科"); + } else if ("course" in val && val.course !== undefined) { + if (val.course !== "創造理学コース") { + fail(`無効なコースです: ${val.course}`); + } + } else { + fail("理学部には学科またはコースが必要です"); + } + break; + } + case "工学部": { + assertOneOf(val.department, Object.keys(ENGINEERING_COURSES), "学科"); + const courses = ENGINEERING_COURSES[val.department as string]; + if (courses !== null) { + assertOneOf(val.course, courses, "コース"); + } + break; + } + case "農学部": { + assertOneOf(val.department, Object.keys(AGRICULTURE_COURSES), "学科"); + const courses = AGRICULTURE_COURSES[val.department as string]; + if (courses !== null) { + assertOneOf(val.course, courses, "コース"); + } + break; + } + case "グローバル共創科学部": { + if (val.department !== "グローバル共創科学科") { + fail(`無効な学科です: ${val.department}`); + } + assertOneOf(val.course, [...GLOBAL_COURSES], "コース"); + break; + } + case "地域創造学環": + break; + default: + fail(`無効な学部です: ${val.faculty}`); + } +} + +export function validateMasterValue(v: unknown): void { + const val = v as Record; + assertYearRange(val.year, 2); + + switch (val.school) { + case "人文社会科学研究科": { + assertOneOf(val.major, Object.keys(HUMANITIES_MASTER_COURSES), "専攻"); + assertOneOf( + val.course, + HUMANITIES_MASTER_COURSES[val.major as string], + "コース", + ); + break; + } + case "総合科学技術研究科": { + assertOneOf(val.major, Object.keys(INTEGRATED_MASTER_COURSES), "専攻"); + assertOneOf( + val.course, + INTEGRATED_MASTER_COURSES[val.major as string], + "コース", + ); + break; + } + case "山岳流域研究院": + break; + default: + fail(`無効な研究科です: ${val.school}`); + } +} + +export function validateDoctoralValue(v: unknown): void { + const val = v as Record; + assertYearRange(val.year, 3); + + switch (val.school) { + case "創造科学技術大学院": { + assertOneOf(val.major, [...CREATIVE_DOCTORAL_MAJORS], "専攻"); + break; + } + case "教育学研究科": { + if (val.major !== "共同教科開発学専攻") { + fail(`無効な専攻です: ${val.major}`); + } + break; + } + case "光医工学研究科": { + if (val.major !== "光医工学共同専攻") { + fail(`無効な専攻です: ${val.major}`); + } + break; + } + default: + fail(`無効な研究科です: ${val.school}`); + } +} + +export function validateProfessionalValue(v: unknown): void { + const val = v as Record; + assertYearRange(val.year, 2); + + if (val.school !== "教育学研究科") { + fail(`無効な研究科です: ${val.school}`); + } + if (val.major !== "教育実践高度化専攻") { + fail(`無効な専攻です: ${val.major}`); + } +} diff --git a/src/domain/shared/affiliation/universityStructure.ts b/src/domain/shared/affiliation/universityStructure.ts index 5b15bc9..8ee5fd5 100644 --- a/src/domain/shared/affiliation/universityStructure.ts +++ b/src/domain/shared/affiliation/universityStructure.ts @@ -2,19 +2,8 @@ * 静岡大学の組織構造定義 * * 各学部・研究科の階層を型レベルで表現する。 - * - * フィールドとユビキタス言語の対応: - * - `faculty` — 学部(学部課程の組織単位) - * - `school` — 研究科・大学院(大学院課程の組織単位) - * - `department` — 学科 - * - `program` — 課程(例: 学校教育教員養成課程) - * - `major` — 専攻 - * - `course` — コース(専門分野の細分化) - * - `subspecialty` — 専修(コースよりさらに細分化された専門領域) - * - `enrollmentType` — 入学区分(昼間コース / 夜間主コース) - * - `year` — 在学年次 - * - * @see https://www.shizuoka.ac.jp/subject/ + * 分類子は実際の名称に対応: Faculty(学部), Department(学科), Program(課程), + * Major(専攻), Course(コース), Subspecialty(専修) */ // ── 年次 ── @@ -26,41 +15,26 @@ export type ProfessionalYear = 1 | 2; // ── 学部(Undergraduate) ── -/** 人文社会科学部 @see https://www.hss.shizuoka.ac.jp/ */ export type HumanitiesFacultyValue = | { - /** 学部 */ faculty: "人文社会科学部"; - /** 入学区分 */ enrollmentType: "昼間コース"; - /** 学科 */ department: "社会学科" | "言語文化学科" | "法学科" | "経済学科"; - /** 在学年次 */ year: UndergraduateYear; } | { - /** 学部 */ faculty: "人文社会科学部"; - /** 入学区分 */ enrollmentType: "夜間主コース"; - /** 学科 */ department: "法学科" | "経済学科"; - /** 在学年次 */ year: UndergraduateYear; }; -/** 教育学部 @see https://www.ed.shizuoka.ac.jp/applicants/about/organization/ */ export type EducationFacultyValue = | { - /** 学部 */ faculty: "教育学部"; - /** 課程 */ program: "学校教育教員養成課程"; - /** 専攻 */ major: "発達教育学専攻"; - /** 専修 */ subspecialty: "教育実践学専修" | "教育心理学専修" | "幼児教育専修"; - /** 在学年次 */ year: UndergraduateYear; } | { @@ -99,17 +73,12 @@ export type EducationFacultyValue = year: UndergraduateYear; }; -/** 情報学部 @see https://www.inf.shizuoka.ac.jp/ */ export type InformaticsFacultyValue = { - /** 学部 */ faculty: "情報学部"; - /** 学科 */ department: "情報科学科" | "行動情報学科" | "情報社会学科"; - /** 在学年次 */ year: UndergraduateYear; }; -/** 理学部 @see https://www.sci.shizuoka.ac.jp/dep_study */ export type ScienceFacultyValue = | { faculty: "理学部"; @@ -123,12 +92,10 @@ export type ScienceFacultyValue = } | { faculty: "理学部"; - /** コース */ course: "創造理学コース"; year: UndergraduateYear; }; -/** 工学部 @see https://www.eng.shizuoka.ac.jp/department/ */ export type EngineeringFacultyValue = | { faculty: "工学部"; @@ -163,7 +130,6 @@ export type EngineeringFacultyValue = year: UndergraduateYear; }; -/** 農学部 @see https://www.agr.shizuoka.ac.jp/ */ export type AgricultureFacultyValue = | { faculty: "農学部"; @@ -177,7 +143,6 @@ export type AgricultureFacultyValue = year: UndergraduateYear; }; -/** グローバル共創科学部 @see https://www.gkk.shizuoka.ac.jp/outline/courses/ */ export type GlobalCoCreationFacultyValue = { faculty: "グローバル共創科学部"; department: "グローバル共創科学科"; @@ -188,34 +153,28 @@ export type GlobalCoCreationFacultyValue = { year: UndergraduateYear; }; -/** 地域創造学環 @see https://www.srd.shizuoka.ac.jp/ */ export type RegionalDevelopmentValue = { faculty: "地域創造学環"; year: UndergraduateYear; }; export type UndergraduateAffiliationValue = - | HumanitiesFacultyValue // 人文社会科学部 - | EducationFacultyValue // 教育学部 - | InformaticsFacultyValue // 情報学部 - | ScienceFacultyValue // 理学部 - | EngineeringFacultyValue // 工学部 - | AgricultureFacultyValue // 農学部 - | GlobalCoCreationFacultyValue // グローバル共創科学部 - | RegionalDevelopmentValue; // 地域創造学環 + | HumanitiesFacultyValue + | EducationFacultyValue + | InformaticsFacultyValue + | ScienceFacultyValue + | EngineeringFacultyValue + | AgricultureFacultyValue + | GlobalCoCreationFacultyValue + | RegionalDevelopmentValue; // ── 修士課程(Master) ── -/** 人文社会科学研究科 @see https://www.hss.shizuoka.ac.jp/ghss/ */ export type HumanitiesMasterValue = | { - /** 研究科・大学院 */ school: "人文社会科学研究科"; - /** 専攻 */ major: "臨床人間科学専攻"; - /** コース */ course: "臨床心理学コース" | "臨床人間科学コース"; - /** 在学年次 */ year: MasterYear; } | { @@ -231,7 +190,6 @@ export type HumanitiesMasterValue = year: MasterYear; }; -/** 総合科学技術研究科 @see https://www.shizuoka.ac.jp/subject/graduate/stg/ */ export type IntegratedSciTechMasterValue = | { school: "総合科学技術研究科"; @@ -272,20 +230,18 @@ export type IntegratedSciTechMasterValue = year: MasterYear; }; -/** 山岳流域研究院 @see https://www.igsmw.shizuoka.ac.jp/ */ export type MountainWatershedValue = { school: "山岳流域研究院"; year: MasterYear; }; export type MasterAffiliationValue = - | HumanitiesMasterValue // 人文社会科学研究科 - | IntegratedSciTechMasterValue // 総合科学技術研究科 - | MountainWatershedValue; // 山岳流域研究院 + | HumanitiesMasterValue + | IntegratedSciTechMasterValue + | MountainWatershedValue; // ── 博士課程(Doctoral) ── -/** 創造科学技術大学院 @see https://gsst.shizuoka.ac.jp/ */ export type CreativeSciTechDoctoralValue = { school: "創造科学技術大学院"; major: @@ -297,14 +253,12 @@ export type CreativeSciTechDoctoralValue = { year: DoctoralYear; }; -/** 教育学研究科(博士) @see https://subdev.ed.shizuoka.ac.jp/ */ export type EducationDoctoralValue = { school: "教育学研究科"; major: "共同教科開発学専攻"; year: DoctoralYear; }; -/** 光医工学研究科 @see https://www.cmmp.shizuoka.ac.jp/ */ export type OptoBiomedicalDoctoralValue = { school: "光医工学研究科"; major: "光医工学共同専攻"; @@ -312,13 +266,12 @@ export type OptoBiomedicalDoctoralValue = { }; export type DoctoralAffiliationValue = - | CreativeSciTechDoctoralValue // 創造科学技術大学院 - | EducationDoctoralValue // 教育学研究科 - | OptoBiomedicalDoctoralValue; // 光医工学研究科 + | CreativeSciTechDoctoralValue + | EducationDoctoralValue + | OptoBiomedicalDoctoralValue; // ── 専門職学位課程(Professional) ── -/** 教育学研究科(専門職) @see https://dapse2.ed.shizuoka.ac.jp/ */ export type ProfessionalAffiliationValue = { school: "教育学研究科"; major: "教育実践高度化専攻"; From 03f2f0b866896c379f9a12f2b0260e4fb15dfe1d Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 13:48:55 +0900 Subject: [PATCH 02/26] =?UTF-8?q?refactor:=20Affiliation=E3=81=AE=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=90=E3=83=AA=E3=83=87?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 型レベルの判別共用体で制約が保証されるため、 ランタイムバリデーションはValueObjectの責務ではなくシステム境界の責務とする。 Co-Authored-By: Claude Opus 4.6 --- src/domain/shared/affiliation/Affiliation.ts | 22 +- .../affiliation/affiliationValidator.ts | 288 ------------------ 2 files changed, 4 insertions(+), 306 deletions(-) delete mode 100644 src/domain/shared/affiliation/affiliationValidator.ts diff --git a/src/domain/shared/affiliation/Affiliation.ts b/src/domain/shared/affiliation/Affiliation.ts index c216bbc..5b04478 100644 --- a/src/domain/shared/affiliation/Affiliation.ts +++ b/src/domain/shared/affiliation/Affiliation.ts @@ -1,10 +1,4 @@ import { ValueObject } from "#domain/base/ValueObject"; -import { - validateDoctoralValue, - validateMasterValue, - validateProfessionalValue, - validateUndergraduateValue, -} from "./affiliationValidator"; import type { DoctoralAffiliationValue, MasterAffiliationValue, @@ -13,27 +7,19 @@ import type { } from "./universityStructure"; export class UndergraduateAffiliation extends ValueObject { - protected validate(): void { - validateUndergraduateValue(this.value); - } + protected validate(): void {} } export class MasterAffiliation extends ValueObject { - protected validate(): void { - validateMasterValue(this.value); - } + protected validate(): void {} } export class DoctoralAffiliation extends ValueObject { - protected validate(): void { - validateDoctoralValue(this.value); - } + protected validate(): void {} } export class ProfessionalAffiliation extends ValueObject { - protected validate(): void { - validateProfessionalValue(this.value); - } + protected validate(): void {} } export type Affiliation = diff --git a/src/domain/shared/affiliation/affiliationValidator.ts b/src/domain/shared/affiliation/affiliationValidator.ts deleted file mode 100644 index cc67c49..0000000 --- a/src/domain/shared/affiliation/affiliationValidator.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Affiliation値オブジェクトのランタイムバリデーション - * - * universityStructure.ts の型定義と対になるランタイム検証ロジック。 - * 外部入力(DB、API)から構築する際に、型レベルでは保証できない - * フィールド組み合わせの妥当性を検証する。 - */ -import { InvalidAffiliationException } from "#domain/exceptions"; - -// ── ヘルパー ── - -function fail(detail: string): never { - throw new InvalidAffiliationException(detail); -} - -function assertOneOf( - value: unknown, - valid: readonly string[], - label: string, -): asserts value is string { - if (typeof value !== "string" || !valid.includes(value)) { - fail(`無効な${label}です: ${value}`); - } -} - -function assertYearRange(year: unknown, max: number): void { - if ( - typeof year !== "number" || - !Number.isInteger(year) || - year < 1 || - year > max - ) { - fail(`無効な年次です: ${year} (1〜${max})`); - } -} - -// ── 学部バリデーションデータ ── - -const HUMANITIES_DEPTS: Readonly> = { - 昼間コース: ["社会学科", "言語文化学科", "法学科", "経済学科"], - 夜間主コース: ["法学科", "経済学科"], -}; - -const EDUCATION_SUBSPECIALTIES: Readonly< - Record -> = { - 発達教育学専攻: ["教育実践学専修", "教育心理学専修", "幼児教育専修"], - 初等学習開発学専攻: null, - 養護教育専攻: null, - 特別支援教育専攻: null, - 教科教育学専攻: [ - "国語教育専修", - "社会科教育専修", - "数学教育専修", - "理科教育専修", - "音楽教育専修", - "美術教育専修", - "保健体育教育専修", - "技術教育専修", - "家庭科教育専修", - "英語教育専修", - ], -}; - -const INFORMATICS_DEPTS = [ - "情報科学科", - "行動情報学科", - "情報社会学科", -] as const; - -const SCIENCE_DEPTS = [ - "数学科", - "物理学科", - "化学科", - "生物科学科", - "地球科学科", -] as const; - -const ENGINEERING_COURSES: Readonly> = - { - 機械工学科: [ - "宇宙・環境コース", - "知能・材料コース", - "電気機械システムコース", - ], - 電気電子工学科: [ - "情報エレクトロニクスコース", - "エネルギー・電子制御コース", - ], - 電子物質科学科: ["電子物理デバイスコース", "材料エネルギー化学コース"], - 化学バイオ工学科: ["環境応用化学コース", "バイオ応用工学コース"], - 数理システム工学科: null, - }; - -const AGRICULTURE_COURSES: Readonly> = - { - 生物資源科学科: ["バイオサイエンスコース", "環境サイエンスコース"], - 応用生命科学科: null, - }; - -const GLOBAL_COURSES = [ - "国際地域共生学コース", - "生命圏循環共生学コース", - "総合人間科学コース", -] as const; - -// ── 修士バリデーションデータ ── - -const HUMANITIES_MASTER_COURSES: Readonly> = { - 臨床人間科学専攻: ["臨床心理学コース", "臨床人間科学コース"], - 比較地域文化専攻: ["歴史・文化論コース", "言語文化論コース"], - 経済専攻: ["国際経営コース", "地域公共政策コース"], -}; - -const INTEGRATED_MASTER_COURSES: Readonly> = { - 情報学専攻: ["基盤情報学コース", "領域情報学コース"], - 理学専攻: [ - "数学コース", - "物理学コース", - "化学コース", - "生物科学コース", - "地球科学コース", - ], - 工学専攻: [ - "機械工学コース", - "電気電子工学コース", - "電子物質科学コース", - "化学バイオ工学コース", - "数理システム工学コース", - "事業開発マネジメントコース", - ], - 農学専攻: ["生物資源科学コース", "応用生命科学コース", "環境森林科学コース"], -}; - -// ── 博士バリデーションデータ ── - -const CREATIVE_DOCTORAL_MAJORS = [ - "ナノビジョン工学専攻", - "光・ナノ物質機能専攻", - "情報科学専攻", - "環境・エネルギーシステム専攻", - "バイオサイエンス専攻", -] as const; - -// ── バリデーション関数 ── - -export function validateUndergraduateValue(v: unknown): void { - const val = v as Record; - assertYearRange(val.year, 4); - - switch (val.faculty) { - case "人文社会科学部": { - assertOneOf( - val.enrollmentType, - Object.keys(HUMANITIES_DEPTS), - "課程区分", - ); - assertOneOf( - val.department, - HUMANITIES_DEPTS[val.enrollmentType as string], - "学科", - ); - break; - } - case "教育学部": { - if (val.program !== "学校教育教員養成課程") { - fail(`無効な課程です: ${val.program}`); - } - assertOneOf(val.major, Object.keys(EDUCATION_SUBSPECIALTIES), "専攻"); - const subs = EDUCATION_SUBSPECIALTIES[val.major as string]; - if (subs !== null) { - assertOneOf(val.subspecialty, subs, "専修"); - } - break; - } - case "情報学部": { - assertOneOf(val.department, [...INFORMATICS_DEPTS], "学科"); - break; - } - case "理学部": { - if ("department" in val && val.department !== undefined) { - assertOneOf(val.department, [...SCIENCE_DEPTS], "学科"); - } else if ("course" in val && val.course !== undefined) { - if (val.course !== "創造理学コース") { - fail(`無効なコースです: ${val.course}`); - } - } else { - fail("理学部には学科またはコースが必要です"); - } - break; - } - case "工学部": { - assertOneOf(val.department, Object.keys(ENGINEERING_COURSES), "学科"); - const courses = ENGINEERING_COURSES[val.department as string]; - if (courses !== null) { - assertOneOf(val.course, courses, "コース"); - } - break; - } - case "農学部": { - assertOneOf(val.department, Object.keys(AGRICULTURE_COURSES), "学科"); - const courses = AGRICULTURE_COURSES[val.department as string]; - if (courses !== null) { - assertOneOf(val.course, courses, "コース"); - } - break; - } - case "グローバル共創科学部": { - if (val.department !== "グローバル共創科学科") { - fail(`無効な学科です: ${val.department}`); - } - assertOneOf(val.course, [...GLOBAL_COURSES], "コース"); - break; - } - case "地域創造学環": - break; - default: - fail(`無効な学部です: ${val.faculty}`); - } -} - -export function validateMasterValue(v: unknown): void { - const val = v as Record; - assertYearRange(val.year, 2); - - switch (val.school) { - case "人文社会科学研究科": { - assertOneOf(val.major, Object.keys(HUMANITIES_MASTER_COURSES), "専攻"); - assertOneOf( - val.course, - HUMANITIES_MASTER_COURSES[val.major as string], - "コース", - ); - break; - } - case "総合科学技術研究科": { - assertOneOf(val.major, Object.keys(INTEGRATED_MASTER_COURSES), "専攻"); - assertOneOf( - val.course, - INTEGRATED_MASTER_COURSES[val.major as string], - "コース", - ); - break; - } - case "山岳流域研究院": - break; - default: - fail(`無効な研究科です: ${val.school}`); - } -} - -export function validateDoctoralValue(v: unknown): void { - const val = v as Record; - assertYearRange(val.year, 3); - - switch (val.school) { - case "創造科学技術大学院": { - assertOneOf(val.major, [...CREATIVE_DOCTORAL_MAJORS], "専攻"); - break; - } - case "教育学研究科": { - if (val.major !== "共同教科開発学専攻") { - fail(`無効な専攻です: ${val.major}`); - } - break; - } - case "光医工学研究科": { - if (val.major !== "光医工学共同専攻") { - fail(`無効な専攻です: ${val.major}`); - } - break; - } - default: - fail(`無効な研究科です: ${val.school}`); - } -} - -export function validateProfessionalValue(v: unknown): void { - const val = v as Record; - assertYearRange(val.year, 2); - - if (val.school !== "教育学研究科") { - fail(`無効な研究科です: ${val.school}`); - } - if (val.major !== "教育実践高度化専攻") { - fail(`無効な専攻です: ${val.major}`); - } -} From 02b34db9059b6531ab6319d1f0cac7fafb79247e Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 14:08:22 +0900 Subject: [PATCH 03/26] =?UTF-8?q?docs:=20=E5=9E=8B=E5=AE=9A=E7=BE=A9?= =?UTF-8?q?=E3=81=ABJSDoc=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=81=A8?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E3=83=9A=E3=83=BC=E3=82=B8URL=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各学部・研究科の型定義に日本語名のJSDocコメントと 公式組織構成ページへの@seeリンクを追加。 バリデーター削除で不要になった例外クラスも削除。 Co-Authored-By: Claude Opus 4.6 --- src/domain/exceptions/DomainExceptions.ts | 14 ------ src/domain/shared/affiliation/Affiliation.ts | 12 +++-- .../shared/affiliation/universityStructure.ts | 46 +++++++++++++------ 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index d3da874..dc68586 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -112,20 +112,6 @@ export class InvalidAffiliationException extends DomainException { } } -export class InvalidDepartmentForFacultyException extends DomainException { - constructor(faculty: string, department: string) { - super(`学部「${faculty}」に学科「${department}」は存在しません`); - this.name = "InvalidDepartmentForFacultyException"; - } -} - -export class InvalidMajorForSchoolException extends DomainException { - constructor(school: string, major: string) { - super(`研究科「${school}」に専攻「${major}」は存在しません`); - this.name = "InvalidMajorForSchoolException"; - } -} - export class InvalidStudentIdException extends DomainException { constructor(studentId: string) { super( diff --git a/src/domain/shared/affiliation/Affiliation.ts b/src/domain/shared/affiliation/Affiliation.ts index 5b04478..846832d 100644 --- a/src/domain/shared/affiliation/Affiliation.ts +++ b/src/domain/shared/affiliation/Affiliation.ts @@ -6,24 +6,28 @@ import type { UndergraduateAffiliationValue, } from "./universityStructure"; +/** 学部所属 */ export class UndergraduateAffiliation extends ValueObject { protected validate(): void {} } +/** 修士課程所属 */ export class MasterAffiliation extends ValueObject { protected validate(): void {} } +/** 博士課程所属 */ export class DoctoralAffiliation extends ValueObject { protected validate(): void {} } +/** 専門職学位課程所属 */ export class ProfessionalAffiliation extends ValueObject { protected validate(): void {} } export type Affiliation = - | UndergraduateAffiliation - | MasterAffiliation - | DoctoralAffiliation - | ProfessionalAffiliation; + | UndergraduateAffiliation // 学部 + | MasterAffiliation // 修士 + | DoctoralAffiliation // 博士 + | ProfessionalAffiliation; // 専門職 diff --git a/src/domain/shared/affiliation/universityStructure.ts b/src/domain/shared/affiliation/universityStructure.ts index 8ee5fd5..e9401af 100644 --- a/src/domain/shared/affiliation/universityStructure.ts +++ b/src/domain/shared/affiliation/universityStructure.ts @@ -4,6 +4,9 @@ * 各学部・研究科の階層を型レベルで表現する。 * 分類子は実際の名称に対応: Faculty(学部), Department(学科), Program(課程), * Major(専攻), Course(コース), Subspecialty(専修) + * + * @see https://www.shizuoka.ac.jp/subject/ + * @see https://www.ed.shizuoka.ac.jp/applicants/about/organization/ */ // ── 年次 ── @@ -15,6 +18,7 @@ export type ProfessionalYear = 1 | 2; // ── 学部(Undergraduate) ── +/** 人文社会科学部 @see https://www.hss.shizuoka.ac.jp/ */ export type HumanitiesFacultyValue = | { faculty: "人文社会科学部"; @@ -29,6 +33,7 @@ export type HumanitiesFacultyValue = year: UndergraduateYear; }; +/** 教育学部 @see https://www.ed.shizuoka.ac.jp/applicants/about/organization/ */ export type EducationFacultyValue = | { faculty: "教育学部"; @@ -73,12 +78,14 @@ export type EducationFacultyValue = year: UndergraduateYear; }; +/** 情報学部 @see https://www.inf.shizuoka.ac.jp/ */ export type InformaticsFacultyValue = { faculty: "情報学部"; department: "情報科学科" | "行動情報学科" | "情報社会学科"; year: UndergraduateYear; }; +/** 理学部 @see https://www.sci.shizuoka.ac.jp/dep_study */ export type ScienceFacultyValue = | { faculty: "理学部"; @@ -96,6 +103,7 @@ export type ScienceFacultyValue = year: UndergraduateYear; }; +/** 工学部 @see https://www.eng.shizuoka.ac.jp/department/ */ export type EngineeringFacultyValue = | { faculty: "工学部"; @@ -130,6 +138,7 @@ export type EngineeringFacultyValue = year: UndergraduateYear; }; +/** 農学部 @see https://www.agr.shizuoka.ac.jp/ */ export type AgricultureFacultyValue = | { faculty: "農学部"; @@ -143,6 +152,7 @@ export type AgricultureFacultyValue = year: UndergraduateYear; }; +/** グローバル共創科学部 @see https://www.gkk.shizuoka.ac.jp/outline/courses/ */ export type GlobalCoCreationFacultyValue = { faculty: "グローバル共創科学部"; department: "グローバル共創科学科"; @@ -153,23 +163,25 @@ export type GlobalCoCreationFacultyValue = { year: UndergraduateYear; }; +/** 地域創造学環 @see https://www.srd.shizuoka.ac.jp/ */ export type RegionalDevelopmentValue = { faculty: "地域創造学環"; year: UndergraduateYear; }; export type UndergraduateAffiliationValue = - | HumanitiesFacultyValue - | EducationFacultyValue - | InformaticsFacultyValue - | ScienceFacultyValue - | EngineeringFacultyValue - | AgricultureFacultyValue - | GlobalCoCreationFacultyValue - | RegionalDevelopmentValue; + | HumanitiesFacultyValue // 人文社会科学部 + | EducationFacultyValue // 教育学部 + | InformaticsFacultyValue // 情報学部 + | ScienceFacultyValue // 理学部 + | EngineeringFacultyValue // 工学部 + | AgricultureFacultyValue // 農学部 + | GlobalCoCreationFacultyValue // グローバル共創科学部 + | RegionalDevelopmentValue; // 地域創造学環 // ── 修士課程(Master) ── +/** 人文社会科学研究科 @see https://www.hss.shizuoka.ac.jp/ghss/ */ export type HumanitiesMasterValue = | { school: "人文社会科学研究科"; @@ -190,6 +202,7 @@ export type HumanitiesMasterValue = year: MasterYear; }; +/** 総合科学技術研究科 @see https://www.shizuoka.ac.jp/subject/graduate/stg/ */ export type IntegratedSciTechMasterValue = | { school: "総合科学技術研究科"; @@ -230,18 +243,20 @@ export type IntegratedSciTechMasterValue = year: MasterYear; }; +/** 山岳流域研究院 @see https://www.igsmw.shizuoka.ac.jp/ */ export type MountainWatershedValue = { school: "山岳流域研究院"; year: MasterYear; }; export type MasterAffiliationValue = - | HumanitiesMasterValue - | IntegratedSciTechMasterValue - | MountainWatershedValue; + | HumanitiesMasterValue // 人文社会科学研究科 + | IntegratedSciTechMasterValue // 総合科学技術研究科 + | MountainWatershedValue; // 山岳流域研究院 // ── 博士課程(Doctoral) ── +/** 創造科学技術大学院 @see https://gsst.shizuoka.ac.jp/ */ export type CreativeSciTechDoctoralValue = { school: "創造科学技術大学院"; major: @@ -253,12 +268,14 @@ export type CreativeSciTechDoctoralValue = { year: DoctoralYear; }; +/** 教育学研究科(博士) @see https://subdev.ed.shizuoka.ac.jp/ */ export type EducationDoctoralValue = { school: "教育学研究科"; major: "共同教科開発学専攻"; year: DoctoralYear; }; +/** 光医工学研究科 @see https://www.cmmp.shizuoka.ac.jp/ */ export type OptoBiomedicalDoctoralValue = { school: "光医工学研究科"; major: "光医工学共同専攻"; @@ -266,12 +283,13 @@ export type OptoBiomedicalDoctoralValue = { }; export type DoctoralAffiliationValue = - | CreativeSciTechDoctoralValue - | EducationDoctoralValue - | OptoBiomedicalDoctoralValue; + | CreativeSciTechDoctoralValue // 創造科学技術大学院 + | EducationDoctoralValue // 教育学研究科 + | OptoBiomedicalDoctoralValue; // 光医工学研究科 // ── 専門職学位課程(Professional) ── +/** 教育学研究科(専門職) @see https://dapse2.ed.shizuoka.ac.jp/ */ export type ProfessionalAffiliationValue = { school: "教育学研究科"; major: "教育実践高度化専攻"; From 7f8a68710f867196aceef7ab56a7fae5d2917b5c Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 16:16:41 +0900 Subject: [PATCH 04/26] =?UTF-8?q?refactor:=20=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E3=81=AEInvalidAffiliationException=E3=82=92=E5=89=8A=E9=99=A4?= =?UTF-8?q?=E3=81=97=E3=80=81=E7=B5=84=E7=B9=94=E6=A7=8B=E9=80=A0=E5=9E=8B?= =?UTF-8?q?=E3=81=ABJSDoc=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 型レベルで安全性を保証する設計のため不要だったInvalidAffiliationExceptionを削除。 universityStructure.tsのフィールドにユビキタス言語との対応を示すJSDocを追加。 Co-Authored-By: Claude Opus 4.6 --- src/domain/exceptions/DomainExceptions.ts | 8 ----- .../shared/affiliation/universityStructure.ts | 34 +++++++++++++++++-- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index dc68586..339e71a 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -104,14 +104,6 @@ export class ExhibitHasMemberException extends DomainException { this.name = "ExhibitHasMemberException"; } } - -export class InvalidAffiliationException extends DomainException { - constructor(detail: string) { - super(`無効な所属情報です: ${detail}`); - this.name = "InvalidAffiliationException"; - } -} - export class InvalidStudentIdException extends DomainException { constructor(studentId: string) { super( diff --git a/src/domain/shared/affiliation/universityStructure.ts b/src/domain/shared/affiliation/universityStructure.ts index e9401af..a61b9c2 100644 --- a/src/domain/shared/affiliation/universityStructure.ts +++ b/src/domain/shared/affiliation/universityStructure.ts @@ -2,8 +2,17 @@ * 静岡大学の組織構造定義 * * 各学部・研究科の階層を型レベルで表現する。 - * 分類子は実際の名称に対応: Faculty(学部), Department(学科), Program(課程), - * Major(専攻), Course(コース), Subspecialty(専修) + * + * フィールドとユビキタス言語の対応: + * - `faculty` — 学部(学部課程の組織単位) + * - `school` — 研究科・大学院(大学院課程の組織単位) + * - `department` — 学科 + * - `program` — 課程(例: 学校教育教員養成課程) + * - `major` — 専攻 + * - `course` — コース(専門分野の細分化) + * - `subspecialty` — 専修(コースよりさらに細分化された専門領域) + * - `enrollmentType` — 入学区分(昼間コース / 夜間主コース) + * - `year` — 在学年次 * * @see https://www.shizuoka.ac.jp/subject/ * @see https://www.ed.shizuoka.ac.jp/applicants/about/organization/ @@ -21,25 +30,38 @@ export type ProfessionalYear = 1 | 2; /** 人文社会科学部 @see https://www.hss.shizuoka.ac.jp/ */ export type HumanitiesFacultyValue = | { + /** 学部 */ faculty: "人文社会科学部"; + /** 入学区分 */ enrollmentType: "昼間コース"; + /** 学科 */ department: "社会学科" | "言語文化学科" | "法学科" | "経済学科"; + /** 在学年次 */ year: UndergraduateYear; } | { + /** 学部 */ faculty: "人文社会科学部"; + /** 入学区分 */ enrollmentType: "夜間主コース"; + /** 学科 */ department: "法学科" | "経済学科"; + /** 在学年次 */ year: UndergraduateYear; }; /** 教育学部 @see https://www.ed.shizuoka.ac.jp/applicants/about/organization/ */ export type EducationFacultyValue = | { + /** 学部 */ faculty: "教育学部"; + /** 課程 */ program: "学校教育教員養成課程"; + /** 専攻 */ major: "発達教育学専攻"; + /** 専修 */ subspecialty: "教育実践学専修" | "教育心理学専修" | "幼児教育専修"; + /** 在学年次 */ year: UndergraduateYear; } | { @@ -80,8 +102,11 @@ export type EducationFacultyValue = /** 情報学部 @see https://www.inf.shizuoka.ac.jp/ */ export type InformaticsFacultyValue = { + /** 学部 */ faculty: "情報学部"; + /** 学科 */ department: "情報科学科" | "行動情報学科" | "情報社会学科"; + /** 在学年次 */ year: UndergraduateYear; }; @@ -99,6 +124,7 @@ export type ScienceFacultyValue = } | { faculty: "理学部"; + /** コース */ course: "創造理学コース"; year: UndergraduateYear; }; @@ -184,9 +210,13 @@ export type UndergraduateAffiliationValue = /** 人文社会科学研究科 @see https://www.hss.shizuoka.ac.jp/ghss/ */ export type HumanitiesMasterValue = | { + /** 研究科・大学院 */ school: "人文社会科学研究科"; + /** 専攻 */ major: "臨床人間科学専攻"; + /** コース */ course: "臨床心理学コース" | "臨床人間科学コース"; + /** 在学年次 */ year: MasterYear; } | { From 770a75dc9a1e93479142ecc83f16c700cc41300c Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 16:19:20 +0900 Subject: [PATCH 05/26] =?UTF-8?q?refactor:=20=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=83=AC=E3=83=99=E3=83=ABJSDoc=E3=81=8B=E3=82=89?= =?UTF-8?q?=E6=95=99=E8=82=B2=E5=AD=A6=E9=83=A8=E5=9B=BA=E6=9C=89=E3=81=AE?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=AF=E3=82=92=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 教育学部のURLは EducationFacultyValue の型JSDocに既にあるため重複を解消。 Co-Authored-By: Claude Opus 4.6 --- src/domain/shared/affiliation/universityStructure.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/shared/affiliation/universityStructure.ts b/src/domain/shared/affiliation/universityStructure.ts index a61b9c2..5b15bc9 100644 --- a/src/domain/shared/affiliation/universityStructure.ts +++ b/src/domain/shared/affiliation/universityStructure.ts @@ -15,7 +15,6 @@ * - `year` — 在学年次 * * @see https://www.shizuoka.ac.jp/subject/ - * @see https://www.ed.shizuoka.ac.jp/applicants/about/organization/ */ // ── 年次 ── From afe72d7b0fc738f51552b6bc995fc7cd2c3e3b90 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 16:21:48 +0900 Subject: [PATCH 06/26] =?UTF-8?q?style:=20DomainExceptions.ts=E3=81=AE?= =?UTF-8?q?=E7=A9=BA=E8=A1=8C=E3=82=92develop=E3=81=A8=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/domain/exceptions/DomainExceptions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 339e71a..0168334 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -104,6 +104,7 @@ export class ExhibitHasMemberException extends DomainException { this.name = "ExhibitHasMemberException"; } } + export class InvalidStudentIdException extends DomainException { constructor(studentId: string) { super( From 65d74c48a8535a85870de625a5b7aeb20e852020 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 16:22:44 +0900 Subject: [PATCH 07/26] =?UTF-8?q?docs:=20Affiliation=E5=9E=8B=E3=81=AB?= =?UTF-8?q?=E3=83=A6=E3=83=93=E3=82=AD=E3=82=BF=E3=82=B9=E8=A8=80=E8=AA=9E?= =?UTF-8?q?=E3=80=8C=E6=89=80=E5=B1=9E=E3=80=8D=E3=81=A8=E3=81=AE=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=82=92JSDoc=E3=81=A7=E6=98=8E=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/domain/shared/affiliation/Affiliation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/shared/affiliation/Affiliation.ts b/src/domain/shared/affiliation/Affiliation.ts index 846832d..610446f 100644 --- a/src/domain/shared/affiliation/Affiliation.ts +++ b/src/domain/shared/affiliation/Affiliation.ts @@ -26,6 +26,7 @@ export class ProfessionalAffiliation extends ValueObject Date: Thu, 12 Mar 2026 14:21:48 +0900 Subject: [PATCH 08/26] =?UTF-8?q?feat:=20PC=E7=9B=B8=E8=AB=87=E5=AE=A4?= =?UTF-8?q?=E3=82=AB=E3=83=AB=E3=83=86=E9=9B=86=E7=B4=84=E3=81=AE=E3=83=89?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同意事項・相談事・対応事という3つのドメイン概念で構成される 深いモデルとしてKarte集約を設計した。 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/index.ts | 1 + src/domain/aggregates/karte/Client.ts | 17 +++ src/domain/aggregates/karte/Consent.ts | 10 ++ src/domain/aggregates/karte/Consultation.ts | 13 ++ .../aggregates/karte/ConsultationCategory.ts | 34 ++++++ .../aggregates/karte/FollowUpDestination.ts | 1 + src/domain/aggregates/karte/Karte.ts | 111 ++++++++++++++++++ .../aggregates/karte/KarteRepository.ts | 9 ++ src/domain/aggregates/karte/Resolution.ts | 1 + src/domain/aggregates/karte/Response.ts | 17 +++ src/domain/aggregates/karte/WorkDuration.ts | 9 ++ src/domain/aggregates/karte/index.ts | 10 ++ src/domain/exceptions/DomainExceptions.ts | 14 +++ 13 files changed, 247 insertions(+) create mode 100644 src/domain/aggregates/karte/Client.ts create mode 100644 src/domain/aggregates/karte/Consent.ts create mode 100644 src/domain/aggregates/karte/Consultation.ts create mode 100644 src/domain/aggregates/karte/ConsultationCategory.ts create mode 100644 src/domain/aggregates/karte/FollowUpDestination.ts create mode 100644 src/domain/aggregates/karte/Karte.ts create mode 100644 src/domain/aggregates/karte/KarteRepository.ts create mode 100644 src/domain/aggregates/karte/Resolution.ts create mode 100644 src/domain/aggregates/karte/Response.ts create mode 100644 src/domain/aggregates/karte/WorkDuration.ts create mode 100644 src/domain/aggregates/karte/index.ts diff --git a/src/domain/aggregates/index.ts b/src/domain/aggregates/index.ts index f9ff458..233da61 100644 --- a/src/domain/aggregates/index.ts +++ b/src/domain/aggregates/index.ts @@ -1,2 +1,3 @@ export * from "./event"; +export * from "./karte"; export * from "./member"; diff --git a/src/domain/aggregates/karte/Client.ts b/src/domain/aggregates/karte/Client.ts new file mode 100644 index 0000000..8572ffe --- /dev/null +++ b/src/domain/aggregates/karte/Client.ts @@ -0,0 +1,17 @@ +import type { Affiliation } from "#domain/shared"; +import type { StudentId } from "#domain/shared"; + +type StudentClient = { + readonly type: "student"; + readonly studentId: StudentId; + readonly name: string; + readonly affiliation: Affiliation; +}; + +type StaffClient = { + readonly type: "staff"; + readonly name: string; + readonly affiliation: Affiliation; +}; + +export type Client = StudentClient | StaffClient; diff --git a/src/domain/aggregates/karte/Consent.ts b/src/domain/aggregates/karte/Consent.ts new file mode 100644 index 0000000..edc3734 --- /dev/null +++ b/src/domain/aggregates/karte/Consent.ts @@ -0,0 +1,10 @@ +/** + * 同意事項 + * + * カルテ記録における免責同意と情報公開同意をまとめた値オブジェクト。 + * カルテは同意なしでも作成できるため、各フィールドはbooleanで表現する。 + */ +export type Consent = { + readonly liabilityConsent: boolean; + readonly disclosureConsent: boolean; +}; diff --git a/src/domain/aggregates/karte/Consultation.ts b/src/domain/aggregates/karte/Consultation.ts new file mode 100644 index 0000000..e619b0b --- /dev/null +++ b/src/domain/aggregates/karte/Consultation.ts @@ -0,0 +1,13 @@ +import type { ConsultationCategory } from "./ConsultationCategory"; + +/** + * 相談事 + * + * 相談者が持ち込んだトラブルの内容をまとめた値オブジェクト。 + * カテゴリは複数選択可能。 + */ +export type Consultation = { + readonly categories: readonly ConsultationCategory[]; + readonly targetDevice: string; + readonly troubleDetails: string; +}; diff --git a/src/domain/aggregates/karte/ConsultationCategory.ts b/src/domain/aggregates/karte/ConsultationCategory.ts new file mode 100644 index 0000000..3ab8983 --- /dev/null +++ b/src/domain/aggregates/karte/ConsultationCategory.ts @@ -0,0 +1,34 @@ +import { ValueObject } from "#domain/base/ValueObject"; +import { InvalidConsultationCategoryException } from "#domain/exceptions"; + +type ConsultationCategoryValue = { + readonly id: string; + readonly displayName: string; +}; + +/** + * 相談カテゴリ + * + * PC相談室で扱うトラブルの分類タグ。 + * IDと表示名のペアで構成され、追加はできるが削除はできない(履歴整合性のため)。 + */ +export class ConsultationCategory extends ValueObject { + protected validate(): void { + this.throwIfInvalid( + this.value.id.trim().length > 0, + new InvalidConsultationCategoryException("IDが空です"), + ); + this.throwIfInvalid( + this.value.displayName.trim().length > 0, + new InvalidConsultationCategoryException("表示名が空です"), + ); + } + + get id(): string { + return this.value.id; + } + + get displayName(): string { + return this.value.displayName; + } +} diff --git a/src/domain/aggregates/karte/FollowUpDestination.ts b/src/domain/aggregates/karte/FollowUpDestination.ts new file mode 100644 index 0000000..07b8fbd --- /dev/null +++ b/src/domain/aggregates/karte/FollowUpDestination.ts @@ -0,0 +1 @@ +export type FollowUpDestination = "技術部" | "見送り"; diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts new file mode 100644 index 0000000..154d400 --- /dev/null +++ b/src/domain/aggregates/karte/Karte.ts @@ -0,0 +1,111 @@ +import type { Client } from "./Client"; +import type { Consent } from "./Consent"; +import type { Consultation } from "./Consultation"; +import type { Response } from "./Response"; + +/** + * カルテ集約ルート + * + * PC相談室での相談記録を表す。 + * 同意事項・相談事・対応事という3つのドメイン概念で構成される。 + */ +export class Karte { + constructor( + public readonly id: string, + private readonly recordedAt: Date, + private lastUpdatedAt: Date, + private client: Client, + private consent: Consent, + private consultation: Consultation, + private response: Response, + ) {} + + getRecordedAt(): Date { + return this.recordedAt; + } + + getLastUpdatedAt(): Date { + return this.lastUpdatedAt; + } + + getClient(): Client { + return this.client; + } + + getConsent(): Consent { + return this.consent; + } + + getConsultation(): Consultation { + return this.consultation; + } + + getResponse(): Response { + return this.response; + } + + updateConsent(consent: Consent): void { + this.consent = consent; + this.touch(); + } + + updateConsultation(consultation: Consultation): void { + this.consultation = consultation; + this.touch(); + } + + updateResponse(response: Response): void { + this.response = response; + this.touch(); + } + + updateClient(client: Client): void { + this.client = client; + this.touch(); + } + + private touch(): void { + this.lastUpdatedAt = new Date(); + } + + private snapshotClient(): Record { + const base = { + type: this.client.type, + name: this.client.name, + affiliation: this.client.affiliation.getValue(), + }; + if (this.client.type === "student") { + return { ...base, studentId: this.client.studentId.getValue() }; + } + return base; + } + + private snapshotCategories(): Array<{ id: string; displayName: string }> { + return this.consultation.categories.map((c) => ({ + id: c.id, + displayName: c.displayName, + })); + } + + toSnapshot() { + return { + id: this.id, + recordedAt: this.recordedAt, + lastUpdatedAt: this.lastUpdatedAt, + client: this.snapshotClient(), + consent: this.consent, + consultation: { + categories: this.snapshotCategories(), + targetDevice: this.consultation.targetDevice, + troubleDetails: this.consultation.troubleDetails, + }, + response: { + assignedMemberIds: [...this.response.assignedMemberIds], + responseContent: this.response.responseContent, + resolution: this.response.resolution, + followUpDestination: this.response.followUpDestination, + workDuration: this.response.workDuration, + }, + }; + } +} diff --git a/src/domain/aggregates/karte/KarteRepository.ts b/src/domain/aggregates/karte/KarteRepository.ts new file mode 100644 index 0000000..2244353 --- /dev/null +++ b/src/domain/aggregates/karte/KarteRepository.ts @@ -0,0 +1,9 @@ +import type { Karte } from "./Karte"; + +export interface KarteRepository { + findById(id: string): Promise; + findByClientStudentId(studentId: string): Promise; + findAll(): Promise; + save(karte: Karte): Promise; + delete(karteId: string): Promise; +} diff --git a/src/domain/aggregates/karte/Resolution.ts b/src/domain/aggregates/karte/Resolution.ts new file mode 100644 index 0000000..385e942 --- /dev/null +++ b/src/domain/aggregates/karte/Resolution.ts @@ -0,0 +1 @@ +export type Resolution = "resolved" | "unresolved"; diff --git a/src/domain/aggregates/karte/Response.ts b/src/domain/aggregates/karte/Response.ts new file mode 100644 index 0000000..e6f86b7 --- /dev/null +++ b/src/domain/aggregates/karte/Response.ts @@ -0,0 +1,17 @@ +import type { FollowUpDestination } from "./FollowUpDestination"; +import type { Resolution } from "./Resolution"; +import type { WorkDuration } from "./WorkDuration"; + +/** + * 対応事 + * + * PC相談室での対応内容をまとめた値オブジェクト。 + * 担当メンバー、対応内容、解決ステータス、後処理先、作業時間を含む。 + */ +export type Response = { + readonly assignedMemberIds: readonly string[]; + readonly responseContent: string; + readonly resolution: Resolution; + readonly followUpDestination?: FollowUpDestination; + readonly workDuration: WorkDuration; +}; diff --git a/src/domain/aggregates/karte/WorkDuration.ts b/src/domain/aggregates/karte/WorkDuration.ts new file mode 100644 index 0000000..94273b8 --- /dev/null +++ b/src/domain/aggregates/karte/WorkDuration.ts @@ -0,0 +1,9 @@ +/** + * 作業時間 + * + * FDM原則に従い、null/undefinedに意味を持たせない。 + * 作業時間が記録されている場合と記録されていない場合を明示的に区別する。 + */ +export type WorkDuration = + | { readonly type: "recorded"; readonly minutes: number } + | { readonly type: "notRecorded" }; diff --git a/src/domain/aggregates/karte/index.ts b/src/domain/aggregates/karte/index.ts new file mode 100644 index 0000000..bfb56e7 --- /dev/null +++ b/src/domain/aggregates/karte/index.ts @@ -0,0 +1,10 @@ +export * from "./Karte"; +export * from "./KarteRepository"; +export * from "./Client"; +export * from "./Consent"; +export * from "./Consultation"; +export * from "./ConsultationCategory"; +export * from "./Resolution"; +export * from "./Response"; +export * from "./FollowUpDestination"; +export * from "./WorkDuration"; diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 0168334..74fc5a5 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -113,3 +113,17 @@ export class InvalidStudentIdException extends DomainException { this.name = "InvalidStudentIdException"; } } + +export class InvalidConsultationCategoryException extends DomainException { + constructor(detail: string) { + super(`無効な相談カテゴリです: ${detail}`); + this.name = "InvalidConsultationCategoryException"; + } +} + +export class InvalidWorkDurationException extends DomainException { + constructor(minutes: number) { + super(`無効な作業時間です: ${minutes}分 (正の数値で指定してください)`); + this.name = "InvalidWorkDurationException"; + } +} From bd602d1757faed06854d015139315ec2abc2fe84 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 16:31:05 +0900 Subject: [PATCH 09/26] =?UTF-8?q?docs:=20=E3=82=AB=E3=83=AB=E3=83=86?= =?UTF-8?q?=E9=9B=86=E7=B4=84=E3=81=AE=E5=9E=8B=E3=83=BB=E3=83=95=E3=82=A3?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=89=E3=81=AB=E3=83=A6=E3=83=93=E3=82=AD?= =?UTF-8?q?=E3=82=BF=E3=82=B9=E8=A8=80=E8=AA=9E=E3=81=A8=E3=81=AE=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=82=92JSDoc=E3=81=A7=E6=98=8E=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Client.ts | 8 ++++++++ src/domain/aggregates/karte/Consent.ts | 2 ++ src/domain/aggregates/karte/Consultation.ts | 3 +++ src/domain/aggregates/karte/ConsultationCategory.ts | 2 ++ src/domain/aggregates/karte/FollowUpDestination.ts | 1 + src/domain/aggregates/karte/Karte.ts | 6 ++++++ src/domain/aggregates/karte/Resolution.ts | 1 + src/domain/aggregates/karte/Response.ts | 5 +++++ src/domain/aggregates/karte/WorkDuration.ts | 2 +- 9 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/domain/aggregates/karte/Client.ts b/src/domain/aggregates/karte/Client.ts index 8572ffe..6c1113b 100644 --- a/src/domain/aggregates/karte/Client.ts +++ b/src/domain/aggregates/karte/Client.ts @@ -1,17 +1,25 @@ import type { Affiliation } from "#domain/shared"; import type { StudentId } from "#domain/shared"; +/** 学生の相談者 */ type StudentClient = { readonly type: "student"; + /** 学籍番号 */ readonly studentId: StudentId; + /** 氏名 */ readonly name: string; + /** 所属 */ readonly affiliation: Affiliation; }; +/** 教職員の相談者 */ type StaffClient = { readonly type: "staff"; + /** 氏名 */ readonly name: string; + /** 所属 */ readonly affiliation: Affiliation; }; +/** 相談者 — PC相談室に相談を持ち込んだ人 */ export type Client = StudentClient | StaffClient; diff --git a/src/domain/aggregates/karte/Consent.ts b/src/domain/aggregates/karte/Consent.ts index edc3734..206de26 100644 --- a/src/domain/aggregates/karte/Consent.ts +++ b/src/domain/aggregates/karte/Consent.ts @@ -5,6 +5,8 @@ * カルテは同意なしでも作成できるため、各フィールドはbooleanで表現する。 */ export type Consent = { + /** 免責同意 */ readonly liabilityConsent: boolean; + /** 情報公開同意 */ readonly disclosureConsent: boolean; }; diff --git a/src/domain/aggregates/karte/Consultation.ts b/src/domain/aggregates/karte/Consultation.ts index e619b0b..2c02cc9 100644 --- a/src/domain/aggregates/karte/Consultation.ts +++ b/src/domain/aggregates/karte/Consultation.ts @@ -7,7 +7,10 @@ import type { ConsultationCategory } from "./ConsultationCategory"; * カテゴリは複数選択可能。 */ export type Consultation = { + /** 相談カテゴリ(複数選択可) */ readonly categories: readonly ConsultationCategory[]; + /** 対象機器 */ readonly targetDevice: string; + /** トラブル詳細 */ readonly troubleDetails: string; }; diff --git a/src/domain/aggregates/karte/ConsultationCategory.ts b/src/domain/aggregates/karte/ConsultationCategory.ts index 3ab8983..94db9f8 100644 --- a/src/domain/aggregates/karte/ConsultationCategory.ts +++ b/src/domain/aggregates/karte/ConsultationCategory.ts @@ -2,7 +2,9 @@ import { ValueObject } from "#domain/base/ValueObject"; import { InvalidConsultationCategoryException } from "#domain/exceptions"; type ConsultationCategoryValue = { + /** カテゴリID */ readonly id: string; + /** 表示名 */ readonly displayName: string; }; diff --git a/src/domain/aggregates/karte/FollowUpDestination.ts b/src/domain/aggregates/karte/FollowUpDestination.ts index 07b8fbd..3dfd8d7 100644 --- a/src/domain/aggregates/karte/FollowUpDestination.ts +++ b/src/domain/aggregates/karte/FollowUpDestination.ts @@ -1 +1,2 @@ +/** 後処理先 — 相談対応後の引き継ぎ先 */ export type FollowUpDestination = "技術部" | "見送り"; diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index 154d400..01f8996 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -12,11 +12,17 @@ import type { Response } from "./Response"; export class Karte { constructor( public readonly id: string, + /** 記録日時 */ private readonly recordedAt: Date, + /** 最終更新日時 */ private lastUpdatedAt: Date, + /** 相談者 */ private client: Client, + /** 同意事項 */ private consent: Consent, + /** 相談事 */ private consultation: Consultation, + /** 対応事 */ private response: Response, ) {} diff --git a/src/domain/aggregates/karte/Resolution.ts b/src/domain/aggregates/karte/Resolution.ts index 385e942..e59f7d8 100644 --- a/src/domain/aggregates/karte/Resolution.ts +++ b/src/domain/aggregates/karte/Resolution.ts @@ -1 +1,2 @@ +/** 解決ステータス — 相談が解決したかどうか */ export type Resolution = "resolved" | "unresolved"; diff --git a/src/domain/aggregates/karte/Response.ts b/src/domain/aggregates/karte/Response.ts index e6f86b7..23ea22d 100644 --- a/src/domain/aggregates/karte/Response.ts +++ b/src/domain/aggregates/karte/Response.ts @@ -9,9 +9,14 @@ import type { WorkDuration } from "./WorkDuration"; * 担当メンバー、対応内容、解決ステータス、後処理先、作業時間を含む。 */ export type Response = { + /** 担当メンバーID一覧 */ readonly assignedMemberIds: readonly string[]; + /** 対応内容 */ readonly responseContent: string; + /** 解決ステータス */ readonly resolution: Resolution; + /** 後処理先 */ readonly followUpDestination?: FollowUpDestination; + /** 作業時間 */ readonly workDuration: WorkDuration; }; diff --git a/src/domain/aggregates/karte/WorkDuration.ts b/src/domain/aggregates/karte/WorkDuration.ts index 94273b8..d73eceb 100644 --- a/src/domain/aggregates/karte/WorkDuration.ts +++ b/src/domain/aggregates/karte/WorkDuration.ts @@ -5,5 +5,5 @@ * 作業時間が記録されている場合と記録されていない場合を明示的に区別する。 */ export type WorkDuration = - | { readonly type: "recorded"; readonly minutes: number } + | { readonly type: "recorded"; /** 作業時間(分) */ readonly minutes: number } | { readonly type: "notRecorded" }; From 85e2d2eedbcbae775505838b9dbfde2d84ab3ec2 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 18:05:49 +0900 Subject: [PATCH 10/26] =?UTF-8?q?refactor:=20PR=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=81=AB=E5=9F=BA=E3=81=A5=E3=81=8D=E3=82=AB=E3=83=AB?= =?UTF-8?q?=E3=83=86=E9=9B=86=E7=B4=84=E3=81=AE=E3=83=89=E3=83=A1=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=A2=E3=83=87=E3=83=AB=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StaffClientからaffiliationを除去 - OtherClient(学外者等)を追加 - FollowUpDestinationに生協・情報基盤センター・その他を追加 - ResolutionをDUに変更し、未解決時にfollowUpDestinationを必須化 - ResponseからfollowUpDestination optional fieldを除去 - KarteにconsultedAt(相談日時)を追加 - WorkDuration/Resolutionのフォーマットを修正 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Client.ts | 11 ++++-- .../aggregates/karte/FollowUpDestination.ts | 7 +++- src/domain/aggregates/karte/Karte.ts | 35 ++++++++++++++----- src/domain/aggregates/karte/Resolution.ts | 17 +++++++-- src/domain/aggregates/karte/Response.ts | 5 +-- src/domain/aggregates/karte/WorkDuration.ts | 6 +++- 6 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/domain/aggregates/karte/Client.ts b/src/domain/aggregates/karte/Client.ts index 6c1113b..7e85d28 100644 --- a/src/domain/aggregates/karte/Client.ts +++ b/src/domain/aggregates/karte/Client.ts @@ -17,9 +17,14 @@ type StaffClient = { readonly type: "staff"; /** 氏名 */ readonly name: string; - /** 所属 */ - readonly affiliation: Affiliation; +}; + +/** その他の相談者(学外者など) */ +type OtherClient = { + readonly type: "other"; + /** 氏名 */ + readonly name: string; }; /** 相談者 — PC相談室に相談を持ち込んだ人 */ -export type Client = StudentClient | StaffClient; +export type Client = StudentClient | StaffClient | OtherClient; diff --git a/src/domain/aggregates/karte/FollowUpDestination.ts b/src/domain/aggregates/karte/FollowUpDestination.ts index 3dfd8d7..22bdc48 100644 --- a/src/domain/aggregates/karte/FollowUpDestination.ts +++ b/src/domain/aggregates/karte/FollowUpDestination.ts @@ -1,2 +1,7 @@ /** 後処理先 — 相談対応後の引き継ぎ先 */ -export type FollowUpDestination = "技術部" | "見送り"; +export type FollowUpDestination = + | "技術部" + | "生協" + | "情報基盤センター" + | "見送り" + | "その他"; diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index 01f8996..521b244 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -14,6 +14,8 @@ export class Karte { public readonly id: string, /** 記録日時 */ private readonly recordedAt: Date, + /** 相談日時 */ + private readonly consultedAt: Date, /** 最終更新日時 */ private lastUpdatedAt: Date, /** 相談者 */ @@ -30,6 +32,10 @@ export class Karte { return this.recordedAt; } + getConsultedAt(): Date { + return this.consultedAt; + } + getLastUpdatedAt(): Date { return this.lastUpdatedAt; } @@ -75,15 +81,26 @@ export class Karte { } private snapshotClient(): Record { - const base = { - type: this.client.type, - name: this.client.name, - affiliation: this.client.affiliation.getValue(), - }; - if (this.client.type === "student") { - return { ...base, studentId: this.client.studentId.getValue() }; + const client = this.client; + switch (client.type) { + case "student": + return { + type: client.type, + name: client.name, + studentId: client.studentId.getValue(), + affiliation: client.affiliation.getValue(), + }; + case "staff": + return { + type: client.type, + name: client.name, + }; + case "other": + return { + type: client.type, + name: client.name, + }; } - return base; } private snapshotCategories(): Array<{ id: string; displayName: string }> { @@ -97,6 +114,7 @@ export class Karte { return { id: this.id, recordedAt: this.recordedAt, + consultedAt: this.consultedAt, lastUpdatedAt: this.lastUpdatedAt, client: this.snapshotClient(), consent: this.consent, @@ -109,7 +127,6 @@ export class Karte { assignedMemberIds: [...this.response.assignedMemberIds], responseContent: this.response.responseContent, resolution: this.response.resolution, - followUpDestination: this.response.followUpDestination, workDuration: this.response.workDuration, }, }; diff --git a/src/domain/aggregates/karte/Resolution.ts b/src/domain/aggregates/karte/Resolution.ts index e59f7d8..6c21600 100644 --- a/src/domain/aggregates/karte/Resolution.ts +++ b/src/domain/aggregates/karte/Resolution.ts @@ -1,2 +1,15 @@ -/** 解決ステータス — 相談が解決したかどうか */ -export type Resolution = "resolved" | "unresolved"; +import type { FollowUpDestination } from "./FollowUpDestination"; + +/** + * 解決ステータス — 相談が解決したかどうか + * + * 未解決の場合は後処理先が必須。 + * FDM原則に従い、optional fieldを排除する。 + */ +export type Resolution = + | { readonly type: "resolved" } + | { + readonly type: "unresolved"; + /** 後処理先 */ + readonly followUpDestination: FollowUpDestination; + }; diff --git a/src/domain/aggregates/karte/Response.ts b/src/domain/aggregates/karte/Response.ts index 23ea22d..0f6149f 100644 --- a/src/domain/aggregates/karte/Response.ts +++ b/src/domain/aggregates/karte/Response.ts @@ -1,4 +1,3 @@ -import type { FollowUpDestination } from "./FollowUpDestination"; import type { Resolution } from "./Resolution"; import type { WorkDuration } from "./WorkDuration"; @@ -6,7 +5,7 @@ import type { WorkDuration } from "./WorkDuration"; * 対応事 * * PC相談室での対応内容をまとめた値オブジェクト。 - * 担当メンバー、対応内容、解決ステータス、後処理先、作業時間を含む。 + * 担当メンバー、対応内容、解決ステータス、作業時間を含む。 */ export type Response = { /** 担当メンバーID一覧 */ @@ -15,8 +14,6 @@ export type Response = { readonly responseContent: string; /** 解決ステータス */ readonly resolution: Resolution; - /** 後処理先 */ - readonly followUpDestination?: FollowUpDestination; /** 作業時間 */ readonly workDuration: WorkDuration; }; diff --git a/src/domain/aggregates/karte/WorkDuration.ts b/src/domain/aggregates/karte/WorkDuration.ts index d73eceb..3f26d7e 100644 --- a/src/domain/aggregates/karte/WorkDuration.ts +++ b/src/domain/aggregates/karte/WorkDuration.ts @@ -5,5 +5,9 @@ * 作業時間が記録されている場合と記録されていない場合を明示的に区別する。 */ export type WorkDuration = - | { readonly type: "recorded"; /** 作業時間(分) */ readonly minutes: number } + | { + readonly type: "recorded"; + /** 作業時間(分) */ + readonly minutes: number; + } | { readonly type: "notRecorded" }; From 5f996deb4a3e347587073629256bcde3a36438f3 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Fri, 13 Mar 2026 18:50:40 +0900 Subject: [PATCH 11/26] =?UTF-8?q?refactor:=20ConsultationCategory=E3=82=92?= =?UTF-8?q?=E3=83=8F=E3=83=BC=E3=83=89=E3=82=B3=E3=83=BC=E3=83=89=E5=AE=9A?= =?UTF-8?q?=E6=95=B0=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValueObjectクラスからリテラル型ID+定数一覧に置き換え。 タグは増えることはあるが減ることはない性質のため、ドメイン層に定義する。 不要になったInvalidConsultationCategoryExceptionも削除。 Co-Authored-By: Claude Opus 4.6 --- .../aggregates/karte/ConsultationCategory.ts | 74 +++++++++++-------- src/domain/aggregates/karte/Karte.ts | 8 +- src/domain/exceptions/DomainExceptions.ts | 7 -- 3 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/domain/aggregates/karte/ConsultationCategory.ts b/src/domain/aggregates/karte/ConsultationCategory.ts index 94db9f8..51c8c67 100644 --- a/src/domain/aggregates/karte/ConsultationCategory.ts +++ b/src/domain/aggregates/karte/ConsultationCategory.ts @@ -1,36 +1,50 @@ -import { ValueObject } from "#domain/base/ValueObject"; -import { InvalidConsultationCategoryException } from "#domain/exceptions"; - -type ConsultationCategoryValue = { - /** カテゴリID */ - readonly id: string; - /** 表示名 */ - readonly displayName: string; -}; - /** - * 相談カテゴリ + * 相談カテゴリ — PC相談室で扱うトラブルの分類タグ * - * PC相談室で扱うトラブルの分類タグ。 * IDと表示名のペアで構成され、追加はできるが削除はできない(履歴整合性のため)。 */ -export class ConsultationCategory extends ValueObject { - protected validate(): void { - this.throwIfInvalid( - this.value.id.trim().length > 0, - new InvalidConsultationCategoryException("IDが空です"), - ); - this.throwIfInvalid( - this.value.displayName.trim().length > 0, - new InvalidConsultationCategoryException("表示名が空です"), - ); - } +export type ConsultationCategory = { + /** カテゴリID */ + readonly id: ConsultationCategoryId; + /** 表示名 */ + readonly displayName: string; +}; - get id(): string { - return this.value.id; - } +/** 定義済みカテゴリID */ +export type ConsultationCategoryId = + | "hardware_pc" + | "problem_os" + | "programming" + | "usage_fs" + | "usage_gakujo" + | "usage_ms_software" + | "usage_printer" + | "usage_vm" + | "usage_vpn" + | "wifi_eduroam" + | "wifi_smartphone" + | "wifi_succes" + | "other"; - get displayName(): string { - return this.value.displayName; - } -} +/** 定義済みカテゴリ一覧 */ +export const CONSULTATION_CATEGORIES: readonly ConsultationCategory[] = [ + { id: "hardware_pc", displayName: "PCのハードウェアに関する問題の相談" }, + { id: "problem_os", displayName: "OSに関する問題" }, + { id: "programming", displayName: "プログラミングに関する相談" }, + { id: "usage_fs", displayName: "FSの使い方に関する相談" }, + { id: "usage_gakujo", displayName: "学情の使い方に関する相談" }, + { + id: "usage_ms_software", + displayName: "Microsoftのソフトウェアに関する相談", + }, + { id: "usage_printer", displayName: "プリンタの使い方に関する相談" }, + { id: "usage_vm", displayName: "Virtual Machineに関する相談" }, + { id: "usage_vpn", displayName: "VPNの使い方に関する相談" }, + { id: "wifi_eduroam", displayName: "eduroamに対する接続方法の相談" }, + { + id: "wifi_smartphone", + displayName: "スマホからのWiFi接続方法に関する相談", + }, + { id: "wifi_succes", displayName: "SUCCESに対する接続方法の相談" }, + { id: "other", displayName: "その他の相談" }, +] as const; diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index 521b244..8dfcb85 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -1,6 +1,7 @@ import type { Client } from "./Client"; import type { Consent } from "./Consent"; import type { Consultation } from "./Consultation"; +import type { ConsultationCategory } from "./ConsultationCategory"; import type { Response } from "./Response"; /** @@ -103,11 +104,8 @@ export class Karte { } } - private snapshotCategories(): Array<{ id: string; displayName: string }> { - return this.consultation.categories.map((c) => ({ - id: c.id, - displayName: c.displayName, - })); + private snapshotCategories(): readonly ConsultationCategory[] { + return this.consultation.categories; } toSnapshot() { diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 74fc5a5..1c1f8fc 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -114,13 +114,6 @@ export class InvalidStudentIdException extends DomainException { } } -export class InvalidConsultationCategoryException extends DomainException { - constructor(detail: string) { - super(`無効な相談カテゴリです: ${detail}`); - this.name = "InvalidConsultationCategoryException"; - } -} - export class InvalidWorkDurationException extends DomainException { constructor(minutes: number) { super(`無効な作業時間です: ${minutes}分 (正の数値で指定してください)`); From 61bae364fded872bf01047b0bfb73b03ecf4373e Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Fri, 13 Mar 2026 19:01:41 +0900 Subject: [PATCH 12/26] =?UTF-8?q?fix:=20ConsultationCategory=E3=81=AE?= =?UTF-8?q?=E5=AE=9A=E7=BE=A9=E3=82=92=E6=AD=A3=E5=BC=8F=E3=81=AA=E4=B8=80?= =?UTF-8?q?=E8=A6=A7=E3=81=AB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSVデータにない6カテゴリを追加し、problem_osを廃止。 正式な19カテゴリに統一。 Co-Authored-By: Claude Opus 4.6 --- .../aggregates/karte/ConsultationCategory.ts | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/domain/aggregates/karte/ConsultationCategory.ts b/src/domain/aggregates/karte/ConsultationCategory.ts index 51c8c67..ba20a5e 100644 --- a/src/domain/aggregates/karte/ConsultationCategory.ts +++ b/src/domain/aggregates/karte/ConsultationCategory.ts @@ -12,39 +12,51 @@ export type ConsultationCategory = { /** 定義済みカテゴリID */ export type ConsultationCategoryId = - | "hardware_pc" - | "problem_os" - | "programming" + | "wifi_eduroam" + | "wifi_succes" + | "wifi_smartphone" + | "usage_mac" | "usage_fs" + | "usage_vpn" + | "usage_mail" | "usage_gakujo" - | "usage_ms_software" + | "usage_onedrive" | "usage_printer" | "usage_vm" - | "usage_vpn" - | "wifi_eduroam" - | "wifi_smartphone" - | "wifi_succes" + | "usage_ms_software" + | "hardware_pc" + | "problem_credential" + | "problem_windows" + | "problem_linux" + | "programming" + | "rent" | "other"; /** 定義済みカテゴリ一覧 */ export const CONSULTATION_CATEGORIES: readonly ConsultationCategory[] = [ - { id: "hardware_pc", displayName: "PCのハードウェアに関する問題の相談" }, - { id: "problem_os", displayName: "OSに関する問題" }, - { id: "programming", displayName: "プログラミングに関する相談" }, - { id: "usage_fs", displayName: "FSの使い方に関する相談" }, - { id: "usage_gakujo", displayName: "学情の使い方に関する相談" }, + { id: "wifi_eduroam", displayName: "eduroamに対する接続方法の相談" }, + { id: "wifi_succes", displayName: "SUCCESSに対する接続方法の相談" }, { - id: "usage_ms_software", - displayName: "Microsoftのソフトウェアに関する相談", + id: "wifi_smartphone", + displayName: "スマホからのWiFi接続方法に関する相談", }, + { id: "usage_mac", displayName: "MacOSの使い方に関する相談" }, + { id: "usage_fs", displayName: "FSの使い方に関する相談" }, + { id: "usage_vpn", displayName: "VPNの使い方に関する相談" }, + { id: "usage_mail", displayName: "メールの使い方に関する相談" }, + { id: "usage_gakujo", displayName: "学情の使い方に関する相談" }, + { id: "usage_onedrive", displayName: "OneDriveの使い方に関する相談" }, { id: "usage_printer", displayName: "プリンタの使い方に関する相談" }, { id: "usage_vm", displayName: "Virtual Machineに関する相談" }, - { id: "usage_vpn", displayName: "VPNの使い方に関する相談" }, - { id: "wifi_eduroam", displayName: "eduroamに対する接続方法の相談" }, { - id: "wifi_smartphone", - displayName: "スマホからのWiFi接続方法に関する相談", + id: "usage_ms_software", + displayName: "Microsoftのソフトウェアに関する相談", }, - { id: "wifi_succes", displayName: "SUCCESに対する接続方法の相談" }, + { id: "hardware_pc", displayName: "PCのハードウェアに関する問題の相談" }, + { id: "problem_credential", displayName: "資格情報に関する相談" }, + { id: "problem_windows", displayName: "Windowsに関する問題" }, + { id: "problem_linux", displayName: "Linuxに関する問題" }, + { id: "programming", displayName: "プログラミングに関する相談" }, + { id: "rent", displayName: "貸し出しに関する相談" }, { id: "other", displayName: "その他の相談" }, ] as const; From 4ae669f7119503002dcae5cc4d7c9f19cc62e508 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 20:44:18 +0900 Subject: [PATCH 13/26] =?UTF-8?q?refactor:=20Karte=E9=9B=86=E7=B4=84?= =?UTF-8?q?=E3=81=AE=E5=85=AC=E9=96=8BAPI=E8=A8=AD=E8=A8=88=E3=82=92?= =?UTF-8?q?=E6=B7=B1=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NonEmptyArray型を導入し、空集合の禁止を型レベルで強制 - Recorded型を導入し、レガシーデータの欠損をnull/undefinedなしで表現 - ゲッターを廃止し全フィールドをpublic readonlyに変更 - correct()の引数をcreate()と同じ生の値に統一し完全性を型で保証 - Response→SupportRecordリネーム、Client型にFacultyClientを追加 - KarteSnapshot/KarteId/MemberIdブランド型を新設 - 実行時バリデーション例外を型による保証に置き換え Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Client.ts | 11 +- src/domain/aggregates/karte/Consultation.ts | 7 +- src/domain/aggregates/karte/Karte.ts | 263 +++++++++++------- src/domain/aggregates/karte/KarteId.ts | 6 + .../aggregates/karte/KarteRepository.ts | 5 +- src/domain/aggregates/karte/KarteSnapshot.ts | 129 +++++++++ src/domain/aggregates/karte/MemberId.ts | 6 + src/domain/aggregates/karte/Resolution.ts | 7 +- src/domain/aggregates/karte/Response.ts | 19 -- src/domain/aggregates/karte/SupportRecord.ts | 21 ++ src/domain/aggregates/karte/index.ts | 7 +- src/domain/base/NonEmptyArray.ts | 6 + src/domain/base/Recorded.ts | 22 ++ src/domain/base/index.ts | 2 + 14 files changed, 375 insertions(+), 136 deletions(-) create mode 100644 src/domain/aggregates/karte/KarteId.ts create mode 100644 src/domain/aggregates/karte/KarteSnapshot.ts create mode 100644 src/domain/aggregates/karte/MemberId.ts delete mode 100644 src/domain/aggregates/karte/Response.ts create mode 100644 src/domain/aggregates/karte/SupportRecord.ts create mode 100644 src/domain/base/NonEmptyArray.ts create mode 100644 src/domain/base/Recorded.ts diff --git a/src/domain/aggregates/karte/Client.ts b/src/domain/aggregates/karte/Client.ts index 7e85d28..56d8283 100644 --- a/src/domain/aggregates/karte/Client.ts +++ b/src/domain/aggregates/karte/Client.ts @@ -12,7 +12,14 @@ type StudentClient = { readonly affiliation: Affiliation; }; -/** 教職員の相談者 */ +/** 教員の相談者 */ +type FacultyClient = { + readonly type: "faculty"; + /** 氏名 */ + readonly name: string; +}; + +/** 職員の相談者 */ type StaffClient = { readonly type: "staff"; /** 氏名 */ @@ -27,4 +34,4 @@ type OtherClient = { }; /** 相談者 — PC相談室に相談を持ち込んだ人 */ -export type Client = StudentClient | StaffClient | OtherClient; +export type Client = StudentClient | FacultyClient | StaffClient | OtherClient; diff --git a/src/domain/aggregates/karte/Consultation.ts b/src/domain/aggregates/karte/Consultation.ts index 2c02cc9..fbefea4 100644 --- a/src/domain/aggregates/karte/Consultation.ts +++ b/src/domain/aggregates/karte/Consultation.ts @@ -1,16 +1,17 @@ +import type { Recorded } from "#domain/base"; import type { ConsultationCategory } from "./ConsultationCategory"; /** * 相談事 * * 相談者が持ち込んだトラブルの内容をまとめた値オブジェクト。 - * カテゴリは複数選択可能。 + * カテゴリ・対象機器は過去データで未記録の場合がある。 */ export type Consultation = { /** 相談カテゴリ(複数選択可) */ - readonly categories: readonly ConsultationCategory[]; + readonly categories: Recorded; /** 対象機器 */ - readonly targetDevice: string; + readonly targetDevice: Recorded; /** トラブル詳細 */ readonly troubleDetails: string; }; diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index 8dfcb85..df3e87a 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -1,132 +1,185 @@ +import { type NonEmptyArray, type Recorded, recorded } from "#domain/base"; import type { Client } from "./Client"; import type { Consent } from "./Consent"; import type { Consultation } from "./Consultation"; import type { ConsultationCategory } from "./ConsultationCategory"; -import type { Response } from "./Response"; +import type { FollowUpDestination } from "./FollowUpDestination"; +import type { KarteId } from "./KarteId"; +import type { MemberId } from "./MemberId"; +import type { Resolution } from "./Resolution"; +import type { SupportRecord } from "./SupportRecord"; +import type { WorkDuration } from "./WorkDuration"; + +/** + * 新規カルテ作成時の入力型 + * + * 全フィールドが完全に揃った状態を要求する。 + * Recorded型は使わず、生の値を受け取る。 + */ +type KarteCreationProps = { + readonly id: KarteId; + readonly consultedAt: Date; + readonly client: Client; + readonly consent: Consent; + readonly consultation: { + readonly categories: NonEmptyArray; + readonly targetDevice: string; + readonly troubleDetails: string; + }; + readonly supportRecord: { + readonly assignedMemberIds: NonEmptyArray; + readonly content: string; + readonly resolution: CompleteResolution; + readonly workDuration: WorkDuration; + }; +}; + +/** 新規作成時の解決ステータス — 後処理先は必須 */ +type CompleteResolution = + | { readonly type: "resolved" } + | { + readonly type: "unresolved"; + readonly followUpDestination: FollowUpDestination; + }; + +/** 訂正時の入力型 — 作成時と同じく完全な生の値を要求する */ +type KarteCorrectionProps = { + readonly consultedAt: Date; + readonly client: Client; + readonly consent: Consent; + readonly consultation: { + readonly categories: NonEmptyArray; + readonly targetDevice: string; + readonly troubleDetails: string; + }; + readonly supportRecord: { + readonly assignedMemberIds: NonEmptyArray; + readonly content: string; + readonly resolution: CompleteResolution; + readonly workDuration: WorkDuration; + }; +}; + +/** 永続化データからの復元時の入力型 */ +type KarteReconstructProps = { + readonly id: KarteId; + readonly recordedAt: Date; + readonly consultedAt: Recorded; + readonly lastUpdatedAt: Date; + readonly client: Recorded; + readonly consent: Consent; + readonly consultation: Consultation; + readonly supportRecord: SupportRecord; +}; /** * カルテ集約ルート * * PC相談室での相談記録を表す。 - * 同意事項・相談事・対応事という3つのドメイン概念で構成される。 + * 同意事項・相談事・対応記録という3つのドメイン概念で構成される。 + * 作成時に全データが揃うイミュータブルな記録であり、 + * 修正は correct() による明示的な訂正操作のみ許可する。 + * + * 過去データでは一部フィールドが未記録(Recorded型のnotRecorded)の場合がある。 + * 新規作成時は create() により全フィールドの完全性を保証する。 */ export class Karte { - constructor( - public readonly id: string, + private constructor( + public readonly id: KarteId, /** 記録日時 */ - private readonly recordedAt: Date, + public readonly recordedAt: Date, /** 相談日時 */ - private readonly consultedAt: Date, + public readonly consultedAt: Recorded, /** 最終更新日時 */ - private lastUpdatedAt: Date, + public readonly lastUpdatedAt: Date, /** 相談者 */ - private client: Client, + public readonly client: Recorded, /** 同意事項 */ - private consent: Consent, + public readonly consent: Consent, /** 相談事 */ - private consultation: Consultation, - /** 対応事 */ - private response: Response, + public readonly consultation: Consultation, + /** 対応記録 */ + public readonly supportRecord: SupportRecord, ) {} - getRecordedAt(): Date { - return this.recordedAt; - } - - getConsultedAt(): Date { - return this.consultedAt; - } - - getLastUpdatedAt(): Date { - return this.lastUpdatedAt; - } - - getClient(): Client { - return this.client; - } - - getConsent(): Consent { - return this.consent; - } - - getConsultation(): Consultation { - return this.consultation; - } - - getResponse(): Response { - return this.response; - } - - updateConsent(consent: Consent): void { - this.consent = consent; - this.touch(); - } - - updateConsultation(consultation: Consultation): void { - this.consultation = consultation; - this.touch(); - } - - updateResponse(response: Response): void { - this.response = response; - this.touch(); - } - - updateClient(client: Client): void { - this.client = client; - this.touch(); - } - - private touch(): void { - this.lastUpdatedAt = new Date(); - } - - private snapshotClient(): Record { - const client = this.client; - switch (client.type) { - case "student": - return { - type: client.type, - name: client.name, - studentId: client.studentId.getValue(), - affiliation: client.affiliation.getValue(), - }; - case "staff": - return { - type: client.type, - name: client.name, - }; - case "other": - return { - type: client.type, - name: client.name, - }; - } + /** 新規カルテの作成 — 全フィールド完全であることを型で保証する */ + static create(props: KarteCreationProps): Karte { + const now = new Date(); + return new Karte( + props.id, + now, + recorded(props.consultedAt), + now, + recorded(props.client), + props.consent, + { + categories: recorded(props.consultation.categories), + targetDevice: recorded(props.consultation.targetDevice), + troubleDetails: props.consultation.troubleDetails, + }, + { + assignedMemberIds: recorded(props.supportRecord.assignedMemberIds), + content: props.supportRecord.content, + resolution: recorded( + toRecordedResolution(props.supportRecord.resolution), + ), + workDuration: props.supportRecord.workDuration, + }, + ); } - private snapshotCategories(): readonly ConsultationCategory[] { - return this.consultation.categories; + /** 永続化データからの復元 — バリデーションなし */ + static reconstruct(props: KarteReconstructProps): Karte { + return new Karte( + props.id, + props.recordedAt, + props.consultedAt, + props.lastUpdatedAt, + props.client, + props.consent, + props.consultation, + props.supportRecord, + ); } - toSnapshot() { - return { - id: this.id, - recordedAt: this.recordedAt, - consultedAt: this.consultedAt, - lastUpdatedAt: this.lastUpdatedAt, - client: this.snapshotClient(), - consent: this.consent, - consultation: { - categories: this.snapshotCategories(), - targetDevice: this.consultation.targetDevice, - troubleDetails: this.consultation.troubleDetails, + /** + * カルテの訂正 + * + * 記録ミスの修正など、既存カルテの内容を訂正した新しいインスタンスを返す。 + * recordedAt(元の記録日時)は保持し、lastUpdatedAt を現在時刻に更新する。 + * 完全な生の値を受け取り、不変条件は型で保証する。 + */ + correct(props: KarteCorrectionProps): Karte { + return new Karte( + this.id, + this.recordedAt, + recorded(props.consultedAt), + new Date(), + recorded(props.client), + props.consent, + { + categories: recorded(props.consultation.categories), + targetDevice: recorded(props.consultation.targetDevice), + troubleDetails: props.consultation.troubleDetails, }, - response: { - assignedMemberIds: [...this.response.assignedMemberIds], - responseContent: this.response.responseContent, - resolution: this.response.resolution, - workDuration: this.response.workDuration, + { + assignedMemberIds: recorded(props.supportRecord.assignedMemberIds), + content: props.supportRecord.content, + resolution: recorded( + toRecordedResolution(props.supportRecord.resolution), + ), + workDuration: props.supportRecord.workDuration, }, - }; + ); + } +} + +function toRecordedResolution(complete: CompleteResolution): Resolution { + if (complete.type === "resolved") { + return { type: "resolved" }; } + return { + type: "unresolved", + followUpDestination: recorded(complete.followUpDestination), + }; } diff --git a/src/domain/aggregates/karte/KarteId.ts b/src/domain/aggregates/karte/KarteId.ts new file mode 100644 index 0000000..b0eee9a --- /dev/null +++ b/src/domain/aggregates/karte/KarteId.ts @@ -0,0 +1,6 @@ +/** カルテID — カルテを一意に識別するブランド型 */ +export type KarteId = string & { readonly __brand: unique symbol }; + +export function karteId(value: string): KarteId { + return value as KarteId; +} diff --git a/src/domain/aggregates/karte/KarteRepository.ts b/src/domain/aggregates/karte/KarteRepository.ts index 2244353..f6cb74f 100644 --- a/src/domain/aggregates/karte/KarteRepository.ts +++ b/src/domain/aggregates/karte/KarteRepository.ts @@ -1,9 +1,10 @@ import type { Karte } from "./Karte"; +import type { KarteId } from "./KarteId"; export interface KarteRepository { - findById(id: string): Promise; + findById(id: KarteId): Promise; findByClientStudentId(studentId: string): Promise; findAll(): Promise; save(karte: Karte): Promise; - delete(karteId: string): Promise; + delete(karteId: KarteId): Promise; } diff --git a/src/domain/aggregates/karte/KarteSnapshot.ts b/src/domain/aggregates/karte/KarteSnapshot.ts new file mode 100644 index 0000000..1013dd2 --- /dev/null +++ b/src/domain/aggregates/karte/KarteSnapshot.ts @@ -0,0 +1,129 @@ +import type { Recorded } from "#domain/base"; +import type { Affiliation } from "#domain/shared"; +import type { Consent } from "./Consent"; +import type { ConsultationCategory } from "./ConsultationCategory"; +import type { FollowUpDestination } from "./FollowUpDestination"; +import type { Karte } from "./Karte"; +import type { WorkDuration } from "./WorkDuration"; + +/** 相談者のスナップショット */ +type ClientSnapshot = + | { + readonly type: "student"; + readonly studentId: string; + readonly name: string; + readonly affiliation: ReturnType; + } + | { readonly type: "faculty"; readonly name: string } + | { readonly type: "staff"; readonly name: string } + | { readonly type: "other"; readonly name: string }; + +/** 解決ステータスのスナップショット */ +type ResolutionSnapshot = + | { readonly type: "resolved" } + | { + readonly type: "unresolved"; + readonly followUpDestination: Recorded; + }; + +/** 相談事のスナップショット */ +type ConsultationSnapshot = { + readonly categories: Recorded; + readonly targetDevice: Recorded; + readonly troubleDetails: string; +}; + +/** 対応記録のスナップショット */ +type SupportRecordSnapshot = { + readonly assignedMemberIds: Recorded; + readonly content: string; + readonly resolution: Recorded; + readonly workDuration: WorkDuration; +}; + +/** カルテの永続化用スナップショット */ +export type KarteSnapshot = { + readonly id: string; + readonly recordedAt: Date; + readonly consultedAt: Recorded; + readonly lastUpdatedAt: Date; + readonly client: Recorded; + readonly consent: Consent; + readonly consultation: ConsultationSnapshot; + readonly supportRecord: SupportRecordSnapshot; +}; + +/** Karteドメインオブジェクトからスナップショットを生成する */ +export function toKarteSnapshot(karte: Karte): KarteSnapshot { + return { + id: karte.id, + recordedAt: karte.recordedAt, + consultedAt: karte.consultedAt, + lastUpdatedAt: karte.lastUpdatedAt, + client: snapshotClient(karte.client), + consent: karte.consent, + consultation: { + categories: karte.consultation.categories, + targetDevice: karte.consultation.targetDevice, + troubleDetails: karte.consultation.troubleDetails, + }, + supportRecord: snapshotSupportRecord(karte.supportRecord), + }; +} + +function snapshotClient(client: Karte["client"]): Recorded { + if (client.type === "notRecorded") { + return { type: "notRecorded" }; + } + const c = client.value; + switch (c.type) { + case "student": + return { + type: "recorded", + value: { + type: c.type, + name: c.name, + studentId: c.studentId.getValue(), + affiliation: c.affiliation.getValue(), + }, + }; + case "faculty": + return { + type: "recorded", + value: { type: c.type, name: c.name }, + }; + case "staff": + return { + type: "recorded", + value: { type: c.type, name: c.name }, + }; + case "other": + return { + type: "recorded", + value: { type: c.type, name: c.name }, + }; + } +} + +function snapshotSupportRecord( + sr: Karte["supportRecord"], +): SupportRecordSnapshot { + return { + assignedMemberIds: snapshotMemberIds(sr.assignedMemberIds), + content: sr.content, + resolution: sr.resolution, + workDuration: sr.workDuration, + }; +} + +function snapshotMemberIds( + ids: Karte["supportRecord"]["assignedMemberIds"], +): Recorded { + if (ids.type === "notRecorded") { + return { type: "notRecorded" }; + } + return { + type: "recorded", + value: ids.value.map((id) => id as string), + }; +} diff --git a/src/domain/aggregates/karte/MemberId.ts b/src/domain/aggregates/karte/MemberId.ts new file mode 100644 index 0000000..267a033 --- /dev/null +++ b/src/domain/aggregates/karte/MemberId.ts @@ -0,0 +1,6 @@ +/** メンバーID — 対応担当者を一意に識別するブランド型 */ +export type MemberId = string & { readonly __brand: unique symbol }; + +export function memberId(value: string): MemberId { + return value as MemberId; +} diff --git a/src/domain/aggregates/karte/Resolution.ts b/src/domain/aggregates/karte/Resolution.ts index 6c21600..577e4ee 100644 --- a/src/domain/aggregates/karte/Resolution.ts +++ b/src/domain/aggregates/karte/Resolution.ts @@ -1,15 +1,16 @@ +import type { Recorded } from "#domain/base"; import type { FollowUpDestination } from "./FollowUpDestination"; /** * 解決ステータス — 相談が解決したかどうか * - * 未解決の場合は後処理先が必須。 - * FDM原則に従い、optional fieldを排除する。 + * 未解決の場合、後処理先は過去データでは未記録の場合がある。 + * FDM原則に従い、Recorded型で明示する。 */ export type Resolution = | { readonly type: "resolved" } | { readonly type: "unresolved"; /** 後処理先 */ - readonly followUpDestination: FollowUpDestination; + readonly followUpDestination: Recorded; }; diff --git a/src/domain/aggregates/karte/Response.ts b/src/domain/aggregates/karte/Response.ts deleted file mode 100644 index 0f6149f..0000000 --- a/src/domain/aggregates/karte/Response.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Resolution } from "./Resolution"; -import type { WorkDuration } from "./WorkDuration"; - -/** - * 対応事 - * - * PC相談室での対応内容をまとめた値オブジェクト。 - * 担当メンバー、対応内容、解決ステータス、作業時間を含む。 - */ -export type Response = { - /** 担当メンバーID一覧 */ - readonly assignedMemberIds: readonly string[]; - /** 対応内容 */ - readonly responseContent: string; - /** 解決ステータス */ - readonly resolution: Resolution; - /** 作業時間 */ - readonly workDuration: WorkDuration; -}; diff --git a/src/domain/aggregates/karte/SupportRecord.ts b/src/domain/aggregates/karte/SupportRecord.ts new file mode 100644 index 0000000..9b96ead --- /dev/null +++ b/src/domain/aggregates/karte/SupportRecord.ts @@ -0,0 +1,21 @@ +import type { Recorded } from "#domain/base"; +import type { MemberId } from "./MemberId"; +import type { Resolution } from "./Resolution"; +import type { WorkDuration } from "./WorkDuration"; + +/** + * 対応記録 + * + * 相談に対して行った対応の記録。 + * 担当メンバー・解決ステータスは過去データで未記録の場合がある。 + */ +export type SupportRecord = { + /** 担当メンバーID一覧 */ + readonly assignedMemberIds: Recorded; + /** 対応内容 */ + readonly content: string; + /** 解決ステータス */ + readonly resolution: Recorded; + /** 作業時間 */ + readonly workDuration: WorkDuration; +}; diff --git a/src/domain/aggregates/karte/index.ts b/src/domain/aggregates/karte/index.ts index bfb56e7..715c166 100644 --- a/src/domain/aggregates/karte/index.ts +++ b/src/domain/aggregates/karte/index.ts @@ -1,10 +1,13 @@ export * from "./Karte"; +export * from "./KarteId"; export * from "./KarteRepository"; +export * from "./KarteSnapshot"; export * from "./Client"; export * from "./Consent"; export * from "./Consultation"; export * from "./ConsultationCategory"; -export * from "./Resolution"; -export * from "./Response"; export * from "./FollowUpDestination"; +export * from "./MemberId"; +export * from "./Resolution"; +export * from "./SupportRecord"; export * from "./WorkDuration"; diff --git a/src/domain/base/NonEmptyArray.ts b/src/domain/base/NonEmptyArray.ts new file mode 100644 index 0000000..6294f43 --- /dev/null +++ b/src/domain/base/NonEmptyArray.ts @@ -0,0 +1,6 @@ +/** + * 1つ以上の要素を持つ読み取り専用配列 + * + * 空配列を型レベルで禁止する。 + */ +export type NonEmptyArray = readonly [T, ...T[]]; diff --git a/src/domain/base/Recorded.ts b/src/domain/base/Recorded.ts new file mode 100644 index 0000000..ea617fc --- /dev/null +++ b/src/domain/base/Recorded.ts @@ -0,0 +1,22 @@ +/** + * 記録有無を明示する汎用型 + * + * FDM原則に従い、null/undefinedに意味を持たせない。 + * 過去データで記録されていない可能性があるフィールドに使用する。 + */ +export type Recorded = + | { + readonly type: "recorded"; + readonly value: T; + } + | { readonly type: "notRecorded" }; + +/** 記録済みの値を生成する */ +export function recorded(value: T): Recorded { + return { type: "recorded", value }; +} + +/** 未記録を生成する */ +export function notRecorded(): Recorded { + return { type: "notRecorded" }; +} diff --git a/src/domain/base/index.ts b/src/domain/base/index.ts index 1bb7a73..1b39429 100644 --- a/src/domain/base/index.ts +++ b/src/domain/base/index.ts @@ -1 +1,3 @@ +export * from "./NonEmptyArray"; +export * from "./Recorded"; export * from "./ValueObject"; From 1e2b26739a96678523e06684d6089d3685467dde Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 20:48:50 +0900 Subject: [PATCH 14/26] =?UTF-8?q?refactor:=20MemberId=E3=83=96=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=83=89=E5=9E=8B=E3=82=92=E5=89=8A=E9=99=A4=E3=81=97?= =?UTF-8?q?string=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemberIdはmember集約の概念であり、karte集約内に定義すべきではない。 既存のmember集約がstringで扱っているため合わせる。 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Karte.ts | 5 ++--- src/domain/aggregates/karte/KarteSnapshot.ts | 14 +------------- src/domain/aggregates/karte/MemberId.ts | 6 ------ src/domain/aggregates/karte/SupportRecord.ts | 3 +-- src/domain/aggregates/karte/index.ts | 1 - 5 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 src/domain/aggregates/karte/MemberId.ts diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index df3e87a..d6dc189 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -5,7 +5,6 @@ import type { Consultation } from "./Consultation"; import type { ConsultationCategory } from "./ConsultationCategory"; import type { FollowUpDestination } from "./FollowUpDestination"; import type { KarteId } from "./KarteId"; -import type { MemberId } from "./MemberId"; import type { Resolution } from "./Resolution"; import type { SupportRecord } from "./SupportRecord"; import type { WorkDuration } from "./WorkDuration"; @@ -27,7 +26,7 @@ type KarteCreationProps = { readonly troubleDetails: string; }; readonly supportRecord: { - readonly assignedMemberIds: NonEmptyArray; + readonly assignedMemberIds: NonEmptyArray; readonly content: string; readonly resolution: CompleteResolution; readonly workDuration: WorkDuration; @@ -53,7 +52,7 @@ type KarteCorrectionProps = { readonly troubleDetails: string; }; readonly supportRecord: { - readonly assignedMemberIds: NonEmptyArray; + readonly assignedMemberIds: NonEmptyArray; readonly content: string; readonly resolution: CompleteResolution; readonly workDuration: WorkDuration; diff --git a/src/domain/aggregates/karte/KarteSnapshot.ts b/src/domain/aggregates/karte/KarteSnapshot.ts index 1013dd2..ee6e09d 100644 --- a/src/domain/aggregates/karte/KarteSnapshot.ts +++ b/src/domain/aggregates/karte/KarteSnapshot.ts @@ -109,21 +109,9 @@ function snapshotSupportRecord( sr: Karte["supportRecord"], ): SupportRecordSnapshot { return { - assignedMemberIds: snapshotMemberIds(sr.assignedMemberIds), + assignedMemberIds: sr.assignedMemberIds, content: sr.content, resolution: sr.resolution, workDuration: sr.workDuration, }; } - -function snapshotMemberIds( - ids: Karte["supportRecord"]["assignedMemberIds"], -): Recorded { - if (ids.type === "notRecorded") { - return { type: "notRecorded" }; - } - return { - type: "recorded", - value: ids.value.map((id) => id as string), - }; -} diff --git a/src/domain/aggregates/karte/MemberId.ts b/src/domain/aggregates/karte/MemberId.ts deleted file mode 100644 index 267a033..0000000 --- a/src/domain/aggregates/karte/MemberId.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** メンバーID — 対応担当者を一意に識別するブランド型 */ -export type MemberId = string & { readonly __brand: unique symbol }; - -export function memberId(value: string): MemberId { - return value as MemberId; -} diff --git a/src/domain/aggregates/karte/SupportRecord.ts b/src/domain/aggregates/karte/SupportRecord.ts index 9b96ead..9b2cc7f 100644 --- a/src/domain/aggregates/karte/SupportRecord.ts +++ b/src/domain/aggregates/karte/SupportRecord.ts @@ -1,5 +1,4 @@ import type { Recorded } from "#domain/base"; -import type { MemberId } from "./MemberId"; import type { Resolution } from "./Resolution"; import type { WorkDuration } from "./WorkDuration"; @@ -11,7 +10,7 @@ import type { WorkDuration } from "./WorkDuration"; */ export type SupportRecord = { /** 担当メンバーID一覧 */ - readonly assignedMemberIds: Recorded; + readonly assignedMemberIds: Recorded; /** 対応内容 */ readonly content: string; /** 解決ステータス */ diff --git a/src/domain/aggregates/karte/index.ts b/src/domain/aggregates/karte/index.ts index 715c166..5079abe 100644 --- a/src/domain/aggregates/karte/index.ts +++ b/src/domain/aggregates/karte/index.ts @@ -7,7 +7,6 @@ export * from "./Consent"; export * from "./Consultation"; export * from "./ConsultationCategory"; export * from "./FollowUpDestination"; -export * from "./MemberId"; export * from "./Resolution"; export * from "./SupportRecord"; export * from "./WorkDuration"; From b0c77b2df28eccdfe2d8cc6027b81e4c9cb255e1 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 20:54:17 +0900 Subject: [PATCH 15/26] =?UTF-8?q?refactor:=20FacultyClient=E3=82=92Teacher?= =?UTF-8?q?Client=E3=81=AB=E3=83=AA=E3=83=8D=E3=83=BC=E3=83=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit facultyは教員の集合名詞であり個人を指すには不適切。 student/staff/otherと並べたときteacherが最も自然。 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Client.ts | 6 +++--- src/domain/aggregates/karte/KarteSnapshot.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/domain/aggregates/karte/Client.ts b/src/domain/aggregates/karte/Client.ts index 56d8283..ac12872 100644 --- a/src/domain/aggregates/karte/Client.ts +++ b/src/domain/aggregates/karte/Client.ts @@ -13,8 +13,8 @@ type StudentClient = { }; /** 教員の相談者 */ -type FacultyClient = { - readonly type: "faculty"; +type TeacherClient = { + readonly type: "teacher"; /** 氏名 */ readonly name: string; }; @@ -34,4 +34,4 @@ type OtherClient = { }; /** 相談者 — PC相談室に相談を持ち込んだ人 */ -export type Client = StudentClient | FacultyClient | StaffClient | OtherClient; +export type Client = StudentClient | TeacherClient | StaffClient | OtherClient; diff --git a/src/domain/aggregates/karte/KarteSnapshot.ts b/src/domain/aggregates/karte/KarteSnapshot.ts index ee6e09d..ea4c993 100644 --- a/src/domain/aggregates/karte/KarteSnapshot.ts +++ b/src/domain/aggregates/karte/KarteSnapshot.ts @@ -14,7 +14,7 @@ type ClientSnapshot = readonly name: string; readonly affiliation: ReturnType; } - | { readonly type: "faculty"; readonly name: string } + | { readonly type: "teacher"; readonly name: string } | { readonly type: "staff"; readonly name: string } | { readonly type: "other"; readonly name: string }; @@ -87,7 +87,7 @@ function snapshotClient(client: Karte["client"]): Recorded { affiliation: c.affiliation.getValue(), }, }; - case "faculty": + case "teacher": return { type: "recorded", value: { type: c.type, name: c.name }, From 7b022eb2b4d4c13c3ae5b8371b76947f7c8deca2 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 21:27:42 +0900 Subject: [PATCH 16/26] =?UTF-8?q?refactor:=20KarteSnapshot=E3=82=92?= =?UTF-8?q?=E5=BB=83=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全フィールドがpublic readonlyであるため、永続化層は Karteを直接読めばよく、Snapshotによる変換層は不要。 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/KarteSnapshot.ts | 117 ------------------- src/domain/aggregates/karte/index.ts | 1 - 2 files changed, 118 deletions(-) delete mode 100644 src/domain/aggregates/karte/KarteSnapshot.ts diff --git a/src/domain/aggregates/karte/KarteSnapshot.ts b/src/domain/aggregates/karte/KarteSnapshot.ts deleted file mode 100644 index ea4c993..0000000 --- a/src/domain/aggregates/karte/KarteSnapshot.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { Recorded } from "#domain/base"; -import type { Affiliation } from "#domain/shared"; -import type { Consent } from "./Consent"; -import type { ConsultationCategory } from "./ConsultationCategory"; -import type { FollowUpDestination } from "./FollowUpDestination"; -import type { Karte } from "./Karte"; -import type { WorkDuration } from "./WorkDuration"; - -/** 相談者のスナップショット */ -type ClientSnapshot = - | { - readonly type: "student"; - readonly studentId: string; - readonly name: string; - readonly affiliation: ReturnType; - } - | { readonly type: "teacher"; readonly name: string } - | { readonly type: "staff"; readonly name: string } - | { readonly type: "other"; readonly name: string }; - -/** 解決ステータスのスナップショット */ -type ResolutionSnapshot = - | { readonly type: "resolved" } - | { - readonly type: "unresolved"; - readonly followUpDestination: Recorded; - }; - -/** 相談事のスナップショット */ -type ConsultationSnapshot = { - readonly categories: Recorded; - readonly targetDevice: Recorded; - readonly troubleDetails: string; -}; - -/** 対応記録のスナップショット */ -type SupportRecordSnapshot = { - readonly assignedMemberIds: Recorded; - readonly content: string; - readonly resolution: Recorded; - readonly workDuration: WorkDuration; -}; - -/** カルテの永続化用スナップショット */ -export type KarteSnapshot = { - readonly id: string; - readonly recordedAt: Date; - readonly consultedAt: Recorded; - readonly lastUpdatedAt: Date; - readonly client: Recorded; - readonly consent: Consent; - readonly consultation: ConsultationSnapshot; - readonly supportRecord: SupportRecordSnapshot; -}; - -/** Karteドメインオブジェクトからスナップショットを生成する */ -export function toKarteSnapshot(karte: Karte): KarteSnapshot { - return { - id: karte.id, - recordedAt: karte.recordedAt, - consultedAt: karte.consultedAt, - lastUpdatedAt: karte.lastUpdatedAt, - client: snapshotClient(karte.client), - consent: karte.consent, - consultation: { - categories: karte.consultation.categories, - targetDevice: karte.consultation.targetDevice, - troubleDetails: karte.consultation.troubleDetails, - }, - supportRecord: snapshotSupportRecord(karte.supportRecord), - }; -} - -function snapshotClient(client: Karte["client"]): Recorded { - if (client.type === "notRecorded") { - return { type: "notRecorded" }; - } - const c = client.value; - switch (c.type) { - case "student": - return { - type: "recorded", - value: { - type: c.type, - name: c.name, - studentId: c.studentId.getValue(), - affiliation: c.affiliation.getValue(), - }, - }; - case "teacher": - return { - type: "recorded", - value: { type: c.type, name: c.name }, - }; - case "staff": - return { - type: "recorded", - value: { type: c.type, name: c.name }, - }; - case "other": - return { - type: "recorded", - value: { type: c.type, name: c.name }, - }; - } -} - -function snapshotSupportRecord( - sr: Karte["supportRecord"], -): SupportRecordSnapshot { - return { - assignedMemberIds: sr.assignedMemberIds, - content: sr.content, - resolution: sr.resolution, - workDuration: sr.workDuration, - }; -} diff --git a/src/domain/aggregates/karte/index.ts b/src/domain/aggregates/karte/index.ts index 5079abe..7d1980a 100644 --- a/src/domain/aggregates/karte/index.ts +++ b/src/domain/aggregates/karte/index.ts @@ -1,7 +1,6 @@ export * from "./Karte"; export * from "./KarteId"; export * from "./KarteRepository"; -export * from "./KarteSnapshot"; export * from "./Client"; export * from "./Consent"; export * from "./Consultation"; From fb7d0562521758e8b3a77a3d0659434620a5a220 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 21:31:57 +0900 Subject: [PATCH 17/26] =?UTF-8?q?refactor:=20WorkDuration=E3=82=92branded?= =?UTF-8?q?=20type=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97Recorded=E3=81=A7?= =?UTF-8?q?=E5=8C=85=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0以上の整数という不変条件をファクトリ関数で保証する。 過去データで未記録の場合があるためRecordedに変更。 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Karte.ts | 4 ++-- src/domain/aggregates/karte/SupportRecord.ts | 2 +- src/domain/aggregates/karte/WorkDuration.ts | 25 ++++++++++---------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index d6dc189..a473b0f 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -122,7 +122,7 @@ export class Karte { resolution: recorded( toRecordedResolution(props.supportRecord.resolution), ), - workDuration: props.supportRecord.workDuration, + workDuration: recorded(props.supportRecord.workDuration), }, ); } @@ -167,7 +167,7 @@ export class Karte { resolution: recorded( toRecordedResolution(props.supportRecord.resolution), ), - workDuration: props.supportRecord.workDuration, + workDuration: recorded(props.supportRecord.workDuration), }, ); } diff --git a/src/domain/aggregates/karte/SupportRecord.ts b/src/domain/aggregates/karte/SupportRecord.ts index 9b2cc7f..9e0490c 100644 --- a/src/domain/aggregates/karte/SupportRecord.ts +++ b/src/domain/aggregates/karte/SupportRecord.ts @@ -16,5 +16,5 @@ export type SupportRecord = { /** 解決ステータス */ readonly resolution: Recorded; /** 作業時間 */ - readonly workDuration: WorkDuration; + readonly workDuration: Recorded; }; diff --git a/src/domain/aggregates/karte/WorkDuration.ts b/src/domain/aggregates/karte/WorkDuration.ts index 3f26d7e..439e616 100644 --- a/src/domain/aggregates/karte/WorkDuration.ts +++ b/src/domain/aggregates/karte/WorkDuration.ts @@ -1,13 +1,12 @@ -/** - * 作業時間 - * - * FDM原則に従い、null/undefinedに意味を持たせない。 - * 作業時間が記録されている場合と記録されていない場合を明示的に区別する。 - */ -export type WorkDuration = - | { - readonly type: "recorded"; - /** 作業時間(分) */ - readonly minutes: number; - } - | { readonly type: "notRecorded" }; +import { InvalidWorkDurationException } from "#domain/exceptions"; + +/** 作業時間(分) — 0以上の整数であることを保証する */ +export type WorkDuration = number & { readonly __brand: unique symbol }; + +/** 作業時間を生成する — 0以上の整数でなければ例外 */ +export function workDuration(minutes: number): WorkDuration { + if (!Number.isInteger(minutes) || minutes < 0) { + throw new InvalidWorkDurationException(minutes); + } + return minutes as WorkDuration; +} From e5a2add92bfcceec6e955c2fc87669a294c59762 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 21:38:08 +0900 Subject: [PATCH 18/26] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE3=E4=BB=B6=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wifi_succesのタイポをwifi_successに修正 - InvalidWorkDurationExceptionのエラーメッセージを「0以上の整数」に修正 - Karte.create()のDate共有によるミュータブル参照問題を修正 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/ConsultationCategory.ts | 4 ++-- src/domain/aggregates/karte/Karte.ts | 6 +++--- src/domain/exceptions/DomainExceptions.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/domain/aggregates/karte/ConsultationCategory.ts b/src/domain/aggregates/karte/ConsultationCategory.ts index ba20a5e..f0d007e 100644 --- a/src/domain/aggregates/karte/ConsultationCategory.ts +++ b/src/domain/aggregates/karte/ConsultationCategory.ts @@ -13,7 +13,7 @@ export type ConsultationCategory = { /** 定義済みカテゴリID */ export type ConsultationCategoryId = | "wifi_eduroam" - | "wifi_succes" + | "wifi_success" | "wifi_smartphone" | "usage_mac" | "usage_fs" @@ -35,7 +35,7 @@ export type ConsultationCategoryId = /** 定義済みカテゴリ一覧 */ export const CONSULTATION_CATEGORIES: readonly ConsultationCategory[] = [ { id: "wifi_eduroam", displayName: "eduroamに対する接続方法の相談" }, - { id: "wifi_succes", displayName: "SUCCESSに対する接続方法の相談" }, + { id: "wifi_success", displayName: "SUCCESSに対する接続方法の相談" }, { id: "wifi_smartphone", displayName: "スマホからのWiFi接続方法に関する相談", diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index a473b0f..b033cad 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -103,12 +103,12 @@ export class Karte { /** 新規カルテの作成 — 全フィールド完全であることを型で保証する */ static create(props: KarteCreationProps): Karte { - const now = new Date(); + const now = Date.now(); return new Karte( props.id, - now, + new Date(now), recorded(props.consultedAt), - now, + new Date(now), recorded(props.client), props.consent, { diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 1c1f8fc..621cb88 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -116,7 +116,7 @@ export class InvalidStudentIdException extends DomainException { export class InvalidWorkDurationException extends DomainException { constructor(minutes: number) { - super(`無効な作業時間です: ${minutes}分 (正の数値で指定してください)`); + super(`無効な作業時間です: ${minutes}分 (0以上の整数で指定してください)`); this.name = "InvalidWorkDurationException"; } } From c3586ce0677595c7b7965450964a4c73e129bfe5 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 22:41:43 +0900 Subject: [PATCH 19/26] =?UTF-8?q?refactor:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB=E5=9F=BA=E3=81=A5=E3=81=8F?= =?UTF-8?q?=E3=82=AB=E3=83=AB=E3=83=86=E9=9B=86=E7=B4=84=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=94=B9=E5=96=84=E3=81=A8=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FollowUpDestination → FollowUp にリネーム(「見送り」はdestinationではない) - Recorded を domain/base から karte集約内に移動(現時点でkarte固有) - SupportRecord.assignedMemberIds を Recorded> に修正 - KarteRepository を findById + save のみに絞る(YAGNI) - KarteContentProps 抽出と toConsultation/toSupportRecord 関数化(Shotgun Surgery対策) - Karte.create/correct と WorkDuration のユニットテストを追加 Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Consultation.ts | 2 +- src/domain/aggregates/karte/FollowUp.ts | 7 + .../aggregates/karte/FollowUpDestination.ts | 7 - src/domain/aggregates/karte/Karte.ts | 100 +++++------ .../aggregates/karte/KarteRepository.ts | 3 - .../{base => aggregates/karte}/Recorded.ts | 2 +- src/domain/aggregates/karte/Resolution.ts | 10 +- src/domain/aggregates/karte/SupportRecord.ts | 5 +- src/domain/aggregates/karte/index.ts | 2 +- src/domain/base/index.ts | 1 - tests/domain/aggregates/karte/Karte.test.ts | 157 ++++++++++++++++++ .../aggregates/karte/WorkDuration.test.ts | 31 ++++ 12 files changed, 248 insertions(+), 79 deletions(-) create mode 100644 src/domain/aggregates/karte/FollowUp.ts delete mode 100644 src/domain/aggregates/karte/FollowUpDestination.ts rename src/domain/{base => aggregates/karte}/Recorded.ts (93%) create mode 100644 tests/domain/aggregates/karte/Karte.test.ts create mode 100644 tests/domain/aggregates/karte/WorkDuration.test.ts diff --git a/src/domain/aggregates/karte/Consultation.ts b/src/domain/aggregates/karte/Consultation.ts index fbefea4..84042b0 100644 --- a/src/domain/aggregates/karte/Consultation.ts +++ b/src/domain/aggregates/karte/Consultation.ts @@ -1,4 +1,4 @@ -import type { Recorded } from "#domain/base"; +import type { Recorded } from "./Recorded"; import type { ConsultationCategory } from "./ConsultationCategory"; /** diff --git a/src/domain/aggregates/karte/FollowUp.ts b/src/domain/aggregates/karte/FollowUp.ts new file mode 100644 index 0000000..3235a43 --- /dev/null +++ b/src/domain/aggregates/karte/FollowUp.ts @@ -0,0 +1,7 @@ +/** 後処理 — 相談対応後のアクション */ +export type FollowUp = + | "技術部" + | "生協" + | "情報基盤センター" + | "見送り" + | "その他"; diff --git a/src/domain/aggregates/karte/FollowUpDestination.ts b/src/domain/aggregates/karte/FollowUpDestination.ts deleted file mode 100644 index 22bdc48..0000000 --- a/src/domain/aggregates/karte/FollowUpDestination.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** 後処理先 — 相談対応後の引き継ぎ先 */ -export type FollowUpDestination = - | "技術部" - | "生協" - | "情報基盤センター" - | "見送り" - | "その他"; diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index b033cad..6be1891 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -1,48 +1,30 @@ -import { type NonEmptyArray, type Recorded, recorded } from "#domain/base"; +import type { NonEmptyArray } from "#domain/base/NonEmptyArray"; +import { type Recorded, recorded } from "./Recorded"; import type { Client } from "./Client"; import type { Consent } from "./Consent"; import type { Consultation } from "./Consultation"; import type { ConsultationCategory } from "./ConsultationCategory"; -import type { FollowUpDestination } from "./FollowUpDestination"; +import type { FollowUp } from "./FollowUp"; import type { KarteId } from "./KarteId"; import type { Resolution } from "./Resolution"; import type { SupportRecord } from "./SupportRecord"; import type { WorkDuration } from "./WorkDuration"; -/** - * 新規カルテ作成時の入力型 - * - * 全フィールドが完全に揃った状態を要求する。 - * Recorded型は使わず、生の値を受け取る。 - */ -type KarteCreationProps = { - readonly id: KarteId; - readonly consultedAt: Date; - readonly client: Client; - readonly consent: Consent; - readonly consultation: { - readonly categories: NonEmptyArray; - readonly targetDevice: string; - readonly troubleDetails: string; - }; - readonly supportRecord: { - readonly assignedMemberIds: NonEmptyArray; - readonly content: string; - readonly resolution: CompleteResolution; - readonly workDuration: WorkDuration; - }; -}; - -/** 新規作成時の解決ステータス — 後処理先は必須 */ +/** 新規作成時の解決ステータス — 後処理は必須 */ type CompleteResolution = | { readonly type: "resolved" } | { readonly type: "unresolved"; - readonly followUpDestination: FollowUpDestination; + readonly followUp: FollowUp; }; -/** 訂正時の入力型 — 作成時と同じく完全な生の値を要求する */ -type KarteCorrectionProps = { +/** + * カルテの内容を表す入力型 + * + * create/correctの共通部分。全フィールドが完全に揃った状態を要求する。 + * Recorded型は使わず、生の値を受け取る。 + */ +type KarteContentProps = { readonly consultedAt: Date; readonly client: Client; readonly consent: Consent; @@ -59,6 +41,9 @@ type KarteCorrectionProps = { }; }; +/** 新規カルテ作成時の入力型 */ +type KarteCreationProps = KarteContentProps & { readonly id: KarteId }; + /** 永続化データからの復元時の入力型 */ type KarteReconstructProps = { readonly id: KarteId; @@ -111,19 +96,8 @@ export class Karte { new Date(now), recorded(props.client), props.consent, - { - categories: recorded(props.consultation.categories), - targetDevice: recorded(props.consultation.targetDevice), - troubleDetails: props.consultation.troubleDetails, - }, - { - assignedMemberIds: recorded(props.supportRecord.assignedMemberIds), - content: props.supportRecord.content, - resolution: recorded( - toRecordedResolution(props.supportRecord.resolution), - ), - workDuration: recorded(props.supportRecord.workDuration), - }, + toConsultation(props), + toSupportRecord(props), ); } @@ -148,7 +122,7 @@ export class Karte { * recordedAt(元の記録日時)は保持し、lastUpdatedAt を現在時刻に更新する。 * 完全な生の値を受け取り、不変条件は型で保証する。 */ - correct(props: KarteCorrectionProps): Karte { + correct(props: KarteContentProps): Karte { return new Karte( this.id, this.recordedAt, @@ -156,29 +130,39 @@ export class Karte { new Date(), recorded(props.client), props.consent, - { - categories: recorded(props.consultation.categories), - targetDevice: recorded(props.consultation.targetDevice), - troubleDetails: props.consultation.troubleDetails, - }, - { - assignedMemberIds: recorded(props.supportRecord.assignedMemberIds), - content: props.supportRecord.content, - resolution: recorded( - toRecordedResolution(props.supportRecord.resolution), - ), - workDuration: recorded(props.supportRecord.workDuration), - }, + toConsultation(props), + toSupportRecord(props), ); } } +/** 生の入力から Consultation(Recorded付き)を構築する */ +function toConsultation(props: KarteContentProps): Consultation { + return { + categories: recorded(props.consultation.categories), + targetDevice: recorded(props.consultation.targetDevice), + troubleDetails: props.consultation.troubleDetails, + }; +} + +/** 生の入力から SupportRecord(Recorded付き)を構築する */ +function toSupportRecord(props: KarteContentProps): SupportRecord { + return { + assignedMemberIds: recorded(props.supportRecord.assignedMemberIds), + content: props.supportRecord.content, + resolution: recorded( + toRecordedResolution(props.supportRecord.resolution), + ), + workDuration: recorded(props.supportRecord.workDuration), + }; +} + function toRecordedResolution(complete: CompleteResolution): Resolution { if (complete.type === "resolved") { return { type: "resolved" }; } return { type: "unresolved", - followUpDestination: recorded(complete.followUpDestination), + followUp: recorded(complete.followUp), }; } diff --git a/src/domain/aggregates/karte/KarteRepository.ts b/src/domain/aggregates/karte/KarteRepository.ts index f6cb74f..d4f0b4c 100644 --- a/src/domain/aggregates/karte/KarteRepository.ts +++ b/src/domain/aggregates/karte/KarteRepository.ts @@ -3,8 +3,5 @@ import type { KarteId } from "./KarteId"; export interface KarteRepository { findById(id: KarteId): Promise; - findByClientStudentId(studentId: string): Promise; - findAll(): Promise; save(karte: Karte): Promise; - delete(karteId: KarteId): Promise; } diff --git a/src/domain/base/Recorded.ts b/src/domain/aggregates/karte/Recorded.ts similarity index 93% rename from src/domain/base/Recorded.ts rename to src/domain/aggregates/karte/Recorded.ts index ea617fc..c4c514c 100644 --- a/src/domain/base/Recorded.ts +++ b/src/domain/aggregates/karte/Recorded.ts @@ -1,5 +1,5 @@ /** - * 記録有無を明示する汎用型 + * 記録有無を明示する型 * * FDM原則に従い、null/undefinedに意味を持たせない。 * 過去データで記録されていない可能性があるフィールドに使用する。 diff --git a/src/domain/aggregates/karte/Resolution.ts b/src/domain/aggregates/karte/Resolution.ts index 577e4ee..176eda7 100644 --- a/src/domain/aggregates/karte/Resolution.ts +++ b/src/domain/aggregates/karte/Resolution.ts @@ -1,16 +1,16 @@ -import type { Recorded } from "#domain/base"; -import type { FollowUpDestination } from "./FollowUpDestination"; +import type { Recorded } from "./Recorded"; +import type { FollowUp } from "./FollowUp"; /** * 解決ステータス — 相談が解決したかどうか * - * 未解決の場合、後処理先は過去データでは未記録の場合がある。 + * 未解決の場合、後処理は過去データでは未記録の場合がある。 * FDM原則に従い、Recorded型で明示する。 */ export type Resolution = | { readonly type: "resolved" } | { readonly type: "unresolved"; - /** 後処理先 */ - readonly followUpDestination: Recorded; + /** 後処理 */ + readonly followUp: Recorded; }; diff --git a/src/domain/aggregates/karte/SupportRecord.ts b/src/domain/aggregates/karte/SupportRecord.ts index 9e0490c..17a2098 100644 --- a/src/domain/aggregates/karte/SupportRecord.ts +++ b/src/domain/aggregates/karte/SupportRecord.ts @@ -1,4 +1,5 @@ -import type { Recorded } from "#domain/base"; +import type { NonEmptyArray } from "#domain/base/NonEmptyArray"; +import type { Recorded } from "./Recorded"; import type { Resolution } from "./Resolution"; import type { WorkDuration } from "./WorkDuration"; @@ -10,7 +11,7 @@ import type { WorkDuration } from "./WorkDuration"; */ export type SupportRecord = { /** 担当メンバーID一覧 */ - readonly assignedMemberIds: Recorded; + readonly assignedMemberIds: Recorded>; /** 対応内容 */ readonly content: string; /** 解決ステータス */ diff --git a/src/domain/aggregates/karte/index.ts b/src/domain/aggregates/karte/index.ts index 7d1980a..04de17b 100644 --- a/src/domain/aggregates/karte/index.ts +++ b/src/domain/aggregates/karte/index.ts @@ -5,7 +5,7 @@ export * from "./Client"; export * from "./Consent"; export * from "./Consultation"; export * from "./ConsultationCategory"; -export * from "./FollowUpDestination"; +export * from "./FollowUp"; export * from "./Resolution"; export * from "./SupportRecord"; export * from "./WorkDuration"; diff --git a/src/domain/base/index.ts b/src/domain/base/index.ts index 1b39429..7e5040b 100644 --- a/src/domain/base/index.ts +++ b/src/domain/base/index.ts @@ -1,3 +1,2 @@ export * from "./NonEmptyArray"; -export * from "./Recorded"; export * from "./ValueObject"; diff --git a/tests/domain/aggregates/karte/Karte.test.ts b/tests/domain/aggregates/karte/Karte.test.ts new file mode 100644 index 0000000..ee0e4f5 --- /dev/null +++ b/tests/domain/aggregates/karte/Karte.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { Karte } from "#domain/aggregates/karte/Karte"; +import { karteId } from "#domain/aggregates/karte/KarteId"; +import { workDuration } from "#domain/aggregates/karte/WorkDuration"; +import { StudentId } from "#domain/shared/StudentId"; +import { UndergraduateAffiliation } from "#domain/shared/affiliation/Affiliation"; + +/** テスト用のKarteCreationPropsを生成する */ +function createProps() { + return { + id: karteId("test-karte-id"), + consultedAt: new Date("2025-01-15T10:00:00Z"), + client: { + type: "student" as const, + studentId: StudentId.fromString("70312031"), + name: "テスト太郎", + affiliation: new UndergraduateAffiliation({ + faculty: "情報学部", + department: "情報科学科", + year: 3, + }), + }, + consent: { + liabilityConsent: true, + disclosureConsent: false, + }, + consultation: { + categories: [ + { id: "wifi_eduroam" as const, displayName: "eduroamに対する接続方法の相談" }, + ] as [{ id: "wifi_eduroam"; displayName: string }], + targetDevice: "ノートPC", + troubleDetails: "eduroamに接続できない", + }, + supportRecord: { + assignedMemberIds: ["member-1"] as [string], + content: "プロファイルを再設定して解決", + resolution: { type: "resolved" as const }, + workDuration: workDuration(30), + }, + }; +} + +describe("Karte", () => { + describe("create", () => { + it("recordedAtとlastUpdatedAtが現在時刻で設定される", () => { + const before = Date.now(); + const karte = Karte.create(createProps()); + const after = Date.now(); + + expect(karte.recordedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(karte.recordedAt.getTime()).toBeLessThanOrEqual(after); + expect(karte.lastUpdatedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(karte.lastUpdatedAt.getTime()).toBeLessThanOrEqual(after); + }); + + it("recordedAtとlastUpdatedAtが同一時刻になる", () => { + const karte = Karte.create(createProps()); + + expect(karte.recordedAt.getTime()).toBe(karte.lastUpdatedAt.getTime()); + }); + + it("consultedAtがRecordedでラップされる", () => { + const karte = Karte.create(createProps()); + + expect(karte.consultedAt).toEqual({ + type: "recorded", + value: new Date("2025-01-15T10:00:00Z"), + }); + }); + + it("clientがRecordedでラップされる", () => { + const karte = Karte.create(createProps()); + + expect(karte.client.type).toBe("recorded"); + }); + + it("consultation内のフィールドがRecordedでラップされる", () => { + const karte = Karte.create(createProps()); + + expect(karte.consultation.categories.type).toBe("recorded"); + expect(karte.consultation.targetDevice.type).toBe("recorded"); + expect(karte.consultation.troubleDetails).toBe("eduroamに接続できない"); + }); + + it("supportRecord内のフィールドがRecordedでラップされる", () => { + const karte = Karte.create(createProps()); + + expect(karte.supportRecord.assignedMemberIds.type).toBe("recorded"); + expect(karte.supportRecord.resolution.type).toBe("recorded"); + expect(karte.supportRecord.workDuration.type).toBe("recorded"); + expect(karte.supportRecord.content).toBe("プロファイルを再設定して解決"); + }); + }); + + describe("correct", () => { + it("idとrecordedAtが元のカルテから保持される", () => { + const original = Karte.create(createProps()); + const corrected = original.correct({ + ...createProps(), + consultation: { + ...createProps().consultation, + troubleDetails: "訂正後の内容", + }, + }); + + expect(corrected.id).toBe(original.id); + expect(corrected.recordedAt).toBe(original.recordedAt); + }); + + it("lastUpdatedAtが新しい時刻に更新される", () => { + const original = Karte.create(createProps()); + const beforeCorrect = Date.now(); + const corrected = original.correct(createProps()); + + expect(corrected.lastUpdatedAt.getTime()).toBeGreaterThanOrEqual( + beforeCorrect, + ); + }); + + it("内容が新しい値で置き換わる", () => { + const original = Karte.create(createProps()); + const corrected = original.correct({ + ...createProps(), + consultation: { + ...createProps().consultation, + troubleDetails: "訂正: 実はVPN接続の問題だった", + }, + }); + + expect(corrected.consultation.troubleDetails).toBe( + "訂正: 実はVPN接続の問題だった", + ); + }); + + it("未解決の場合にfollowUpがRecordedでラップされる", () => { + const original = Karte.create(createProps()); + const corrected = original.correct({ + ...createProps(), + supportRecord: { + ...createProps().supportRecord, + resolution: { + type: "unresolved" as const, + followUp: "技術部" as const, + }, + }, + }); + + expect(corrected.supportRecord.resolution).toEqual({ + type: "recorded", + value: { + type: "unresolved", + followUp: { type: "recorded", value: "技術部" }, + }, + }); + }); + }); +}); diff --git a/tests/domain/aggregates/karte/WorkDuration.test.ts b/tests/domain/aggregates/karte/WorkDuration.test.ts new file mode 100644 index 0000000..8b2eb6e --- /dev/null +++ b/tests/domain/aggregates/karte/WorkDuration.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { InvalidWorkDurationException } from "#domain/exceptions"; +import { workDuration } from "#domain/aggregates/karte/WorkDuration"; + +describe("workDuration", () => { + describe("正常系", () => { + it("0分を生成できる", () => { + expect(workDuration(0)).toBe(0); + }); + + it("正の整数を生成できる", () => { + expect(workDuration(60)).toBe(60); + }); + }); + + describe("異常系", () => { + it("負数は無効", () => { + expect(() => workDuration(-1)).toThrow(InvalidWorkDurationException); + }); + + it("小数は無効", () => { + expect(() => workDuration(1.5)).toThrow(InvalidWorkDurationException); + }); + + it("NaNは無効", () => { + expect(() => workDuration(Number.NaN)).toThrow( + InvalidWorkDurationException, + ); + }); + }); +}); From b7d44173889c902d812a22596ccd6b4c5fddb4ef Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 22:52:28 +0900 Subject: [PATCH 20/26] =?UTF-8?q?refactor:=20Consultation.categories?= =?UTF-8?q?=E3=81=ABNonEmptyArray=E3=82=92=E9=81=A9=E7=94=A8=E3=81=97?= =?UTF-8?q?=E8=A8=AD=E8=A8=88=E5=88=A4=E6=96=AD=E3=82=92=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recordedのドメインルール整合性原則に基づき、categories型を修正。 DD-001〜DD-008の設計判断をdocs/design-decisions.mdに記録。 Co-Authored-By: Claude Opus 4.6 --- docs/design-decisions.md | 133 ++++++++++++++++++++ src/domain/aggregates/karte/Consultation.ts | 5 +- 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 docs/design-decisions.md diff --git a/docs/design-decisions.md b/docs/design-decisions.md new file mode 100644 index 0000000..960c345 --- /dev/null +++ b/docs/design-decisions.md @@ -0,0 +1,133 @@ +# 設計判断の記録 + +PR #82「PC相談室カルテ集約のドメインモデルを追加」に関する設計議論の結論を記録する。 + +--- + +## DD-001: 値オブジェクトの実装方式 + +**結論**: ブランド型 + ファクトリ関数を採用する。 + +**背景**: コードベースに2つのパターンが混在している。 + +| パターン | 方式 | 使用箇所 | +|---------|------|---------| +| A | `ValueObject` 基底クラス継承 | event/member集約 | +| B | ブランド型 + ファクトリ関数 | karte集約 | + +**判断理由**: + +- TypeScriptは構造的型付けのため、クラスで包まなくても型安全性が得られる +- ブランド型はプリミティブとして扱える(算術演算、`===` 比較など) +- 既存の `ValueObject` 基底クラスの `equals()` は JSON.stringify 比較に依存しており、実装として脆い +- ブランド型 + ファクトリ関数の方がTypeScriptのイディオムとして自然 + +**補足**: ブランド型はTypeScript公式機能ではなく、型システムのテクニック。VOはドメインモデリングの設計概念であり、ブランド型はその実装手段の一つ。 + +**今後の対応**: event/member集約のVOも段階的にブランド型パターンに移行する(別PR)。 + +--- + +## DD-002: 集約ルートの可変性 + +**結論**: 集約の性質に合わせて選択する(統一しない)。 + +**判断理由**: + +- カルテ=一度書いたら基本変えない「記録」→ immutableが自然 +- イベント=展示追加やメンバーアサインなど「育てていく」→ mutableが自然 +- 全集約を同一パターンに強制すると、ドメインの性質と実装が乖離する + +**指針**: 新規集約を設計する際は、そのドメイン概念が「記録型(immutable)」か「ライフサイクル型(mutable)」かで判断する。 + +--- + +## DD-003: KarteIdのバリデーション + +**結論**: 現時点ではブランドのみ付与し、バリデーションは入れない。 + +**判断理由**: + +- IDの形式(UUID、連番等)が未決定 +- 未決定の段階でバリデーションを入れると、形式決定時に不要な変更が発生する +- ブランド型だけで「KarteIdとstringの取り違え防止」は達成できる + +**今後の対応**: ID形式が決まった時点でファクトリ関数にバリデーションを追加する。 + +--- + +## DD-004: create()/correct()のShotgun Surgery対策 + +**結論**: 型と構築ロジックの共通化を行う。 + +**対策**: + +1. `KarteContentProps` 共通型を抽出し、`KarteCreationProps = KarteContentProps & { id }` とする。`correct()` は `KarteContentProps` を直接受け取る(エイリアスは不要) +2. `toConsultation()` / `toSupportRecord()` 関数でRecordedラッピングロジックを一元化 + +**効果**: フィールド追加時の変更箇所を5箇所→3箇所、共通フィールド変更を4箇所→2箇所に削減。 + +--- + +## DD-005: ConsultationCategoryの管理方式 + +**結論**: 型リテラル + 定数配列によるハードコードを維持する。 + +**背景**: カテゴリIDが型リテラルのユニオンと定数配列の両方にハードコードされており、DB管理への移行を検討した。 + +**判断理由**: + +- カテゴリの追加頻度は低い(現状ほぼ発生しない) +- 型リテラルによりtypoや不正値をコンパイル時に検出できる価値が大きい +- DB管理にすると管理UI/APIの開発コストが追加頻度に見合わない +- 「削除不可(履歴整合性)」のルールもコード上で明示できている + +**今後の対応**: 追加頻度が上がった場合にDB管理への移行を検討する。 + +--- + +## DD-006: FollowUpDestination → FollowUp へのリネーム + +**結論**: `FollowUpDestination` を `FollowUp` にリネームし、フィールド名も `followUpDestination` → `followUp` に統一する。 + +**判断理由**: + +- 「見送り」は引き継ぎ**先**(Destination)ではなく、後処理としての**アクション** +- `FollowUp`(後処理)であれば「見送り」も「技術部への引き継ぎ」も同列に扱える +- Destinationという名前がドメインの実態と乖離していた + +**未決事項**: 「その他」を選択した場合に自由記述フィールドが必要かどうか。ドメインエキスパートとの議論が必要。 + +--- + +## DD-007: Recordedの配置とドメインルール整合性 + +**結論**: + +1. `Recorded` をkarte集約内に移動する(`domain/base` → `domain/aggregates/karte`) +2. `NonEmptyArray` は汎用型として `domain/base` に残す +3. `SupportRecord.assignedMemberIds` の型を `Recorded` → `Recorded>` に修正する + +**判断理由**: + +- `Recorded` は現時点でkarte集約でのみ使用されるFDMパターンであり、汎用基盤に置くのは早計 +- 「記録された」状態であればドメインルール(担当者は1人以上)に従うべき。`Recorded` では `recorded([])` が型上許容されてしまう +- `NonEmptyArray` は「1つ以上の要素を持つ配列」という汎用概念であり、他の集約でも使用しうる + +**今後の対応**: 他の集約でも `Recorded` が必要になった時点で共有モジュールへの昇格を検討する。 + +--- + +## DD-008: KarteRepositoryのメソッド設計 + +**結論**: `findById` と `save` のみに絞る。`findAll`、`findByClientStudentId`、`delete` は削除する。 + +**判断理由**: + +- RepositoryはCRUDラッパーではなく、集約の永続化に必要な操作を表すドメインの語彙 +- ユースケース駆動で「今必要なものだけ」定義する(YAGNI) +- `findAll()`: 全件取得はスケーラビリティの問題があり、ユースケースが不明 +- `findByClientStudentId()`: 引数が `string` で型安全でなく、ユースケースも未定 +- `delete()`: カルテは「記録型(immutable)」であり、削除はドメイン的に不自然(DD-002参照) + +**今後の対応**: 検索や一覧のユースケースが明確になった時点で、適切な型を持つメソッドを追加する。 diff --git a/src/domain/aggregates/karte/Consultation.ts b/src/domain/aggregates/karte/Consultation.ts index 84042b0..4e90dae 100644 --- a/src/domain/aggregates/karte/Consultation.ts +++ b/src/domain/aggregates/karte/Consultation.ts @@ -1,5 +1,6 @@ -import type { Recorded } from "./Recorded"; +import type { NonEmptyArray } from "#domain/base/NonEmptyArray"; import type { ConsultationCategory } from "./ConsultationCategory"; +import type { Recorded } from "./Recorded"; /** * 相談事 @@ -9,7 +10,7 @@ import type { ConsultationCategory } from "./ConsultationCategory"; */ export type Consultation = { /** 相談カテゴリ(複数選択可) */ - readonly categories: Recorded; + readonly categories: Recorded>; /** 対象機器 */ readonly targetDevice: Recorded; /** トラブル詳細 */ From 5e192b1c4ce8e3639daa394b44a760fd247e221e Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 22:53:17 +0900 Subject: [PATCH 21/26] =?UTF-8?q?chore:=20docs/design-decisions.md?= =?UTF-8?q?=E3=82=92=E3=83=88=E3=83=A9=E3=83=83=E3=82=AD=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E3=81=8B=E3=82=89=E9=99=A4=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計判断の記録はPR本文に記載する。 Co-Authored-By: Claude Opus 4.6 --- docs/design-decisions.md | 133 --------------------------------------- 1 file changed, 133 deletions(-) delete mode 100644 docs/design-decisions.md diff --git a/docs/design-decisions.md b/docs/design-decisions.md deleted file mode 100644 index 960c345..0000000 --- a/docs/design-decisions.md +++ /dev/null @@ -1,133 +0,0 @@ -# 設計判断の記録 - -PR #82「PC相談室カルテ集約のドメインモデルを追加」に関する設計議論の結論を記録する。 - ---- - -## DD-001: 値オブジェクトの実装方式 - -**結論**: ブランド型 + ファクトリ関数を採用する。 - -**背景**: コードベースに2つのパターンが混在している。 - -| パターン | 方式 | 使用箇所 | -|---------|------|---------| -| A | `ValueObject` 基底クラス継承 | event/member集約 | -| B | ブランド型 + ファクトリ関数 | karte集約 | - -**判断理由**: - -- TypeScriptは構造的型付けのため、クラスで包まなくても型安全性が得られる -- ブランド型はプリミティブとして扱える(算術演算、`===` 比較など) -- 既存の `ValueObject` 基底クラスの `equals()` は JSON.stringify 比較に依存しており、実装として脆い -- ブランド型 + ファクトリ関数の方がTypeScriptのイディオムとして自然 - -**補足**: ブランド型はTypeScript公式機能ではなく、型システムのテクニック。VOはドメインモデリングの設計概念であり、ブランド型はその実装手段の一つ。 - -**今後の対応**: event/member集約のVOも段階的にブランド型パターンに移行する(別PR)。 - ---- - -## DD-002: 集約ルートの可変性 - -**結論**: 集約の性質に合わせて選択する(統一しない)。 - -**判断理由**: - -- カルテ=一度書いたら基本変えない「記録」→ immutableが自然 -- イベント=展示追加やメンバーアサインなど「育てていく」→ mutableが自然 -- 全集約を同一パターンに強制すると、ドメインの性質と実装が乖離する - -**指針**: 新規集約を設計する際は、そのドメイン概念が「記録型(immutable)」か「ライフサイクル型(mutable)」かで判断する。 - ---- - -## DD-003: KarteIdのバリデーション - -**結論**: 現時点ではブランドのみ付与し、バリデーションは入れない。 - -**判断理由**: - -- IDの形式(UUID、連番等)が未決定 -- 未決定の段階でバリデーションを入れると、形式決定時に不要な変更が発生する -- ブランド型だけで「KarteIdとstringの取り違え防止」は達成できる - -**今後の対応**: ID形式が決まった時点でファクトリ関数にバリデーションを追加する。 - ---- - -## DD-004: create()/correct()のShotgun Surgery対策 - -**結論**: 型と構築ロジックの共通化を行う。 - -**対策**: - -1. `KarteContentProps` 共通型を抽出し、`KarteCreationProps = KarteContentProps & { id }` とする。`correct()` は `KarteContentProps` を直接受け取る(エイリアスは不要) -2. `toConsultation()` / `toSupportRecord()` 関数でRecordedラッピングロジックを一元化 - -**効果**: フィールド追加時の変更箇所を5箇所→3箇所、共通フィールド変更を4箇所→2箇所に削減。 - ---- - -## DD-005: ConsultationCategoryの管理方式 - -**結論**: 型リテラル + 定数配列によるハードコードを維持する。 - -**背景**: カテゴリIDが型リテラルのユニオンと定数配列の両方にハードコードされており、DB管理への移行を検討した。 - -**判断理由**: - -- カテゴリの追加頻度は低い(現状ほぼ発生しない) -- 型リテラルによりtypoや不正値をコンパイル時に検出できる価値が大きい -- DB管理にすると管理UI/APIの開発コストが追加頻度に見合わない -- 「削除不可(履歴整合性)」のルールもコード上で明示できている - -**今後の対応**: 追加頻度が上がった場合にDB管理への移行を検討する。 - ---- - -## DD-006: FollowUpDestination → FollowUp へのリネーム - -**結論**: `FollowUpDestination` を `FollowUp` にリネームし、フィールド名も `followUpDestination` → `followUp` に統一する。 - -**判断理由**: - -- 「見送り」は引き継ぎ**先**(Destination)ではなく、後処理としての**アクション** -- `FollowUp`(後処理)であれば「見送り」も「技術部への引き継ぎ」も同列に扱える -- Destinationという名前がドメインの実態と乖離していた - -**未決事項**: 「その他」を選択した場合に自由記述フィールドが必要かどうか。ドメインエキスパートとの議論が必要。 - ---- - -## DD-007: Recordedの配置とドメインルール整合性 - -**結論**: - -1. `Recorded` をkarte集約内に移動する(`domain/base` → `domain/aggregates/karte`) -2. `NonEmptyArray` は汎用型として `domain/base` に残す -3. `SupportRecord.assignedMemberIds` の型を `Recorded` → `Recorded>` に修正する - -**判断理由**: - -- `Recorded` は現時点でkarte集約でのみ使用されるFDMパターンであり、汎用基盤に置くのは早計 -- 「記録された」状態であればドメインルール(担当者は1人以上)に従うべき。`Recorded` では `recorded([])` が型上許容されてしまう -- `NonEmptyArray` は「1つ以上の要素を持つ配列」という汎用概念であり、他の集約でも使用しうる - -**今後の対応**: 他の集約でも `Recorded` が必要になった時点で共有モジュールへの昇格を検討する。 - ---- - -## DD-008: KarteRepositoryのメソッド設計 - -**結論**: `findById` と `save` のみに絞る。`findAll`、`findByClientStudentId`、`delete` は削除する。 - -**判断理由**: - -- RepositoryはCRUDラッパーではなく、集約の永続化に必要な操作を表すドメインの語彙 -- ユースケース駆動で「今必要なものだけ」定義する(YAGNI) -- `findAll()`: 全件取得はスケーラビリティの問題があり、ユースケースが不明 -- `findByClientStudentId()`: 引数が `string` で型安全でなく、ユースケースも未定 -- `delete()`: カルテは「記録型(immutable)」であり、削除はドメイン的に不自然(DD-002参照) - -**今後の対応**: 検索や一覧のユースケースが明確になった時点で、適切な型を持つメソッドを追加する。 From eaa8cfb862a7d28f5088d208d92926d6042afc3d Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 14 Mar 2026 23:12:51 +0900 Subject: [PATCH 22/26] =?UTF-8?q?style:=20Biome=E3=81=AEimport=E9=A0=86?= =?UTF-8?q?=E5=BA=8F=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- tests/domain/aggregates/karte/Karte.test.ts | 5 ++++- tests/domain/aggregates/karte/WorkDuration.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/domain/aggregates/karte/Karte.test.ts b/tests/domain/aggregates/karte/Karte.test.ts index ee0e4f5..19fb9d5 100644 --- a/tests/domain/aggregates/karte/Karte.test.ts +++ b/tests/domain/aggregates/karte/Karte.test.ts @@ -26,7 +26,10 @@ function createProps() { }, consultation: { categories: [ - { id: "wifi_eduroam" as const, displayName: "eduroamに対する接続方法の相談" }, + { + id: "wifi_eduroam" as const, + displayName: "eduroamに対する接続方法の相談", + }, ] as [{ id: "wifi_eduroam"; displayName: string }], targetDevice: "ノートPC", troubleDetails: "eduroamに接続できない", diff --git a/tests/domain/aggregates/karte/WorkDuration.test.ts b/tests/domain/aggregates/karte/WorkDuration.test.ts index 8b2eb6e..6da9e41 100644 --- a/tests/domain/aggregates/karte/WorkDuration.test.ts +++ b/tests/domain/aggregates/karte/WorkDuration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { InvalidWorkDurationException } from "#domain/exceptions"; import { workDuration } from "#domain/aggregates/karte/WorkDuration"; +import { InvalidWorkDurationException } from "#domain/exceptions"; describe("workDuration", () => { describe("正常系", () => { From d149ca398d52019dcedd4af13faa222be5476ec5 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sun, 15 Mar 2026 09:25:31 +0900 Subject: [PATCH 23/26] =?UTF-8?q?style:=20Biome=E3=81=AEimport=E9=A0=86?= =?UTF-8?q?=E5=BA=8F=E3=81=A8=E3=83=95=E3=82=A9=E3=83=BC=E3=83=9E=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/domain/aggregates/karte/Karte.ts | 6 ++---- src/domain/aggregates/karte/Resolution.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index 6be1891..7f9c2f9 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -1,11 +1,11 @@ import type { NonEmptyArray } from "#domain/base/NonEmptyArray"; -import { type Recorded, recorded } from "./Recorded"; import type { Client } from "./Client"; import type { Consent } from "./Consent"; import type { Consultation } from "./Consultation"; import type { ConsultationCategory } from "./ConsultationCategory"; import type { FollowUp } from "./FollowUp"; import type { KarteId } from "./KarteId"; +import { type Recorded, recorded } from "./Recorded"; import type { Resolution } from "./Resolution"; import type { SupportRecord } from "./SupportRecord"; import type { WorkDuration } from "./WorkDuration"; @@ -150,9 +150,7 @@ function toSupportRecord(props: KarteContentProps): SupportRecord { return { assignedMemberIds: recorded(props.supportRecord.assignedMemberIds), content: props.supportRecord.content, - resolution: recorded( - toRecordedResolution(props.supportRecord.resolution), - ), + resolution: recorded(toRecordedResolution(props.supportRecord.resolution)), workDuration: recorded(props.supportRecord.workDuration), }; } diff --git a/src/domain/aggregates/karte/Resolution.ts b/src/domain/aggregates/karte/Resolution.ts index 176eda7..a21cf97 100644 --- a/src/domain/aggregates/karte/Resolution.ts +++ b/src/domain/aggregates/karte/Resolution.ts @@ -1,5 +1,5 @@ -import type { Recorded } from "./Recorded"; import type { FollowUp } from "./FollowUp"; +import type { Recorded } from "./Recorded"; /** * 解決ステータス — 相談が解決したかどうか From 660def44288d2d37bea3390ee82def623ee61371 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 21 Mar 2026 00:17:15 +0900 Subject: [PATCH 24/26] =?UTF-8?q?fix:=20Recorded=E5=9E=8B=E3=81=AEbarrel?= =?UTF-8?q?=E3=82=A8=E3=82=AF=E3=82=B9=E3=83=9D=E3=83=BC=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/domain/aggregates/karte/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/aggregates/karte/index.ts b/src/domain/aggregates/karte/index.ts index 04de17b..be5ff63 100644 --- a/src/domain/aggregates/karte/index.ts +++ b/src/domain/aggregates/karte/index.ts @@ -1,6 +1,7 @@ export * from "./Karte"; export * from "./KarteId"; export * from "./KarteRepository"; +export * from "./Recorded"; export * from "./Client"; export * from "./Consent"; export * from "./Consultation"; From a235b711aef5f672cb590c55916bd5e0cd80d11f Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 21 Mar 2026 00:49:05 +0900 Subject: [PATCH 25/26] =?UTF-8?q?refactor:=20Date=E3=83=95=E3=82=A3?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=89=E3=82=92getter=E3=81=AB=E3=82=88?= =?UTF-8?q?=E3=82=8B=E9=98=B2=E5=BE=A1=E7=9A=84=E3=82=B3=E3=83=94=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dateはミュータブルなため、public readonlyでは破壊的メソッド (setTime()等)による変更を防げない。privateで保持しgetterで 新しいDateインスタンスを返すことで集約の不変性を保証する。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/domain/aggregates/karte/Karte.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/domain/aggregates/karte/Karte.ts b/src/domain/aggregates/karte/Karte.ts index 7f9c2f9..d3fc923 100644 --- a/src/domain/aggregates/karte/Karte.ts +++ b/src/domain/aggregates/karte/Karte.ts @@ -68,14 +68,19 @@ type KarteReconstructProps = { * 新規作成時は create() により全フィールドの完全性を保証する。 */ export class Karte { + /** + * Dateはミュータブルなため、privateで保持しgetterで防御的コピーを返す。 + * readonlyは再代入を防ぐが、Date.setTime()等の破壊的メソッドは防げない。 + */ + private readonly _recordedAt: Date; + private readonly _lastUpdatedAt: Date; + private constructor( public readonly id: KarteId, - /** 記録日時 */ - public readonly recordedAt: Date, + recordedAt: Date, /** 相談日時 */ public readonly consultedAt: Recorded, - /** 最終更新日時 */ - public readonly lastUpdatedAt: Date, + lastUpdatedAt: Date, /** 相談者 */ public readonly client: Recorded, /** 同意事項 */ @@ -84,7 +89,20 @@ export class Karte { public readonly consultation: Consultation, /** 対応記録 */ public readonly supportRecord: SupportRecord, - ) {} + ) { + this._recordedAt = recordedAt; + this._lastUpdatedAt = lastUpdatedAt; + } + + /** 記録日時 */ + get recordedAt(): Date { + return new Date(this._recordedAt); + } + + /** 最終更新日時 */ + get lastUpdatedAt(): Date { + return new Date(this._lastUpdatedAt); + } /** 新規カルテの作成 — 全フィールド完全であることを型で保証する */ static create(props: KarteCreationProps): Karte { From 896530514dcc6c4d4e01565c297b9021a2e21345 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Sat, 21 Mar 2026 01:40:35 +0900 Subject: [PATCH 26/26] =?UTF-8?q?fix:=20Date=E3=81=AE=E9=98=B2=E5=BE=A1?= =?UTF-8?q?=E7=9A=84=E3=82=B3=E3=83=94=E3=83=BC=E3=81=AB=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=9B=E3=81=A6=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E5=8F=82?= =?UTF-8?q?=E7=85=A7=E6=AF=94=E8=BC=83=E3=82=92=E5=80=A4=E6=AF=94=E8=BC=83?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/domain/aggregates/karte/Karte.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/domain/aggregates/karte/Karte.test.ts b/tests/domain/aggregates/karte/Karte.test.ts index 19fb9d5..a639af3 100644 --- a/tests/domain/aggregates/karte/Karte.test.ts +++ b/tests/domain/aggregates/karte/Karte.test.ts @@ -107,7 +107,7 @@ describe("Karte", () => { }); expect(corrected.id).toBe(original.id); - expect(corrected.recordedAt).toBe(original.recordedAt); + expect(corrected.recordedAt).toEqual(original.recordedAt); }); it("lastUpdatedAtが新しい時刻に更新される", () => {