From afc8cebc8ff0604d145bbdef2d888b5f33550d86 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Fri, 16 Jan 2026 19:41:58 -0800 Subject: [PATCH] feat(effort): VPR-42 - Harvest Import process - Add multi-phase harvest importing instructors, courses, and effort records from CREST, Banner, and Clinical Scheduler sources - Implement SSE progress streaming and preview/diff UI before commit - Add transactional harvest with rollback on failure - Fix time parser for military time formats (e.g., "1430") - Skip instructors without valid VIPER person records - Use generic error messages to prevent info disclosure - Add CrestContext/DictionaryContext for cross-database queries - Split types/index.ts into domain-specific modules --- .../Effort/__tests__/harvest-dialog.test.ts | 428 +++++++++++ .../__tests__/instructor-add-dialog.test.ts | 16 +- .../src/Effort/components/HarvestDialog.vue | 681 ++++++++++++++++++ VueApp/src/Effort/pages/TermManagement.vue | 59 +- VueApp/src/Effort/services/harvest-service.ts | 57 ++ VueApp/src/Effort/types/admin-types.ts | 62 ++ VueApp/src/Effort/types/audit-types.ts | 33 + VueApp/src/Effort/types/course-types.ts | 88 +++ VueApp/src/Effort/types/harvest-types.ts | 100 +++ VueApp/src/Effort/types/index.ts | 330 +-------- VueApp/src/Effort/types/instructor-types.ts | 113 +++ VueApp/src/Effort/types/term-types.ts | 54 ++ scripts/lib/lint-staged-common.js | 6 +- scripts/lint-staged-dotnet.js | 40 +- test/Effort/EffortTypeServiceTests.cs | 55 +- .../EffortTypesControllerIntegrationTests.cs | 55 +- test/Effort/EffortTypesControllerTests.cs | 55 +- test/Effort/HarvestServiceTests.cs | 659 +++++++++++++++++ test/Effort/HarvestTimeParserTests.cs | 210 ++++++ test/Effort/InstructorServiceTests.cs | 56 +- .../Effort/Constants/EffortAuditActions.cs | 1 + web/Areas/Effort/Constants/EffortConstants.cs | 140 ++++ .../Effort/Controllers/HarvestController.cs | 178 +++++ .../DTOs/Requests/UpdateEffortTypeRequest.cs | 12 +- .../DTOs/Responses/HarvestPreviewDto.cs | 158 ++++ .../DTOs/Responses/HarvestProgressEvent.cs | 107 +++ .../Models/DTOs/Responses/HarvestResultDto.cs | 14 + .../Effort/Models/DTOs/Responses/TermDto.cs | 5 + .../Effort/Services/EffortAuditService.cs | 71 +- .../Services/Harvest/ClinicalHarvestPhase.cs | 378 ++++++++++ .../Services/Harvest/CrestHarvestPhase.cs | 485 +++++++++++++ .../Services/Harvest/GuestAccountPhase.cs | 71 ++ .../Effort/Services/Harvest/HarvestContext.cs | 199 +++++ .../Services/Harvest/HarvestPhaseBase.cs | 301 ++++++++ .../Services/Harvest/HarvestTimeParser.cs | 121 ++++ .../Effort/Services/Harvest/IHarvestPhase.cs | 42 ++ .../Services/Harvest/NonCrestHarvestPhase.cs | 331 +++++++++ web/Areas/Effort/Services/HarvestService.cs | 572 +++++++++++++++ .../Effort/Services/IEffortAuditService.cs | 24 + web/Areas/Effort/Services/IHarvestService.cs | 40 + .../Effort/Services/IInstructorService.cs | 8 + .../Effort/Services/InstructorService.cs | 250 +++---- web/Classes/SQLContext/CrestContext.cs | 80 ++ web/Classes/SQLContext/DictionaryContext.cs | 53 ++ web/Models/Crest/CrestBlock.cs | 12 + .../Crest/CrestCourseSessionOffering.cs | 20 + web/Models/Crest/EdutaskOfferPerson.cs | 11 + web/Models/Crest/EdutaskPerson.cs | 13 + web/Models/Dictionary/DvtSvmUnit.cs | 12 + web/Models/Dictionary/DvtTitle.cs | 14 + web/Program.cs | 9 + web/appsettings.Development.json | 2 + web/appsettings.Production.json | 2 + web/appsettings.Test.json | 2 + 54 files changed, 6347 insertions(+), 548 deletions(-) create mode 100644 VueApp/src/Effort/__tests__/harvest-dialog.test.ts create mode 100644 VueApp/src/Effort/components/HarvestDialog.vue create mode 100644 VueApp/src/Effort/services/harvest-service.ts create mode 100644 VueApp/src/Effort/types/admin-types.ts create mode 100644 VueApp/src/Effort/types/audit-types.ts create mode 100644 VueApp/src/Effort/types/course-types.ts create mode 100644 VueApp/src/Effort/types/harvest-types.ts create mode 100644 VueApp/src/Effort/types/instructor-types.ts create mode 100644 VueApp/src/Effort/types/term-types.ts create mode 100644 test/Effort/HarvestServiceTests.cs create mode 100644 test/Effort/HarvestTimeParserTests.cs create mode 100644 web/Areas/Effort/Constants/EffortConstants.cs create mode 100644 web/Areas/Effort/Controllers/HarvestController.cs create mode 100644 web/Areas/Effort/Models/DTOs/Responses/HarvestPreviewDto.cs create mode 100644 web/Areas/Effort/Models/DTOs/Responses/HarvestProgressEvent.cs create mode 100644 web/Areas/Effort/Models/DTOs/Responses/HarvestResultDto.cs create mode 100644 web/Areas/Effort/Services/Harvest/ClinicalHarvestPhase.cs create mode 100644 web/Areas/Effort/Services/Harvest/CrestHarvestPhase.cs create mode 100644 web/Areas/Effort/Services/Harvest/GuestAccountPhase.cs create mode 100644 web/Areas/Effort/Services/Harvest/HarvestContext.cs create mode 100644 web/Areas/Effort/Services/Harvest/HarvestPhaseBase.cs create mode 100644 web/Areas/Effort/Services/Harvest/HarvestTimeParser.cs create mode 100644 web/Areas/Effort/Services/Harvest/IHarvestPhase.cs create mode 100644 web/Areas/Effort/Services/Harvest/NonCrestHarvestPhase.cs create mode 100644 web/Areas/Effort/Services/HarvestService.cs create mode 100644 web/Areas/Effort/Services/IHarvestService.cs create mode 100644 web/Classes/SQLContext/CrestContext.cs create mode 100644 web/Classes/SQLContext/DictionaryContext.cs create mode 100644 web/Models/Crest/CrestBlock.cs create mode 100644 web/Models/Crest/CrestCourseSessionOffering.cs create mode 100644 web/Models/Crest/EdutaskOfferPerson.cs create mode 100644 web/Models/Crest/EdutaskPerson.cs create mode 100644 web/Models/Dictionary/DvtSvmUnit.cs create mode 100644 web/Models/Dictionary/DvtTitle.cs diff --git a/VueApp/src/Effort/__tests__/harvest-dialog.test.ts b/VueApp/src/Effort/__tests__/harvest-dialog.test.ts new file mode 100644 index 00000000..7dc7d6b1 --- /dev/null +++ b/VueApp/src/Effort/__tests__/harvest-dialog.test.ts @@ -0,0 +1,428 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref, computed } from "vue" +import { setActivePinia, createPinia } from "pinia" +import type { HarvestPreviewDto, HarvestResultDto } from "../types" + +/** + * Tests for HarvestDialog error handling, loading states, and validation behavior. + * + * These tests validate that the component properly handles various states + * during the harvest preview and commit process. + */ + +// Mock the harvest service +const mockGetPreview = vi.fn() +const mockCommitHarvest = vi.fn() +vi.mock("../services/harvest-service", () => ({ + harvestService: { + getPreview: (...args: unknown[]) => mockGetPreview(...args), + commitHarvest: (...args: unknown[]) => mockCommitHarvest(...args), + }, +})) + +// Helper to create a minimal valid preview +function createMockPreview(overrides: Partial = {}): HarvestPreviewDto { + return { + termCode: 202_410, + termName: "Fall 2024", + crestInstructors: [], + crestCourses: [], + crestEffort: [], + nonCrestInstructors: [], + nonCrestCourses: [], + nonCrestEffort: [], + clinicalInstructors: [], + clinicalCourses: [], + clinicalEffort: [], + guestAccounts: [], + removedInstructors: [], + removedCourses: [], + summary: { + totalInstructors: 0, + totalCourses: 0, + totalEffortRecords: 0, + guestAccounts: 0, + }, + warnings: [], + errors: [], + ...overrides, + } +} + +describe("HarvestDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Preview Load Errors", () => { + it("should capture error message when preview returns null", async () => { + const loadError = ref(null) + const preview = ref(null) + + mockGetPreview.mockResolvedValue(null) + + // Simulate loadPreview logic + const result = await mockGetPreview(202_410) + if (result) { + preview.value = result + } else { + loadError.value = "Failed to load harvest preview" + } + + expect(loadError.value).toBe("Failed to load harvest preview") + expect(preview.value).toBeNull() + }) + + it("should capture error message from thrown exception", async () => { + const loadError = ref(null) + + mockGetPreview.mockRejectedValue(new Error("Network error")) + + // Simulate loadPreview error handling + try { + await mockGetPreview(202_410) + } catch (err) { + loadError.value = err instanceof Error ? err.message : "Failed to load harvest preview" + } + + expect(loadError.value).toBe("Network error") + }) + + it("should use default message when exception has no message", async () => { + const loadError = ref(null) + + mockGetPreview.mockRejectedValue("Unknown error") + + // Simulate loadPreview error handling + try { + await mockGetPreview(202_410) + } catch (err) { + loadError.value = err instanceof Error ? err.message : "Failed to load harvest preview" + } + + expect(loadError.value).toBe("Failed to load harvest preview") + }) + + it("should clear error when preview loads successfully", async () => { + const loadError = ref("Previous error") + const preview = ref(null) + + const mockPreview = createMockPreview() + mockGetPreview.mockResolvedValue(mockPreview) + + // Simulate loadPreview success + loadError.value = null + const result = await mockGetPreview(202_410) + if (result) { + preview.value = result + } + + expect(loadError.value).toBeNull() + expect(preview.value).not.toBeNull() + }) + }) + + describe("Commit Errors", () => { + it("should detect failed commit from success: false", () => { + const result: HarvestResultDto = { + success: false, + termCode: 202_410, + harvestedDate: null, + summary: { totalInstructors: 0, totalCourses: 0, totalEffortRecords: 0, guestAccounts: 0 }, + warnings: [], + errorMessage: "Term is locked", + } + + const errorMessage = result.success ? null : (result.errorMessage ?? "Harvest failed") + + expect(errorMessage).toBe("Term is locked") + }) + + it("should use default message when commit fails without error message", () => { + const result: HarvestResultDto = { + success: false, + termCode: 202_410, + harvestedDate: null, + summary: { totalInstructors: 0, totalCourses: 0, totalEffortRecords: 0, guestAccounts: 0 }, + warnings: [], + errorMessage: null, + } + + const errorMessage = result.success ? null : (result.errorMessage ?? "Harvest failed") + + expect(errorMessage).toBe("Harvest failed") + }) + }) +}) + +describe("HarvestDialog - Loading States", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it("should track loading state during preview fetch", async () => { + const isLoading = ref(false) + + isLoading.value = true + expect(isLoading.value).toBeTruthy() + + // Simulate async operation + await Promise.resolve() + + isLoading.value = false + expect(isLoading.value).toBeFalsy() + }) + + it("should track committing state during harvest commit", async () => { + const isCommitting = ref(false) + + isCommitting.value = true + expect(isCommitting.value).toBeTruthy() + + // Simulate async operation + await Promise.resolve() + + isCommitting.value = false + expect(isCommitting.value).toBeFalsy() + }) + + it("should disable confirm button while committing", () => { + const isCommitting = ref(true) + const preview = ref(createMockPreview()) + + // Button is disabled when committing or when there are errors + const canConfirm = !isCommitting.value && preview.value && preview.value.errors.length === 0 + + expect(canConfirm).toBeFalsy() + }) + + it("should enable confirm button when not committing and no errors", () => { + const isCommitting = ref(false) + const preview = ref(createMockPreview()) + + const canConfirm = !isCommitting.value && preview.value && preview.value.errors.length === 0 + + expect(canConfirm).toBeTruthy() + }) +}) + +describe("HarvestDialog - Validation Logic", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it("should disable confirm when errors exist in preview", () => { + const preview = ref( + createMockPreview({ + errors: [{ phase: "CREST", message: "No data found", details: "" }], + }), + ) + + const canConfirm = preview.value && preview.value.errors.length === 0 + + expect(canConfirm).toBeFalsy() + }) + + it("should enable confirm when no errors exist", () => { + const preview = ref(createMockPreview({ errors: [] })) + + const canConfirm = preview.value && preview.value.errors.length === 0 + + expect(canConfirm).toBeTruthy() + }) + + it("should correctly identify when removed items exist", () => { + const preview = ref( + createMockPreview({ + removedInstructors: [ + { + mothraId: "TEST123", + personId: 1, + fullName: "Test, User", + firstName: "User", + lastName: "Test", + department: "VME", + titleCode: "001", + titleDescription: "Professor", + source: "Existing", + isNew: false, + }, + ], + }), + ) + + const hasRemovedItems = computed( + () => + preview.value && + (preview.value.removedInstructors.length > 0 || preview.value.removedCourses.length > 0), + ) + + expect(hasRemovedItems.value).toBeTruthy() + }) + + it("should correctly identify when no removed items exist", () => { + const preview = ref( + createMockPreview({ + removedInstructors: [], + removedCourses: [], + }), + ) + + const hasRemovedItems = computed( + () => + preview.value && + (preview.value.removedInstructors.length > 0 || preview.value.removedCourses.length > 0), + ) + + expect(hasRemovedItems.value).toBeFalsy() + }) + + it("should identify removed courses", () => { + const preview = ref( + createMockPreview({ + removedCourses: [ + { + crn: "12345", + subjCode: "VME", + crseNumb: "100", + seqNumb: "001", + enrollment: 10, + units: 4, + custDept: "VME", + source: "Existing", + isNew: false, + }, + ], + }), + ) + + const hasRemovedItems = computed( + () => + preview.value && + (preview.value.removedInstructors.length > 0 || preview.value.removedCourses.length > 0), + ) + + expect(hasRemovedItems.value).toBeTruthy() + }) +}) + +describe("HarvestDialog - State Reset", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it("should reset all state when dialog closes", () => { + // Simulate state after dialog was open + const preview = ref(createMockPreview()) + const loadError = ref("Some error") + const activeTab = ref("clinical") + + // Simulate dialog close reset + preview.value = null + loadError.value = null + activeTab.value = "crest" + + expect(preview.value).toBeNull() + expect(loadError.value).toBeNull() + expect(activeTab.value).toBe("crest") + }) + + it("should not load preview when termCode is null", async () => { + const termCode = ref(null) + const preview = ref(null) + + // Simulate loadPreview guard + if (termCode.value) { + const result = await mockGetPreview(termCode.value) + if (result) { + preview.value = result + } + } + + expect(mockGetPreview).not.toHaveBeenCalled() + expect(preview.value).toBeNull() + }) + + it("should load preview when termCode is provided", async () => { + const termCode = ref(202_410) + const preview = ref(null) + + const mockPreview = createMockPreview() + mockGetPreview.mockResolvedValue(mockPreview) + + // Simulate loadPreview with termCode + if (termCode.value) { + const result = await mockGetPreview(termCode.value) + if (result) { + preview.value = result + } + } + + expect(mockGetPreview).toHaveBeenCalledWith(202_410) + expect(preview.value).not.toBeNull() + }) +}) + +describe("HarvestDialog - Summary Display", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it("should display correct summary totals", () => { + const preview = ref( + createMockPreview({ + summary: { + totalInstructors: 25, + totalCourses: 15, + totalEffortRecords: 100, + guestAccounts: 6, + }, + }), + ) + + expect(preview.value?.summary.totalInstructors).toBe(25) + expect(preview.value?.summary.totalCourses).toBe(15) + expect(preview.value?.summary.totalEffortRecords).toBe(100) + expect(preview.value?.summary.guestAccounts).toBe(6) + }) + + it("should track warning count correctly", () => { + const preview = ref( + createMockPreview({ + warnings: [ + { phase: "", message: "Warning 1", details: "" }, + { phase: "CREST", message: "Warning 2", details: "" }, + { phase: "Clinical", message: "Warning 3", details: "" }, + ], + }), + ) + + expect(preview.value?.warnings.length).toBe(3) + }) + + it("should limit displayed warnings to 5", () => { + const warnings = [ + { phase: "", message: "Warning 1", details: "" }, + { phase: "", message: "Warning 2", details: "" }, + { phase: "", message: "Warning 3", details: "" }, + { phase: "", message: "Warning 4", details: "" }, + { phase: "", message: "Warning 5", details: "" }, + { phase: "", message: "Warning 6", details: "" }, + { phase: "", message: "Warning 7", details: "" }, + ] + + const preview = ref(createMockPreview({ warnings })) + + // Component displays first 5 warnings + const displayedWarnings = preview.value?.warnings.slice(0, 5) ?? [] + const remainingCount = (preview.value?.warnings.length ?? 0) - 5 + + expect(displayedWarnings.length).toBe(5) + expect(remainingCount).toBe(2) + }) +}) diff --git a/VueApp/src/Effort/__tests__/instructor-add-dialog.test.ts b/VueApp/src/Effort/__tests__/instructor-add-dialog.test.ts index 21beda6a..922702be 100644 --- a/VueApp/src/Effort/__tests__/instructor-add-dialog.test.ts +++ b/VueApp/src/Effort/__tests__/instructor-add-dialog.test.ts @@ -70,14 +70,14 @@ describe("InstructorAddDialog - Error Handling", () => { const searchTerm = "J" const minSearchLength = 2 - expect(searchTerm.length >= minSearchLength).toBe(false) + expect(searchTerm.length >= minSearchLength).toBeFalsy() }) it("should allow search with 2 or more characters", () => { const searchTerm = "Jo" const minSearchLength = 2 - expect(searchTerm.length >= minSearchLength).toBe(true) + expect(searchTerm.length >= minSearchLength).toBeTruthy() }) }) @@ -87,7 +87,7 @@ describe("InstructorAddDialog - Error Handling", () => { const canSubmit = !!selectedPerson.value - expect(canSubmit).toBe(false) + expect(canSubmit).toBeFalsy() }) it("should allow submission when person is selected", () => { @@ -95,7 +95,7 @@ describe("InstructorAddDialog - Error Handling", () => { const canSubmit = !!selectedPerson.value - expect(canSubmit).toBe(true) + expect(canSubmit).toBeTruthy() }) }) @@ -135,24 +135,24 @@ describe("InstructorAddDialog - State Management", () => { const isSearching = ref(false) isSearching.value = true - expect(isSearching.value).toBe(true) + expect(isSearching.value).toBeTruthy() // Simulate search completion await Promise.resolve() isSearching.value = false - expect(isSearching.value).toBe(false) + expect(isSearching.value).toBeFalsy() }) it("should track saving state during instructor creation", async () => { const isSaving = ref(false) isSaving.value = true - expect(isSaving.value).toBe(true) + expect(isSaving.value).toBeTruthy() // Simulate save completion await Promise.resolve() isSaving.value = false - expect(isSaving.value).toBe(false) + expect(isSaving.value).toBeFalsy() }) }) diff --git a/VueApp/src/Effort/components/HarvestDialog.vue b/VueApp/src/Effort/components/HarvestDialog.vue new file mode 100644 index 00000000..17cdf0ac --- /dev/null +++ b/VueApp/src/Effort/components/HarvestDialog.vue @@ -0,0 +1,681 @@ + + + diff --git a/VueApp/src/Effort/pages/TermManagement.vue b/VueApp/src/Effort/pages/TermManagement.vue index 904e6050..930b2f54 100644 --- a/VueApp/src/Effort/pages/TermManagement.vue +++ b/VueApp/src/Effort/pages/TermManagement.vue @@ -78,7 +78,26 @@ + + + @@ -275,6 +316,7 @@ import { ref, onMounted } from "vue" import { useQuasar } from "quasar" import { termService } from "../services/term-service" import { useDateFunctions } from "@/composables/DateFunctions" +import HarvestDialog from "../components/HarvestDialog.vue" import type { TermDto, AvailableTermDto } from "../types" import type { QTableColumn } from "quasar" @@ -286,6 +328,11 @@ const availableTerms = ref([]) const selectedNewTerm = ref(null) const isLoading = ref(false) +// Harvest dialog state +const harvestDialogOpen = ref(false) +const harvestTermCode = ref(null) +const harvestTermName = ref("") + const columns: QTableColumn[] = [ { name: "termName", label: "Term", field: "termName", align: "left" }, { name: "harvestedDate", label: "Harvested", field: "harvestedDate", align: "left" }, @@ -386,5 +433,15 @@ function confirmDeleteTerm(term: TermDto) { }) } +function openHarvestDialog(term: TermDto) { + harvestTermCode.value = term.termCode + harvestTermName.value = term.termName + harvestDialogOpen.value = true +} + +function onHarvested() { + loadTerms() +} + onMounted(loadTerms) diff --git a/VueApp/src/Effort/services/harvest-service.ts b/VueApp/src/Effort/services/harvest-service.ts new file mode 100644 index 00000000..1c194952 --- /dev/null +++ b/VueApp/src/Effort/services/harvest-service.ts @@ -0,0 +1,57 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { HarvestPreviewDto, HarvestResultDto } from "../types" + +const { get, post } = useFetch() + +function getHarvestUrl(termCode: number) { + return `${import.meta.env.VITE_API_URL}effort/terms/${termCode}/harvest` +} + +/** + * Progress event from SSE stream. + */ +export type HarvestProgressEvent = { + type: "progress" | "complete" | "error" + phase: string + progress: number + message: string + detail?: string + result?: HarvestResultDto + error?: string +} + +/** + * Service for Harvest API calls. + */ +export const harvestService = { + /** + * Get a preview of harvest data without saving. + */ + async getPreview(termCode: number): Promise { + const response = await get(`${getHarvestUrl(termCode)}/preview`) + if (!response.success || !response.result) { + return null + } + return response.result as HarvestPreviewDto + }, + + /** + * Commit the harvest - clear existing data and import all phases. + * @deprecated Use streamHarvest for real-time progress updates. + */ + async commitHarvest(termCode: number): Promise { + const response = await post(`${getHarvestUrl(termCode)}/commit`, {}) + if (!response.success || !response.result) { + return null + } + return response.result as HarvestResultDto + }, + + /** + * Get the SSE stream URL for harvest with real-time progress. + * Use with EventSource to receive progress updates. + */ + getStreamUrl(termCode: number): string { + return `${getHarvestUrl(termCode)}/stream` + }, +} diff --git a/VueApp/src/Effort/types/admin-types.ts b/VueApp/src/Effort/types/admin-types.ts new file mode 100644 index 00000000..34f8c847 --- /dev/null +++ b/VueApp/src/Effort/types/admin-types.ts @@ -0,0 +1,62 @@ +/** + * Admin/configuration types for the Effort system. + */ + +type UnitDto = { + id: number + name: string + isActive: boolean + usageCount: number + canDelete: boolean +} + +type CreateUnitRequest = { + name: string +} + +type UpdateUnitRequest = { + name: string + isActive: boolean +} + +type EffortTypeDto = { + id: string + description: string + usesWeeks: boolean + isActive: boolean + facultyCanEnter: boolean + allowedOnDvm: boolean + allowedOn199299: boolean + allowedOnRCourses: boolean + usageCount: number + canDelete: boolean +} + +type CreateEffortTypeRequest = { + id: string + description: string + usesWeeks?: boolean + facultyCanEnter?: boolean + allowedOnDvm?: boolean + allowedOn199299?: boolean + allowedOnRCourses?: boolean +} + +type UpdateEffortTypeRequest = { + description: string + usesWeeks: boolean + isActive: boolean + facultyCanEnter: boolean + allowedOnDvm: boolean + allowedOn199299: boolean + allowedOnRCourses: boolean +} + +export type { + UnitDto, + CreateUnitRequest, + UpdateUnitRequest, + EffortTypeDto, + CreateEffortTypeRequest, + UpdateEffortTypeRequest, +} diff --git a/VueApp/src/Effort/types/audit-types.ts b/VueApp/src/Effort/types/audit-types.ts new file mode 100644 index 00000000..51611472 --- /dev/null +++ b/VueApp/src/Effort/types/audit-types.ts @@ -0,0 +1,33 @@ +/** + * Audit types for the Effort system. + */ + +type ChangeDetail = { + oldValue: string | null + newValue: string | null +} + +type EffortAuditRow = { + id: number + tableName: string + recordId: number + action: string + changedDate: string + changedBy: number + changedByName: string + instructorPersonId: number | null + instructorName: string | null + termCode: number | null + termName: string | null + courseCode: string | null + crn: string | null + changes: string | null + changesDetail: Record | null +} + +type ModifierInfo = { + personId: number + fullName: string +} + +export type { ChangeDetail, EffortAuditRow, ModifierInfo } diff --git a/VueApp/src/Effort/types/course-types.ts b/VueApp/src/Effort/types/course-types.ts new file mode 100644 index 00000000..434b32e6 --- /dev/null +++ b/VueApp/src/Effort/types/course-types.ts @@ -0,0 +1,88 @@ +/** + * Course management types for the Effort system. + */ + +type CourseDto = { + id: number + crn: string + termCode: number + subjCode: string + crseNumb: string + seqNumb: string + courseCode: string + enrollment: number + units: number + custDept: string + /** Parent course ID if this course is linked as a child. Null/undefined if not a child. */ + parentCourseId?: number | null +} + +type BannerCourseDto = { + crn: string + subjCode: string + crseNumb: string + seqNumb: string + title: string + enrollment: number + unitType: string // F=Fixed, V=Variable + unitLow: number + unitHigh: number + deptCode: string + courseCode: string + isVariableUnits: boolean + alreadyImported: boolean + importedUnitValues: number[] +} + +type CreateCourseRequest = { + termCode: number + crn: string + subjCode: string + crseNumb: string + seqNumb: string + enrollment: number + units: number + custDept: string +} + +type UpdateCourseRequest = { + enrollment: number + units: number + custDept: string +} + +type ImportCourseRequest = { + termCode: number + crn: string + units?: number // For variable-unit courses +} + +type CourseRelationshipDto = { + id: number + parentCourseId: number + childCourseId: number + relationshipType: "CrossList" | "Section" + childCourse?: CourseDto + parentCourse?: CourseDto +} + +type CourseRelationshipsResult = { + parentRelationship: CourseRelationshipDto | null + childRelationships: CourseRelationshipDto[] +} + +type CreateCourseRelationshipRequest = { + childCourseId: number + relationshipType: "CrossList" | "Section" +} + +export type { + CourseDto, + BannerCourseDto, + CreateCourseRequest, + UpdateCourseRequest, + ImportCourseRequest, + CourseRelationshipDto, + CourseRelationshipsResult, + CreateCourseRelationshipRequest, +} diff --git a/VueApp/src/Effort/types/harvest-types.ts b/VueApp/src/Effort/types/harvest-types.ts new file mode 100644 index 00000000..67584d96 --- /dev/null +++ b/VueApp/src/Effort/types/harvest-types.ts @@ -0,0 +1,100 @@ +/** + * Harvest types for the Effort system. + */ + +type HarvestPersonPreview = { + mothraId: string + personId: number + fullName: string + firstName: string + lastName: string + department: string + titleCode: string + titleDescription: string + source: string + isNew: boolean +} + +type HarvestCoursePreview = { + crn: string + subjCode: string + crseNumb: string + seqNumb: string + enrollment: number + units: number + custDept: string + source: string + isNew: boolean +} + +type HarvestRecordPreview = { + mothraId: string + personName: string + crn: string + courseCode: string + effortType: string + hours: number | null + weeks: number | null + roleId: number + roleName: string + source: string +} + +type HarvestSummary = { + totalInstructors: number + totalCourses: number + totalEffortRecords: number + guestAccounts: number +} + +type HarvestWarning = { + phase: string + message: string + details: string +} + +type HarvestError = { + phase: string + message: string + details: string +} + +type HarvestPreviewDto = { + termCode: number + termName: string + crestInstructors: HarvestPersonPreview[] + crestCourses: HarvestCoursePreview[] + crestEffort: HarvestRecordPreview[] + nonCrestInstructors: HarvestPersonPreview[] + nonCrestCourses: HarvestCoursePreview[] + nonCrestEffort: HarvestRecordPreview[] + clinicalInstructors: HarvestPersonPreview[] + clinicalCourses: HarvestCoursePreview[] + clinicalEffort: HarvestRecordPreview[] + guestAccounts: HarvestPersonPreview[] + removedInstructors: HarvestPersonPreview[] + removedCourses: HarvestCoursePreview[] + summary: HarvestSummary + warnings: HarvestWarning[] + errors: HarvestError[] +} + +type HarvestResultDto = { + success: boolean + termCode: number + harvestedDate: string | null + summary: HarvestSummary + warnings: HarvestWarning[] + errorMessage: string | null +} + +export type { + HarvestPersonPreview, + HarvestCoursePreview, + HarvestRecordPreview, + HarvestSummary, + HarvestWarning, + HarvestError, + HarvestPreviewDto, + HarvestResultDto, +} diff --git a/VueApp/src/Effort/types/index.ts b/VueApp/src/Effort/types/index.ts index c44d3ae7..1d1e1418 100644 --- a/VueApp/src/Effort/types/index.ts +++ b/VueApp/src/Effort/types/index.ts @@ -1,317 +1,12 @@ /** * TypeScript types for the Effort system. + * Re-exports all types from domain-specific modules. */ -type TermDto = { - termCode: number - termName: string - status: string - harvestedDate: string | null - openedDate: string | null - closedDate: string | null - isOpen: boolean - canEdit: boolean - // State transition properties for term management UI - canOpen: boolean - canClose: boolean - canReopen: boolean - canUnopen: boolean - canDelete: boolean -} - -type PersonDto = { - personId: number - termCode: number - firstName: string - lastName: string - middleInitial: string | null - fullName: string - effortTitleCode: string - effortDept: string - percentAdmin: number - jobGroupId: string | null - title: string | null - adminUnit: string | null - effortVerified: string | null - reportUnit: string | null - volunteerWos: boolean - percentClinical: number | null - isVerified: boolean -} - -type CourseDto = { - id: number - crn: string - termCode: number - subjCode: string - crseNumb: string - seqNumb: string - courseCode: string - enrollment: number - units: number - custDept: string - /** Parent course ID if this course is linked as a child. Null/undefined if not a child. */ - parentCourseId?: number | null -} - -type AvailableTermDto = { - termCode: number - termName: string - startDate: string -} - -// Audit types -type ChangeDetail = { - oldValue: string | null - newValue: string | null -} - -type EffortAuditRow = { - id: number - tableName: string - recordId: number - action: string - changedDate: string - changedBy: number - changedByName: string - instructorPersonId: number | null - instructorName: string | null - termCode: number | null - termName: string | null - courseCode: string | null - crn: string | null - changes: string | null - changesDetail: Record | null -} - -type ModifierInfo = { - personId: number - fullName: string -} - -type TermOptionDto = { - termCode: number - termName: string -} - -// Course management types -type BannerCourseDto = { - crn: string - subjCode: string - crseNumb: string - seqNumb: string - title: string - enrollment: number - unitType: string // F=Fixed, V=Variable - unitLow: number - unitHigh: number - deptCode: string - courseCode: string - isVariableUnits: boolean - alreadyImported: boolean - importedUnitValues: number[] -} - -type CreateCourseRequest = { - termCode: number - crn: string - subjCode: string - crseNumb: string - seqNumb: string - enrollment: number - units: number - custDept: string -} - -type UpdateCourseRequest = { - enrollment: number - units: number - custDept: string -} - -type ImportCourseRequest = { - termCode: number - crn: string - units?: number // For variable-unit courses -} - -// Course relationship types -type CourseRelationshipDto = { - id: number - parentCourseId: number - childCourseId: number - relationshipType: "CrossList" | "Section" - childCourse?: CourseDto - parentCourse?: CourseDto -} - -type CourseRelationshipsResult = { - parentRelationship: CourseRelationshipDto | null - childRelationships: CourseRelationshipDto[] -} - -type CreateCourseRelationshipRequest = { - childCourseId: number - relationshipType: "CrossList" | "Section" -} - -// Instructor management types -type AaudPersonDto = { - personId: number - firstName: string - lastName: string - middleInitial: string | null - fullName: string - effortDept: string | null - deptName: string | null - titleCode: string | null - title: string | null - jobGroupId: string | null -} - -type CreateInstructorRequest = { - personId: number - termCode: number -} - -type UpdateInstructorRequest = { - effortDept: string - effortTitleCode: string - jobGroupId: string | null - reportUnits: string[] | null - volunteerWos: boolean -} - -type ReportUnitDto = { - abbrev: string - unit: string -} - -type DepartmentDto = { - code: string - name: string - group: string -} - -type CanDeleteResult = { - canDelete: boolean - recordCount: number -} - -// Percent Assignment Type types (read-only) -type PercentAssignTypeDto = { - id: number - class: string - name: string - showOnTemplate: boolean - isActive: boolean - instructorCount: number -} - -// Instructor by percent assignment type types -type InstructorByPercentAssignTypeDto = { - personId: number - firstName: string - lastName: string - fullName: string - academicYear: string -} - -type InstructorsByPercentAssignTypeResponseDto = { - typeId: number - typeName: string - typeClass: string - instructors: InstructorByPercentAssignTypeDto[] -} - -type InstructorEffortRecordDto = { - id: number - courseId: number - personId: number - termCode: number - effortType: string - role: number - roleDescription: string - hours: number | null - weeks: number | null - crn: string - modifiedDate: string | null - effortValue: number | null - effortLabel: string - course: CourseDto -} - -type TitleCodeDto = { - code: string - name: string -} - -type JobGroupDto = { - code: string - name: string -} - -// Unit management types -type UnitDto = { - id: number - name: string - isActive: boolean - usageCount: number - canDelete: boolean -} - -type CreateUnitRequest = { - name: string -} - -type UpdateUnitRequest = { - name: string - isActive: boolean -} - -// Effort Type management types -type EffortTypeDto = { - id: string - description: string - usesWeeks: boolean - isActive: boolean - facultyCanEnter: boolean - allowedOnDvm: boolean - allowedOn199299: boolean - allowedOnRCourses: boolean - usageCount: number - canDelete: boolean -} - -type CreateEffortTypeRequest = { - id: string - description: string - usesWeeks?: boolean - facultyCanEnter?: boolean - allowedOnDvm?: boolean - allowedOn199299?: boolean - allowedOnRCourses?: boolean -} - -type UpdateEffortTypeRequest = { - description: string - usesWeeks: boolean - isActive: boolean - facultyCanEnter: boolean - allowedOnDvm: boolean - allowedOn199299: boolean - allowedOnRCourses: boolean -} +export type { TermDto, PersonDto, AvailableTermDto, TermOptionDto } from "./term-types" export type { - TermDto, - PersonDto, CourseDto, - AvailableTermDto, - EffortAuditRow, - ChangeDetail, - ModifierInfo, - TermOptionDto, BannerCourseDto, CreateCourseRequest, UpdateCourseRequest, @@ -319,6 +14,9 @@ export type { CourseRelationshipDto, CourseRelationshipsResult, CreateCourseRelationshipRequest, +} from "./course-types" + +export type { AaudPersonDto, CreateInstructorRequest, UpdateInstructorRequest, @@ -331,10 +29,26 @@ export type { PercentAssignTypeDto, InstructorByPercentAssignTypeDto, InstructorsByPercentAssignTypeResponseDto, +} from "./instructor-types" + +export type { UnitDto, CreateUnitRequest, UpdateUnitRequest, EffortTypeDto, CreateEffortTypeRequest, UpdateEffortTypeRequest, -} +} from "./admin-types" + +export type { + HarvestPersonPreview, + HarvestCoursePreview, + HarvestRecordPreview, + HarvestSummary, + HarvestWarning, + HarvestError, + HarvestPreviewDto, + HarvestResultDto, +} from "./harvest-types" + +export type { ChangeDetail, EffortAuditRow, ModifierInfo } from "./audit-types" diff --git a/VueApp/src/Effort/types/instructor-types.ts b/VueApp/src/Effort/types/instructor-types.ts new file mode 100644 index 00000000..d5289e5c --- /dev/null +++ b/VueApp/src/Effort/types/instructor-types.ts @@ -0,0 +1,113 @@ +/** + * Instructor management types for the Effort system. + */ + +import type { CourseDto } from "./course-types" + +type AaudPersonDto = { + personId: number + firstName: string + lastName: string + middleInitial: string | null + fullName: string + effortDept: string | null + deptName: string | null + titleCode: string | null + title: string | null + jobGroupId: string | null +} + +type CreateInstructorRequest = { + personId: number + termCode: number +} + +type UpdateInstructorRequest = { + effortDept: string + effortTitleCode: string + jobGroupId: string | null + reportUnits: string[] | null + volunteerWos: boolean +} + +type ReportUnitDto = { + abbrev: string + unit: string +} + +type DepartmentDto = { + code: string + name: string + group: string +} + +type CanDeleteResult = { + canDelete: boolean + recordCount: number +} + +type InstructorEffortRecordDto = { + id: number + courseId: number + personId: number + termCode: number + effortType: string + role: number + roleDescription: string + hours: number | null + weeks: number | null + crn: string + modifiedDate: string | null + effortValue: number | null + effortLabel: string + course: CourseDto +} + +type TitleCodeDto = { + code: string + name: string +} + +type JobGroupDto = { + code: string + name: string +} + +type PercentAssignTypeDto = { + id: number + class: string + name: string + showOnTemplate: boolean + isActive: boolean + instructorCount: number +} + +type InstructorByPercentAssignTypeDto = { + personId: number + firstName: string + lastName: string + fullName: string + academicYear: string +} + +type InstructorsByPercentAssignTypeResponseDto = { + typeId: number + typeName: string + typeClass: string + instructors: InstructorByPercentAssignTypeDto[] +} + +export type { + AaudPersonDto, + CreateInstructorRequest, + UpdateInstructorRequest, + ReportUnitDto, + DepartmentDto, + CanDeleteResult, + InstructorEffortRecordDto, + TitleCodeDto, + JobGroupDto, + PercentAssignTypeDto, + InstructorByPercentAssignTypeDto, + InstructorsByPercentAssignTypeResponseDto, +} diff --git a/VueApp/src/Effort/types/term-types.ts b/VueApp/src/Effort/types/term-types.ts new file mode 100644 index 00000000..fafcafcc --- /dev/null +++ b/VueApp/src/Effort/types/term-types.ts @@ -0,0 +1,54 @@ +/** + * Term and person types for the Effort system. + */ + +type TermDto = { + termCode: number + termName: string + status: string + harvestedDate: string | null + openedDate: string | null + closedDate: string | null + isOpen: boolean + canEdit: boolean + // State transition properties for term management UI + canOpen: boolean + canClose: boolean + canReopen: boolean + canUnopen: boolean + canDelete: boolean + canHarvest: boolean +} + +type PersonDto = { + personId: number + termCode: number + firstName: string + lastName: string + middleInitial: string | null + fullName: string + effortTitleCode: string + effortDept: string + percentAdmin: number + jobGroupId: string | null + title: string | null + adminUnit: string | null + effortVerified: string | null + reportUnit: string | null + volunteerWos: boolean + percentClinical: number | null + isVerified: boolean +} + +type AvailableTermDto = { + termCode: number + termName: string + startDate: string +} + +type TermOptionDto = { + termCode: number + termName: string +} + +export type { TermDto, PersonDto, AvailableTermDto, TermOptionDto } diff --git a/scripts/lib/lint-staged-common.js b/scripts/lib/lint-staged-common.js index b8bbc833..d879c1f1 100644 --- a/scripts/lib/lint-staged-common.js +++ b/scripts/lib/lint-staged-common.js @@ -3,6 +3,8 @@ const path = require("node:path") const fs = require("node:fs") const { createLogger } = require("./script-utils") +const { env } = process + // Platform-specific constants const IS_WINDOWS = process.platform === "win32" @@ -82,7 +84,7 @@ function createSummaryReporter(toolName, logger) { logger.plain("🔒 Critical issues MUST be fixed before committing.") } if (blockOnWarnings && hasWarnings && !hasBlockingIssues) { - logger.plain("\n⚠️ LINTING STOPPED due to warnings.") + logger.plain("\n⚠️ Warnings detected.") logger.plain( "💡 These warnings would not block commits in normal mode. Fix warnings above or use lint:precommit to ignore warnings.", ) @@ -372,7 +374,7 @@ function sanitizeFilePath(filePath, baseDir, allowedExtensions, maxFileSizeMB = * @returns {boolean} - True if warnings should block commits */ function shouldBlockOnWarnings() { - return process.env.LINT_BLOCK_ON_WARNINGS === "true" + return env.LINT_BLOCK_ON_WARNINGS === "true" } /** diff --git a/scripts/lint-staged-dotnet.js b/scripts/lint-staged-dotnet.js index c0c7a5f5..5ef91769 100644 --- a/scripts/lint-staged-dotnet.js +++ b/scripts/lint-staged-dotnet.js @@ -129,24 +129,38 @@ const effortScriptsFiles = rawFiles.filter((f) => EFFORT_SCRIPTS_PATH_REGEX.test const webFiles = rawFiles.filter((f) => WEB_PATH_REGEX.test(f) && !EFFORT_SCRIPTS_PATH_REGEX.test(f)) const testFiles = rawFiles.filter((f) => TEST_PATH_REGEX.test(f)) +// Supported project configurations +// Uses isolated output directories to avoid conflicts with dev server (bin/Debug) +const PROJECT_CONFIG = { + web: { projectName: "Viper.csproj", buildPath: "web/", outputDir: "web/bin/Lint" }, + test: { projectName: "Viper.test.csproj", buildPath: "test/", outputDir: "test/bin/Lint" }, + "web/Areas/Effort/Scripts": { + projectName: "EffortMigration.csproj", + buildPath: "web/Areas/Effort/Scripts/", + outputDir: "web/Areas/Effort/Scripts/bin/Lint", + }, +} + // Function to run dotnet build for SonarAnalyzer on a specific project const runBuild = (projectPath) => { // Fail fast - validate before computing derived values - if (projectPath !== "web" && projectPath !== "test") { - throw new Error(`Unknown projectPath "${projectPath}" passed to runBuild. Expected "web" or "test".`) + const config = PROJECT_CONFIG[projectPath] + if (!config) { + throw new Error( + `Unknown projectPath "${projectPath}" passed to runBuild. Expected one of: ${Object.keys(PROJECT_CONFIG).join(", ")}`, + ) } - // Use actual project file names to match build-dotnet.js cache keys - const projectName = projectPath === "web" ? "Viper.csproj" : "Viper.test.csproj" + const { projectName, buildPath, outputDir } = config // Check if build is needed (unless forced) if (forceFlag) { - logger.info(`Force flag enabled, skipping cache for ${projectPath}/ project`) + logger.info(`Force flag enabled, skipping cache for ${buildPath} project`) } else { try { if (!needsBuild(projectPath, projectName)) { // Get cached analyzer output instead of skipping analysis const cachedOutput = getCachedBuildOutput(projectName) - logger.success(`Build not needed for ${projectPath}/ project (using cached results)`) + logger.success(`Build not needed for ${buildPath} project (using cached results)`) // Check for analyzer warnings in cached output const hasWarnings = @@ -159,10 +173,10 @@ const runBuild = (projectPath) => { } } - const args = ["build", `${projectPath}/`, "--no-incremental", "--verbosity", "quiet"] + const args = ["build", buildPath, "-o", outputDir, "--no-incremental", "--verbosity", "quiet"] try { - logger.info(`Building ${projectPath}/ project for code analysis...`) + logger.info(`Building ${buildPath} project for code analysis...`) const result = execFileSync("dotnet", args, { encoding: "utf8", timeout: 60_000, // Reduce timeout to 1 minute @@ -176,7 +190,7 @@ const runBuild = (projectPath) => { logger.warning(`Failed to cache build result: ${error.message}`) } - logger.success(`Build completed for ${projectPath}/ project`) + logger.success(`Build completed for ${buildPath} project`) // Check for analyzer warnings in build output const hasWarnings = result && (result.includes("warning ") || result.includes(": warning")) @@ -196,11 +210,11 @@ const runBuild = (projectPath) => { const hasWarnings = output.includes("warning ") || output.includes(": warning") const hasErrors = output.includes("error ") && !output.includes("Build FAILED") - logger.info(`Build completed with issues for ${projectPath}/ project`) + logger.info(`Build completed with issues for ${buildPath} project`) return { hasErrors: hasErrors, hasWarnings: hasWarnings, output: output } } - logger.error(`DOTNET BUILD ERROR for ${projectPath}/: ${error.message}`) + logger.error(`DOTNET BUILD ERROR for ${buildPath}: ${error.message}`) return { hasErrors: true, hasWarnings: false, output: "" } } } @@ -402,7 +416,7 @@ try { let allBuildOutput = "" if (effortScriptsFiles.length > 0) { - const buildResult = runBuild("web/Areas/Effort/Scripts", "EffortMigration.csproj") + const buildResult = runBuild("web/Areas/Effort/Scripts") allBuildOutput += buildResult.output || "" const formatResult = runFormat("web/Areas/Effort/Scripts", effortScriptsFiles) @@ -491,6 +505,8 @@ try { } catch (error) { if (error.code === "ETIMEDOUT") { logger.error("dotnet format timed out after 3 minutes") + } else { + logger.error(`Unexpected error: ${error.message}`) } process.exit(1) } diff --git a/test/Effort/EffortTypeServiceTests.cs b/test/Effort/EffortTypeServiceTests.cs index 7c976e1f..f564ab09 100644 --- a/test/Effort/EffortTypeServiceTests.cs +++ b/test/Effort/EffortTypeServiceTests.cs @@ -448,7 +448,16 @@ public async Task UpdateEffortTypeAsync_UpdatesEffortType_WhenExists() _context.EffortTypes.Add(new EffortType { Id = "UPD", Description = "Original", IsActive = true }); await _context.SaveChangesAsync(); - var request = new UpdateEffortTypeRequest { Description = "Updated", IsActive = false, UsesWeeks = true }; + var request = new UpdateEffortTypeRequest + { + Description = "Updated", + IsActive = false, + UsesWeeks = true, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; // Act var result = await _effortTypeService.UpdateEffortTypeAsync("UPD", request); @@ -464,7 +473,16 @@ public async Task UpdateEffortTypeAsync_UpdatesEffortType_WhenExists() public async Task UpdateEffortTypeAsync_ReturnsNull_WhenNotFound() { // Arrange - var request = new UpdateEffortTypeRequest { Description = "Updated", IsActive = true }; + var request = new UpdateEffortTypeRequest + { + Description = "Updated", + IsActive = true, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; // Act var result = await _effortTypeService.UpdateEffortTypeAsync("XXX", request); @@ -480,7 +498,16 @@ public async Task UpdateEffortTypeAsync_NormalizesIdToUppercase() _context.EffortTypes.Add(new EffortType { Id = "UPP", Description = "Original", IsActive = true }); await _context.SaveChangesAsync(); - var request = new UpdateEffortTypeRequest { Description = "Updated", IsActive = true }; + var request = new UpdateEffortTypeRequest + { + Description = "Updated", + IsActive = true, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; // Act var result = await _effortTypeService.UpdateEffortTypeAsync("upp", request); @@ -497,7 +524,16 @@ public async Task UpdateEffortTypeAsync_CallsAuditService() _context.EffortTypes.Add(new EffortType { Id = "AUD", Description = "Original", IsActive = true }); await _context.SaveChangesAsync(); - var request = new UpdateEffortTypeRequest { Description = "Updated", IsActive = false }; + var request = new UpdateEffortTypeRequest + { + Description = "Updated", + IsActive = false, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; // Act await _effortTypeService.UpdateEffortTypeAsync("AUD", request); @@ -519,7 +555,16 @@ public async Task UpdateEffortTypeAsync_TrimsWhitespace_FromDescription() _context.EffortTypes.Add(new EffortType { Id = "TRM", Description = "Original", IsActive = true }); await _context.SaveChangesAsync(); - var request = new UpdateEffortTypeRequest { Description = " Updated Name ", IsActive = true }; + var request = new UpdateEffortTypeRequest + { + Description = " Updated Name ", + IsActive = true, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; // Act var result = await _effortTypeService.UpdateEffortTypeAsync("TRM", request); diff --git a/test/Effort/EffortTypesControllerIntegrationTests.cs b/test/Effort/EffortTypesControllerIntegrationTests.cs index 40881d12..ffccb51a 100644 --- a/test/Effort/EffortTypesControllerIntegrationTests.cs +++ b/test/Effort/EffortTypesControllerIntegrationTests.cs @@ -106,7 +106,7 @@ public async Task GetEffortTypes_IncludesUsageCount() // Add an effort record referencing LEC EffortContext.Records.Add(CreateTestEffortRecord(1, "LEC")); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act var result = await _controller.GetEffortTypes(); @@ -210,7 +210,7 @@ public async Task CreateEffortType_CreatesAndReturnsEffortType() Assert.True(effortType.CanDelete); // Verify persisted in database - var fromDb = EffortContext.EffortTypes.Find("TST"); + var fromDb = await EffortContext.EffortTypes.FindAsync("TST"); Assert.NotNull(fromDb); Assert.Equal("Test Session", fromDb.Description); } @@ -330,7 +330,7 @@ public async Task UpdateEffortType_UpdatesAndReturnsEffortType() Assert.False(effortType.AllowedOnRCourses); // Verify persisted in database - var fromDb = EffortContext.EffortTypes.Find("LEC"); + var fromDb = await EffortContext.EffortTypes.FindAsync("LEC"); Assert.NotNull(fromDb); Assert.Equal("Updated Lecture", fromDb.Description); Assert.True(fromDb.UsesWeeks); @@ -343,7 +343,13 @@ public async Task UpdateEffortType_ReturnsNotFound_WhenNotExists() SetupUserWithManageEffortTypesPermission(); var request = new UpdateEffortTypeRequest { - Description = "Test" + Description = "Test", + UsesWeeks = false, + IsActive = true, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false }; // Act @@ -360,7 +366,13 @@ public async Task UpdateEffortType_IsCaseInsensitive() SetupUserWithManageEffortTypesPermission(); var request = new UpdateEffortTypeRequest { - Description = "Updated" + Description = "Updated", + UsesWeeks = false, + IsActive = true, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false }; // Act @@ -390,7 +402,7 @@ public async Task DeleteEffortType_DeletesEffortType_WhenNoUsage() UsesWeeks = false, IsActive = true }); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act var result = await _controller.DeleteEffortType("DEL", CancellationToken.None); @@ -399,7 +411,7 @@ public async Task DeleteEffortType_DeletesEffortType_WhenNoUsage() Assert.IsType(result); // Verify removed from database - var fromDb = EffortContext.EffortTypes.Find("DEL"); + var fromDb = await EffortContext.EffortTypes.FindAsync("DEL"); Assert.Null(fromDb); } @@ -424,7 +436,7 @@ public async Task DeleteEffortType_ReturnsConflict_WhenHasUsage() // Add an effort record referencing LEC EffortContext.Records.Add(CreateTestEffortRecord(100, "LEC")); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act var result = await _controller.DeleteEffortType("LEC", CancellationToken.None); @@ -447,7 +459,7 @@ public async Task DeleteEffortType_IsCaseInsensitive() UsesWeeks = false, IsActive = true }); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act var result = await _controller.DeleteEffortType("del", CancellationToken.None); @@ -485,7 +497,7 @@ public async Task CanDeleteEffortType_ReturnsFalse_WhenHasUsage() // Add effort records EffortContext.Records.Add(CreateTestEffortRecord(200, "LEC")); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act var result = await _controller.CanDeleteEffortType("LEC", CancellationToken.None); @@ -549,7 +561,7 @@ public async Task GetEffortType_HandlesIdWithSlash() UsesWeeks = false, IsActive = true }); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act - The controller now uses query parameter [FromQuery] to handle "/" in IDs var result = await _controller.GetEffortType("D/L", CancellationToken.None); @@ -574,11 +586,17 @@ public async Task UpdateEffortType_HandlesIdWithSlash() UsesWeeks = false, IsActive = true }); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); var request = new UpdateEffortTypeRequest { - Description = "Lab/Discussion Updated" + Description = "Lab/Discussion Updated", + UsesWeeks = false, + IsActive = true, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false }; // Act @@ -604,7 +622,7 @@ public async Task DeleteEffortType_HandlesIdWithSlash() UsesWeeks = false, IsActive = true }); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act var result = await _controller.DeleteEffortType("T/D", CancellationToken.None); @@ -613,7 +631,7 @@ public async Task DeleteEffortType_HandlesIdWithSlash() Assert.IsType(result); // Verify deleted - var fromDb = EffortContext.EffortTypes.Find("T/D"); + var fromDb = await EffortContext.EffortTypes.FindAsync("T/D"); Assert.Null(fromDb); } @@ -630,7 +648,7 @@ public async Task CanDeleteEffortType_HandlesIdWithSlash() UsesWeeks = false, IsActive = true }); - EffortContext.SaveChanges(); + await EffortContext.SaveChangesAsync(); // Act var result = await _controller.CanDeleteEffortType("T-D", CancellationToken.None); @@ -676,7 +694,10 @@ public async Task FullCrudWorkflow_SuccessfullyPerformsAllOperations() Description = "Updated Workshop", UsesWeeks = true, IsActive = true, - FacultyCanEnter = false + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false }; var updateResult = await _controller.UpdateEffortType("WRK", updateRequest, CancellationToken.None); var updateOkResult = Assert.IsType(updateResult.Result); diff --git a/test/Effort/EffortTypesControllerTests.cs b/test/Effort/EffortTypesControllerTests.cs index b27c244e..bfa46360 100644 --- a/test/Effort/EffortTypesControllerTests.cs +++ b/test/Effort/EffortTypesControllerTests.cs @@ -186,7 +186,16 @@ public async Task CreateEffortType_ReturnsConflict_OnDbUpdateException() public async Task UpdateEffortType_ReturnsOk_OnSuccess() { // Arrange - var request = new UpdateEffortTypeRequest { Description = "Updated Type", IsActive = false }; + var request = new UpdateEffortTypeRequest + { + Description = "Updated Type", + IsActive = false, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; var updatedEffortType = new EffortTypeDto { Id = "UPD", Description = "Updated Type", IsActive = false, UsageCount = 0, CanDelete = true }; _effortTypeServiceMock.Setup(s => s.UpdateEffortTypeAsync("UPD", request, It.IsAny())) .ReturnsAsync(updatedEffortType); @@ -205,7 +214,16 @@ public async Task UpdateEffortType_ReturnsOk_OnSuccess() public async Task UpdateEffortType_ReturnsNotFound_WhenMissing() { // Arrange - var request = new UpdateEffortTypeRequest { Description = "Updated Type", IsActive = true }; + var request = new UpdateEffortTypeRequest + { + Description = "Updated Type", + IsActive = true, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; _effortTypeServiceMock.Setup(s => s.UpdateEffortTypeAsync("XXX", request, It.IsAny())) .ReturnsAsync((EffortTypeDto?)null); @@ -220,7 +238,16 @@ public async Task UpdateEffortType_ReturnsNotFound_WhenMissing() public async Task UpdateEffortType_ReturnsConflict_OnInvalidOperationException() { // Arrange - var request = new UpdateEffortTypeRequest { Description = "Invalid Update", IsActive = true }; + var request = new UpdateEffortTypeRequest + { + Description = "Invalid Update", + IsActive = true, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; _effortTypeServiceMock.Setup(s => s.UpdateEffortTypeAsync("INV", request, It.IsAny())) .ThrowsAsync(new InvalidOperationException("Invalid update operation")); @@ -236,7 +263,16 @@ public async Task UpdateEffortType_ReturnsConflict_OnInvalidOperationException() public async Task UpdateEffortType_ReturnsConflict_OnDbUpdateException() { // Arrange - var request = new UpdateEffortTypeRequest { Description = "Error Update", IsActive = true }; + var request = new UpdateEffortTypeRequest + { + Description = "Error Update", + IsActive = true, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; _effortTypeServiceMock.Setup(s => s.UpdateEffortTypeAsync("ERR", request, It.IsAny())) .ThrowsAsync(new DbUpdateException("Constraint violation")); @@ -413,7 +449,16 @@ public async Task GetEffortType_ReturnsBadRequest_WhenIdIsEmptyOrWhitespace(stri public async Task UpdateEffortType_ReturnsBadRequest_WhenIdIsEmptyOrWhitespace(string id) { // Arrange - var request = new UpdateEffortTypeRequest { Description = "Test", IsActive = true }; + var request = new UpdateEffortTypeRequest + { + Description = "Test", + IsActive = true, + UsesWeeks = false, + FacultyCanEnter = false, + AllowedOnDvm = false, + AllowedOn199299 = false, + AllowedOnRCourses = false + }; // Act var result = await _controller.UpdateEffortType(id, request, CancellationToken.None); diff --git a/test/Effort/HarvestServiceTests.cs b/test/Effort/HarvestServiceTests.cs new file mode 100644 index 00000000..4b237c4b --- /dev/null +++ b/test/Effort/HarvestServiceTests.cs @@ -0,0 +1,659 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Moq; +using Viper.Areas.Effort; +using Viper.Areas.Effort.Models.Entities; +using Viper.Areas.Effort.Services; +using Viper.Areas.Effort.Services.Harvest; +using Viper.Classes.SQLContext; + +namespace Viper.test.Effort; + +/// +/// Unit tests for HarvestService harvest operations. +/// Tests focus on preview generation, harvest execution, and error handling. +/// Note: Clinical data tests are excluded as VIPERContext.Weeks/InstructorSchedules +/// require cross-database access not available in unit tests. +/// +public sealed class HarvestServiceTests : IDisposable +{ + private readonly EffortDbContext _context; + private readonly VIPERContext _viperContext; + private readonly CoursesContext _coursesContext; + private readonly CrestContext _crestContext; + private readonly AAUDContext _aaudContext; + private readonly DictionaryContext _dictionaryContext; + private readonly Mock _auditServiceMock; + private readonly Mock _termServiceMock; + private readonly Mock _instructorServiceMock; + private readonly Mock> _loggerMock; + private readonly HarvestService _harvestService; + + private const int TestTermCode = 202410; + + public HarvestServiceTests() + { + var effortOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + var viperOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + var coursesOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + var crestOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + var aaudOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + var dictionaryOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + _context = new EffortDbContext(effortOptions); + _viperContext = new VIPERContext(viperOptions); + _coursesContext = new CoursesContext(coursesOptions); + _crestContext = new CrestContext(crestOptions); + _aaudContext = new AAUDContext(aaudOptions); + _dictionaryContext = new DictionaryContext(dictionaryOptions); + + _auditServiceMock = new Mock(); + _termServiceMock = new Mock(); + _instructorServiceMock = new Mock(); + _loggerMock = new Mock>(); + + // Setup default term service behavior + _termServiceMock + .Setup(s => s.GetTermName(It.IsAny())) + .Returns((int tc) => $"Term {tc}"); + + // Setup default instructor service behavior + _instructorServiceMock + .Setup(s => s.ResolveInstructorDepartmentAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("VET"); + + _instructorServiceMock + .Setup(s => s.GetTitleCodesAsync(It.IsAny())) + .ReturnsAsync(new List()); + + _instructorServiceMock + .Setup(s => s.GetDepartmentSimpleNameLookupAsync(It.IsAny())) + .ReturnsAsync(new Dictionary()); + + // Setup audit service mock for harvest operations + _auditServiceMock + .Setup(s => s.ClearAuditForTermAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Create harvest phases + var phases = new List + { + new CrestHarvestPhase(), + new NonCrestHarvestPhase(), + new ClinicalHarvestPhase(), + new GuestAccountPhase() + }; + + _harvestService = new HarvestService( + phases, + _context, + _viperContext, + _coursesContext, + _crestContext, + _aaudContext, + _dictionaryContext, + _auditServiceMock.Object, + _termServiceMock.Object, + _instructorServiceMock.Object, + _loggerMock.Object); + } + + public void Dispose() + { + _context.Dispose(); + _viperContext.Dispose(); + _coursesContext.Dispose(); + _crestContext.Dispose(); + _aaudContext.Dispose(); + _dictionaryContext.Dispose(); + } + + #region GeneratePreviewAsync Tests + + [Fact] + public async Task GeneratePreviewAsync_WithNoData_ReturnsEmptyPreview() + { + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.NotNull(result); + Assert.Equal(TestTermCode, result.TermCode); + Assert.Empty(result.CrestInstructors); + Assert.Empty(result.CrestCourses); + Assert.Empty(result.CrestEffort); + Assert.Empty(result.NonCrestInstructors); + Assert.Empty(result.NonCrestCourses); + Assert.Empty(result.NonCrestEffort); + Assert.NotNull(result.Summary); + Assert.Equal(0, result.Summary.TotalInstructors); + Assert.Equal(0, result.Summary.TotalCourses); + Assert.Equal(0, result.Summary.TotalEffortRecords); + } + + [Fact] + public async Task GeneratePreviewAsync_WithExistingData_AddsWarning() + { + // Arrange - Add existing data that will be replaced + _context.Terms.Add(new EffortTerm + { + TermCode = TestTermCode, + Status = "Harvested" + }); + _context.Persons.Add(new EffortPerson + { + PersonId = 1, + TermCode = TestTermCode, + FirstName = "EXISTING", + LastName = "PERSON" + }); + await _context.SaveChangesAsync(); + + // Also add the person to VIPERContext so MothraId lookup works + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1, + ClientId = "EXIST001", + MothraId = "EXIST001", + FirstName = "EXISTING", + LastName = "PERSON", + FullName = "PERSON, EXISTING" + }); + await _viperContext.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Warnings); + Assert.Contains("Existing data will be replaced", result.Warnings[0].Message); + Assert.Contains("1 instructors", result.Warnings[0].Details); + } + + [Fact] + public async Task GeneratePreviewAsync_CallsTermServiceForTermName() + { + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Equal($"Term {TestTermCode}", result.TermName); + _termServiceMock.Verify(s => s.GetTermName(TestTermCode), Times.Once); + } + + #endregion + + #region ExecuteHarvestAsync Tests + + [Fact] + public async Task ExecuteHarvestAsync_WithNoData_ReturnsSuccessWithEmptySummary() + { + // Arrange - Create term + _context.Terms.Add(new EffortTerm + { + TermCode = TestTermCode, + Status = "Created" + }); + await _context.SaveChangesAsync(); + + // Act + var result = await _harvestService.ExecuteHarvestAsync(TestTermCode, modifiedBy: 123); + + // Assert + Assert.True(result.Success, $"Harvest failed with error: {result.ErrorMessage}"); + Assert.Null(result.ErrorMessage); + Assert.NotNull(result.Summary); + Assert.Equal(0, result.Summary.TotalInstructors); + Assert.Equal(0, result.Summary.TotalCourses); + Assert.Equal(0, result.Summary.TotalEffortRecords); + } + + [Fact] + public async Task ExecuteHarvestAsync_UpdatesTermStatus_ToHarvested() + { + // Arrange + _context.Terms.Add(new EffortTerm + { + TermCode = TestTermCode, + Status = "Created", + HarvestedDate = null + }); + await _context.SaveChangesAsync(); + + // Act + var result = await _harvestService.ExecuteHarvestAsync(TestTermCode, modifiedBy: 123); + + // Assert + Assert.True(result.Success); + + var term = await _context.Terms.FirstOrDefaultAsync(t => t.TermCode == TestTermCode); + Assert.NotNull(term); + Assert.Equal("Harvested", term.Status); + Assert.NotNull(term.HarvestedDate); + } + + [Fact] + public async Task ExecuteHarvestAsync_ClearsExistingData_BeforeImporting() + { + // Arrange - Add existing data + _context.Terms.Add(new EffortTerm { TermCode = TestTermCode, Status = "Harvested" }); + _context.Persons.Add(new EffortPerson + { + PersonId = 999, + TermCode = TestTermCode, + FirstName = "OLD", + LastName = "PERSON" + }); + _context.Courses.Add(new EffortCourse + { + TermCode = TestTermCode, + Crn = "99999", + SubjCode = "OLD", + CrseNumb = "100", + SeqNumb = "001" + }); + await _context.SaveChangesAsync(); + + // Act + var result = await _harvestService.ExecuteHarvestAsync(TestTermCode, modifiedBy: 123); + + // Assert + Assert.True(result.Success); + + // Verify old data was cleared + var remainingPersons = await _context.Persons.Where(p => p.TermCode == TestTermCode).ToListAsync(); + var remainingCourses = await _context.Courses.Where(c => c.TermCode == TestTermCode).ToListAsync(); + + // With no new data to import, should be empty + Assert.Empty(remainingPersons); + Assert.Empty(remainingCourses); + } + + [Fact] + public async Task ExecuteHarvestAsync_CreatesAuditTrail() + { + // Arrange + _context.Terms.Add(new EffortTerm { TermCode = TestTermCode, Status = "Created" }); + await _context.SaveChangesAsync(); + + // Act + var result = await _harvestService.ExecuteHarvestAsync(TestTermCode, modifiedBy: 123); + + // Assert + Assert.True(result.Success); + _auditServiceMock.Verify( + s => s.AddTermChangeAudit(TestTermCode, It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + _auditServiceMock.Verify( + s => s.AddImportAudit(TestTermCode, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ExecuteHarvestAsync_SetsHarvestedDateInResult() + { + // Arrange + _context.Terms.Add(new EffortTerm { TermCode = TestTermCode, Status = "Created" }); + await _context.SaveChangesAsync(); + + var beforeHarvest = DateTime.Now; + + // Act + var result = await _harvestService.ExecuteHarvestAsync(TestTermCode, modifiedBy: 123); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.HarvestedDate); + Assert.True(result.HarvestedDate >= beforeHarvest); + } + + #endregion + + #region Guest Account Tests + + [Fact] + public async Task GeneratePreviewAsync_IncludesGuestAccounts_WhenFoundInViperPeople() + { + // Arrange - Add guest account to VIPER People + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1001, + ClientId = "APCGUEST", + MothraId = "APCGUEST", + FirstName = "GUEST", + LastName = "APC", + FullName = "APC, GUEST" + }); + await _viperContext.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Single(result.GuestAccounts); + Assert.Equal("APCGUEST", result.GuestAccounts[0].MothraId); + Assert.Equal(1001, result.GuestAccounts[0].PersonId); + Assert.Equal("Guest", result.GuestAccounts[0].Source); + } + + [Fact] + public async Task GeneratePreviewAsync_SkipsMissingGuestAccounts() + { + // Arrange - No guest accounts in VIPER People + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Empty(result.GuestAccounts); + } + + #endregion + + #region Summary Calculation Tests + + [Fact] + public async Task GeneratePreviewAsync_CalculatesSummary_WithGuestAccountsOnly() + { + // Arrange - Add guest account + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1001, + ClientId = "APCGUEST", + MothraId = "APCGUEST", + FirstName = "GUEST", + LastName = "APC", + FullName = "APC, GUEST" + }); + await _viperContext.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.NotNull(result.Summary); + Assert.Equal(1, result.Summary.GuestAccounts); + Assert.Equal(1, result.Summary.TotalInstructors); // Guest counts as instructor + } + + [Fact] + public async Task GeneratePreviewAsync_MultipleGuestAccounts_CountsCorrectly() + { + // Arrange - Add 3 guest accounts + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1001, + ClientId = "APCGUEST", + MothraId = "APCGUEST", + FirstName = "GUEST", + LastName = "APC", + FullName = "APC, GUEST" + }); + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1002, + ClientId = "VMEGUEST", + MothraId = "VMEGUEST", + FirstName = "GUEST", + LastName = "VME", + FullName = "VME, GUEST" + }); + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1003, + ClientId = "VSRGUEST", + MothraId = "VSRGUEST", + FirstName = "GUEST", + LastName = "VSR", + FullName = "VSR, GUEST" + }); + await _viperContext.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Equal(3, result.GuestAccounts.Count); + Assert.Equal(3, result.Summary.GuestAccounts); + Assert.Equal(3, result.Summary.TotalInstructors); + } + + #endregion + + #region IsNew Flag Tests + + [Fact] + public async Task GeneratePreviewAsync_NewGuestAccount_SetsIsNewTrue() + { + // Arrange - Add guest account to VIPER but NOT to EffortPerson + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1001, + ClientId = "APCGUEST", + MothraId = "APCGUEST", + FirstName = "GUEST", + LastName = "APC", + FullName = "APC, GUEST" + }); + await _viperContext.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Single(result.GuestAccounts); + Assert.True(result.GuestAccounts[0].IsNew); + } + + [Fact] + public async Task GeneratePreviewAsync_ExistingGuestAccount_SetsIsNewFalse() + { + // Arrange - Add guest account to both VIPER and EffortPerson + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1001, + ClientId = "APCGUEST", + MothraId = "APCGUEST", + FirstName = "GUEST", + LastName = "APC", + FullName = "APC, GUEST" + }); + await _viperContext.SaveChangesAsync(); + + _context.Persons.Add(new EffortPerson + { + PersonId = 1001, + TermCode = TestTermCode, + FirstName = "GUEST", + LastName = "APC" + }); + await _context.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Single(result.GuestAccounts); + Assert.False(result.GuestAccounts[0].IsNew); + } + + #endregion + + #region Removed Items Detection Tests + + [Fact] + public async Task GeneratePreviewAsync_InstructorNotInHarvest_AddsToRemovedList() + { + // Arrange - Add existing instructor that won't be in harvest sources + _context.Persons.Add(new EffortPerson + { + PersonId = 999, + TermCode = TestTermCode, + FirstName = "OLD", + LastName = "INSTRUCTOR" + }); + await _context.SaveChangesAsync(); + + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 999, + ClientId = "OLDINST", + MothraId = "OLDINST", + FirstName = "OLD", + LastName = "INSTRUCTOR", + FullName = "INSTRUCTOR, OLD" + }); + await _viperContext.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Single(result.RemovedInstructors); + Assert.Equal("INSTRUCTOR, OLD", result.RemovedInstructors[0].FullName); + } + + [Fact] + public async Task GeneratePreviewAsync_CourseNotInHarvest_AddsToRemovedList() + { + // Arrange - Add existing course that won't be in harvest sources + _context.Courses.Add(new EffortCourse + { + TermCode = TestTermCode, + Crn = "99999", + SubjCode = "OLD", + CrseNumb = "100", + SeqNumb = "001" + }); + await _context.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Single(result.RemovedCourses); + Assert.Equal("99999", result.RemovedCourses[0].Crn); + Assert.Equal("OLD", result.RemovedCourses[0].SubjCode); + } + + [Fact] + public async Task GeneratePreviewAsync_NoExistingData_EmptyRemovedLists() + { + // Arrange - No existing data + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Empty(result.RemovedInstructors); + Assert.Empty(result.RemovedCourses); + } + + #endregion + + #region Error Handling Tests + + [Fact] + public async Task ExecuteHarvestAsync_WithMissingTerm_ReturnsError() + { + // Arrange - No term in database + + // Act + var result = await _harvestService.ExecuteHarvestAsync(999999, modifiedBy: 123); + + // Assert - When term not found, harvest still runs but fails during preview generation + Assert.False(result.Success); + Assert.NotNull(result.ErrorMessage); + } + + [Fact] + public async Task ExecuteHarvestAsync_ReturnsTermCodeInResult() + { + // Arrange + _context.Terms.Add(new EffortTerm { TermCode = TestTermCode, Status = "Created" }); + await _context.SaveChangesAsync(); + + // Act + var result = await _harvestService.ExecuteHarvestAsync(TestTermCode, modifiedBy: 123); + + // Assert + Assert.True(result.Success); + Assert.Equal(TestTermCode, result.TermCode); + } + + #endregion + + #region Warning Details Tests + + [Fact] + public async Task GeneratePreviewAsync_WithExistingData_WarningIncludesCorrectCounts() + { + // Arrange - Add 2 instructors, 3 courses, and records + _context.Terms.Add(new EffortTerm { TermCode = TestTermCode, Status = "Harvested" }); + + _context.Persons.Add(new EffortPerson { PersonId = 1, TermCode = TestTermCode, FirstName = "A", LastName = "B" }); + _context.Persons.Add(new EffortPerson { PersonId = 2, TermCode = TestTermCode, FirstName = "C", LastName = "D" }); + + _context.Courses.Add(new EffortCourse { TermCode = TestTermCode, Crn = "11111", SubjCode = "T", CrseNumb = "1", SeqNumb = "1" }); + _context.Courses.Add(new EffortCourse { TermCode = TestTermCode, Crn = "22222", SubjCode = "T", CrseNumb = "2", SeqNumb = "1" }); + _context.Courses.Add(new EffortCourse { TermCode = TestTermCode, Crn = "33333", SubjCode = "T", CrseNumb = "3", SeqNumb = "1" }); + + await _context.SaveChangesAsync(); + + // Also add the persons to VIPERContext so MothraId lookup works + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 1, + ClientId = "USER001", + MothraId = "USER001", + FirstName = "A", + LastName = "B", + FullName = "B, A" + }); + _viperContext.People.Add(new Viper.Models.VIPER.Person + { + PersonId = 2, + ClientId = "USER002", + MothraId = "USER002", + FirstName = "C", + LastName = "D", + FullName = "D, C" + }); + await _viperContext.SaveChangesAsync(); + + // Act + var result = await _harvestService.GeneratePreviewAsync(TestTermCode); + + // Assert + Assert.Single(result.Warnings); + Assert.Contains("2 instructors", result.Warnings[0].Details); + Assert.Contains("3 courses", result.Warnings[0].Details); + } + + #endregion +} diff --git a/test/Effort/HarvestTimeParserTests.cs b/test/Effort/HarvestTimeParserTests.cs new file mode 100644 index 00000000..075c40f6 --- /dev/null +++ b/test/Effort/HarvestTimeParserTests.cs @@ -0,0 +1,210 @@ +using Viper.Areas.Effort.Services.Harvest; + +namespace Viper.test.Effort; + +/// +/// Unit tests for HarvestTimeParser utility class. +/// Tests parsing of various time formats used in CREST data. +/// +public sealed class HarvestTimeParserTests +{ + #region ParseTimeString Tests + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseTimeString_NullOrEmpty_ReturnsNull(string? input) + { + var result = HarvestTimeParser.ParseTimeString(input); + Assert.Null(result); + } + + [Theory] + [InlineData("8:00 AM", 8, 0)] + [InlineData("8:30 AM", 8, 30)] + [InlineData("12:00 PM", 12, 0)] + [InlineData("1:30 PM", 13, 30)] + [InlineData("11:59 PM", 23, 59)] + public void ParseTimeString_StandardTimeFormat_ParsesCorrectly(string input, int expectedHour, int expectedMinute) + { + var result = HarvestTimeParser.ParseTimeString(input); + + Assert.NotNull(result); + Assert.Equal(expectedHour, result.Value.Hours); + Assert.Equal(expectedMinute, result.Value.Minutes); + } + + [Theory] + [InlineData("0800", 8, 0)] + [InlineData("1430", 14, 30)] + [InlineData("0000", 0, 0)] + [InlineData("2359", 23, 59)] + public void ParseTimeString_FourDigitMilitaryTime_ParsesCorrectly(string input, int expectedHour, int expectedMinute) + { + var result = HarvestTimeParser.ParseTimeString(input); + + Assert.NotNull(result); + Assert.Equal(expectedHour, result.Value.Hours); + Assert.Equal(expectedMinute, result.Value.Minutes); + } + + [Theory] + [InlineData("800", 8, 0)] + [InlineData("900", 9, 0)] + [InlineData("130", 1, 30)] + public void ParseTimeString_ThreeDigitMilitaryTime_ParsesCorrectly(string input, int expectedHour, int expectedMinute) + { + var result = HarvestTimeParser.ParseTimeString(input); + + Assert.NotNull(result); + Assert.Equal(expectedHour, result.Value.Hours); + Assert.Equal(expectedMinute, result.Value.Minutes); + } + + [Fact] + public void ParseTimeString_MilitaryTime2400_Returns24Hours() + { + var result = HarvestTimeParser.ParseTimeString("2400"); + + Assert.NotNull(result); + // TimeSpan(24, 0, 0) stores as 1 day, 0 hours - check TotalHours + Assert.Equal(24, result.Value.TotalHours); + } + + [Theory] + [InlineData("invalid")] + [InlineData("25:00")] + [InlineData("12:60")] + [InlineData("99")] + [InlineData("12345")] + public void ParseTimeString_InvalidFormat_ReturnsNull(string input) + { + var result = HarvestTimeParser.ParseTimeString(input); + Assert.Null(result); + } + + #endregion + + #region CalculateSessionMinutes Tests + + [Fact] + public void CalculateSessionMinutes_NullDates_ReturnsZero() + { + var result = HarvestTimeParser.CalculateSessionMinutes( + null, "0800", DateTime.Today, "0900"); + + Assert.Equal(0, result); + } + + [Fact] + public void CalculateSessionMinutes_NullTimes_ReturnsZero() + { + var result = HarvestTimeParser.CalculateSessionMinutes( + DateTime.Today, null, DateTime.Today, "0900"); + + Assert.Equal(0, result); + } + + [Fact] + public void CalculateSessionMinutes_SameDay_OneHourSession_Returns60() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "0800", date, "0900"); + + Assert.Equal(60, result); + } + + [Fact] + public void CalculateSessionMinutes_SameDay_TwoHourSession_Returns120() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "1400", date, "1600"); + + Assert.Equal(120, result); + } + + [Fact] + public void CalculateSessionMinutes_SameDay_90MinuteSession_Returns90() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "0930", date, "1100"); + + Assert.Equal(90, result); + } + + [Fact] + public void CalculateSessionMinutes_EndTime2400_CalculatesCorrectly() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "2200", date, "2400"); + + Assert.Equal(120, result); + } + + [Fact] + public void CalculateSessionMinutes_CrossesMidnight_CalculatesCorrectly() + { + var startDate = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + var endDate = new DateTime(2024, 1, 16, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + startDate, "2200", endDate, "0100"); + + Assert.Equal(180, result); + } + + [Fact] + public void CalculateSessionMinutes_EndBeforeStart_ReturnsZero() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "1400", date, "1300"); + + Assert.Equal(0, result); + } + + [Fact] + public void CalculateSessionMinutes_SameStartAndEnd_ReturnsZero() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "1400", date, "1400"); + + Assert.Equal(0, result); + } + + [Fact] + public void CalculateSessionMinutes_InvalidTimeFormat_ReturnsZero() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "invalid", date, "0900"); + + Assert.Equal(0, result); + } + + [Fact] + public void CalculateSessionMinutes_WithStandardTimeFormat_ParsesCorrectly() + { + var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc); + + var result = HarvestTimeParser.CalculateSessionMinutes( + date, "8:00 AM", date, "9:30 AM"); + + Assert.Equal(90, result); + } + + #endregion +} diff --git a/test/Effort/InstructorServiceTests.cs b/test/Effort/InstructorServiceTests.cs index 180f8cd6..72716c7d 100644 --- a/test/Effort/InstructorServiceTests.cs +++ b/test/Effort/InstructorServiceTests.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Moq; using Viper.Areas.Effort; @@ -22,9 +21,9 @@ public sealed class InstructorServiceTests : IDisposable private readonly EffortDbContext _context; private readonly VIPERContext _viperContext; private readonly AAUDContext _aaudContext; + private readonly DictionaryContext _dictionaryContext; private readonly Mock _auditServiceMock; private readonly Mock> _loggerMock; - private readonly IConfiguration _configurationMock; private readonly IMemoryCache _cache; private readonly IMapper _mapper; private readonly InstructorService _instructorService; @@ -46,20 +45,17 @@ public InstructorServiceTests() .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; + var dictionaryOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new EffortDbContext(effortOptions); _viperContext = new VIPERContext(viperOptions); _aaudContext = new AAUDContext(aaudOptions); + _dictionaryContext = new DictionaryContext(dictionaryOptions); _auditServiceMock = new Mock(); _loggerMock = new Mock>(); - - // Setup configuration with empty connection strings section to prevent null reference - var configurationBuilder = new ConfigurationBuilder(); - configurationBuilder.AddInMemoryCollection(new Dictionary - { - { "ConnectionStrings:VIPER", "" } - }); - _configurationMock = configurationBuilder.Build(); - _cache = new MemoryCache(new MemoryCacheOptions()); // Configure AutoMapper with the Effort profile @@ -77,10 +73,10 @@ public InstructorServiceTests() _context, _viperContext, _aaudContext, + _dictionaryContext, _auditServiceMock.Object, _mapper, _loggerMock.Object, - _configurationMock, _cache); } @@ -89,6 +85,7 @@ public void Dispose() _context.Dispose(); _viperContext.Dispose(); _aaudContext.Dispose(); + _dictionaryContext.Dispose(); _cache.Dispose(); } @@ -468,17 +465,22 @@ public void IsValidDepartment_ReturnsTrue_ForValidDepartment() #region ResolveInstructorDepartmentAsync / DetermineDepartment Tests [Fact] - public async Task ResolveInstructorDepartmentAsync_ReturnsMisonOverride_ForMothraId02493928() + public async Task ResolveInstructorDepartmentAsync_ReturnsDepartmentOverride_WhenMothraIdHasOverride() { - // Arrange - Mison override should return VSR regardless of jobs/employee data + // Arrange - Department override should return configured dept regardless of jobs/employee data + // Using the MothraId from EffortConstants.DepartmentOverrides + var deptOverrides = Areas.Effort.Constants.EffortConstants.DepartmentOverrides; + var overrideMothraId = deptOverrides.Keys.First(); + var expectedDept = deptOverrides[overrideMothraId]; + _viperContext.People.Add(new Viper.Models.VIPER.Person { PersonId = 100, - ClientId = "02493928", - FirstName = "Michael", - LastName = "Mison", - FullName = "Mison, Michael", - MothraId = "02493928", // This is the key - Mison's MothraId triggers the override + ClientId = overrideMothraId, + FirstName = "Test", + LastName = "Override", + FullName = "Override, Test", + MothraId = overrideMothraId, // This MothraId has a department override configured CurrentEmployee = true }); await _viperContext.SaveChangesAsync(); @@ -486,17 +488,17 @@ public async Task ResolveInstructorDepartmentAsync_ReturnsMisonOverride_ForMothr // Add VMDO job (should be ignored due to override) _aaudContext.Ids.Add(new Viper.Models.AAUD.Id { - IdsPKey = "MISON001", + IdsPKey = "OVERRIDE001", IdsTermCode = "202410", - IdsMothraid = "02493928", - IdsClientid = "02493928" + IdsMothraid = overrideMothraId, + IdsClientid = overrideMothraId }); _aaudContext.Employees.Add(new Viper.Models.AAUD.Employee { - EmpPKey = "MISON001", + EmpPKey = "OVERRIDE001", EmpTermCode = "202410", - EmpClientid = "02493928", - EmpHomeDept = "072000", // VMDO + EmpClientid = overrideMothraId, + EmpHomeDept = "072000", // VMDO - should be ignored EmpAltDeptCode = "", EmpSchoolDivision = "VM", EmpCbuc = "99", @@ -507,8 +509,8 @@ public async Task ResolveInstructorDepartmentAsync_ReturnsMisonOverride_ForMothr // Act var dept = await _instructorService.ResolveInstructorDepartmentAsync(100, 202410); - // Assert - Should return VSR due to hardcoded override - Assert.Equal("VSR", dept); + // Assert - Should return the configured override department + Assert.Equal(expectedDept, dept); } [Fact] diff --git a/web/Areas/Effort/Constants/EffortAuditActions.cs b/web/Areas/Effort/Constants/EffortAuditActions.cs index 17581618..174ccb1e 100644 --- a/web/Areas/Effort/Constants/EffortAuditActions.cs +++ b/web/Areas/Effort/Constants/EffortAuditActions.cs @@ -42,6 +42,7 @@ public static class EffortAuditActions public const string CloseTerm = "CloseTerm"; public const string ReopenTerm = "ReopenTerm"; public const string UnopenTerm = "UnopenTerm"; + public const string HarvestTerm = "HarvestTerm"; // Unit Actions public const string CreateUnit = "CreateUnit"; diff --git a/web/Areas/Effort/Constants/EffortConstants.cs b/web/Areas/Effort/Constants/EffortConstants.cs new file mode 100644 index 00000000..4d36fdbb --- /dev/null +++ b/web/Areas/Effort/Constants/EffortConstants.cs @@ -0,0 +1,140 @@ +using System.Collections.Frozen; + +namespace Viper.Areas.Effort.Constants; + +/// +/// Shared constants for the Effort system. +/// +public static class EffortConstants +{ + /// + /// Academic departments that can have effort records. + /// + public static readonly string[] AcademicDepartments = ["APC", "PHR", "PMI", "VMB", "VME", "VSR"]; + + /// + /// Guest account MothraIDs for department-level guest accounts. + /// PersonIds are looked up from users.Person at runtime. + /// + public static readonly string[] GuestAccountIds = ["APCGUEST", "PHRGUEST", "PMIGUEST", "VMBGUEST", "VMEGUEST", "VSRGUEST"]; + + /// + /// Department overrides by MothraID. These instructors are assigned to a specific + /// department regardless of their job data in AAUD. + /// Key: MothraID, Value: Department code + /// + public static readonly FrozenDictionary DepartmentOverrides = new Dictionary + { + ["02493928"] = "VSR" + }.ToFrozenDictionary(); + + /// + /// Gets the department override for a given MothraID, or null if no override exists. + /// + public static string? GetDepartmentOverride(string? mothraId) + { + if (string.IsNullOrEmpty(mothraId)) + { + return null; + } + return DepartmentOverrides.TryGetValue(mothraId, out var dept) ? dept : null; + } + + #region Harvest Constants + + /// + /// Role ID for Director (Instructor of Record). + /// + public const int DirectorRoleId = 1; + + /// + /// Role ID for Clinical Instructor. + /// + public const int ClinicalInstructorRoleId = 2; + + /// + /// Effort type code for Clinical rotations. + /// + public const string ClinicalEffortType = "CLI"; + + /// + /// Effort type code for Dissertation/Research courses. + /// + public const string ResearchEffortType = "DIS"; + + /// + /// Effort type code for Variable/Other courses. + /// + public const string VariableEffortType = "VAR"; + + /// + /// CREST role code that identifies the Director (IOR) for a course. + /// + public const string CrestDirectorRoleCode = "Dir"; + + /// + /// Default custodial department for clinical courses. + /// + public const string ClinicalCustodialDept = "DVM"; + + /// + /// Default units for clinical courses. + /// + public const int DefaultClinicalUnits = 15; + + /// + /// Default enrollment for clinical courses. + /// + public const int DefaultClinicalEnrollment = 149; + + #endregion + + #region Harvest Source and Status Strings + + /// + /// Status value indicating a term has been harvested. + /// + public const string TermStatusHarvested = "Harvested"; + + /// + /// Source identifier for CREST-imported data. + /// + public const string SourceCrest = "CREST"; + + /// + /// Source identifier for non-CREST imported data. + /// + public const string SourceNonCrest = "NonCREST"; + + /// + /// Source identifier for courses that exist in CREST (shown for transparency, not imported). + /// + public const string SourceInCrest = "In CREST"; + + /// + /// Source identifier for clinical scheduler data. + /// + public const string SourceClinical = "Clinical"; + + /// + /// Source identifier for guest accounts. + /// + public const string SourceGuest = "Guest"; + + /// + /// Source identifier for existing data (shown in removed items list). + /// + public const string SourceExisting = "Existing"; + + /// + /// CREST session type for debrief sessions (excluded from effort calculation). + /// + public const string SessionTypeDebrief = "DEBRIEF"; + + /// + /// Phase name for clinical import. + /// + public const string PhaseClinical = "Clinical"; + + #endregion +} diff --git a/web/Areas/Effort/Controllers/HarvestController.cs b/web/Areas/Effort/Controllers/HarvestController.cs new file mode 100644 index 00000000..e5e6673d --- /dev/null +++ b/web/Areas/Effort/Controllers/HarvestController.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Services; +using Viper.Classes.Utilities; +using Web.Authorization; + +namespace Viper.Areas.Effort.Controllers; + +/// +/// API controller for harvesting instructor and course data into the Effort system. +/// +[Route("/api/effort/terms/{termCode:int}/harvest")] +[Permission(Allow = EffortPermissions.HarvestTerm)] +public class HarvestController : BaseEffortController +{ + private readonly IHarvestService _harvestService; + private readonly ITermService _termService; + private readonly IEffortPermissionService _permissionService; + + public HarvestController( + IHarvestService harvestService, + ITermService termService, + IEffortPermissionService permissionService, + ILogger logger) : base(logger) + { + _harvestService = harvestService; + _termService = termService; + _permissionService = permissionService; + } + + /// + /// Generate a preview of harvest data without saving. + /// + /// The term code to preview harvest for. + /// Cancellation token. + /// Preview of all data that would be imported. + [HttpGet("preview")] + public async Task> GetPreview(int termCode, CancellationToken ct) + { + SetExceptionContext("termCode", termCode); + + var term = await _termService.GetTermAsync(termCode, ct); + if (term == null) + { + _logger.LogWarning("Term not found for harvest preview: {TermCode}", termCode); + return NotFound($"Term {termCode} not found"); + } + + if (term.Status is not ("Created" or "Harvested")) + { + _logger.LogWarning("Invalid term status for harvest: {TermCode} is {Status}", termCode, term.Status); + return BadRequest($"Term must be in Created or Harvested status to harvest. Current status: {term.Status}"); + } + + _logger.LogInformation("Generating harvest preview for term {TermCode}", termCode); + var preview = await _harvestService.GeneratePreviewAsync(termCode, ct); + + return Ok(preview); + } + + /// + /// Execute harvest: clear existing data and import all phases. + /// + /// The term code to harvest. + /// Cancellation token. + /// Result of the harvest operation. + [HttpPost("commit")] + public async Task> CommitHarvest(int termCode, CancellationToken ct) + { + SetExceptionContext("termCode", termCode); + + var term = await _termService.GetTermAsync(termCode, ct); + if (term == null) + { + _logger.LogWarning("Term not found for harvest commit: {TermCode}", termCode); + return NotFound($"Term {termCode} not found"); + } + + if (term.Status is not ("Created" or "Harvested")) + { + _logger.LogWarning("Invalid term status for harvest: {TermCode} is {Status}", termCode, term.Status); + return BadRequest($"Term must be in Created or Harvested status to harvest. Current status: {term.Status}"); + } + + var modifiedBy = _permissionService.GetCurrentPersonId(); + + _logger.LogInformation("Starting harvest for term {TermCode} by user {ModifiedBy}", termCode, modifiedBy); + + var result = await _harvestService.ExecuteHarvestAsync(termCode, modifiedBy, ct); + + if (result.Success) + { + _logger.LogInformation("Harvest completed for term {TermCode}: {Instructors} instructors, {Courses} courses, {Records} records", + termCode, result.Summary.TotalInstructors, result.Summary.TotalCourses, result.Summary.TotalEffortRecords); + return Ok(result); + } + + _logger.LogWarning("Harvest failed for term {TermCode}: {Error}", termCode, LogSanitizer.SanitizeString(result.ErrorMessage)); + return BadRequest(result); + } + + /// + /// Execute harvest with real-time progress updates via Server-Sent Events (SSE). + /// + /// The term code to harvest. + /// Cancellation token. + [HttpGet("stream")] + public async Task StreamHarvest(int termCode, CancellationToken ct) + { + SetExceptionContext("termCode", termCode); + + // Validate term before starting stream + var term = await _termService.GetTermAsync(termCode, ct); + if (term == null) + { + _logger.LogWarning("Term not found for harvest stream: {TermCode}", termCode); + Response.StatusCode = 404; + return; + } + + if (term.Status is not ("Created" or "Harvested")) + { + _logger.LogWarning("Invalid term status for harvest: {TermCode} is {Status}", termCode, term.Status); + Response.StatusCode = 400; + return; + } + + var modifiedBy = _permissionService.GetCurrentPersonId(); + + // Set SSE headers + Response.Headers.Append("Content-Type", "text/event-stream"); + Response.Headers.Append("Cache-Control", "no-cache"); + Response.Headers.Append("Connection", "keep-alive"); + + _logger.LogInformation("Starting SSE harvest stream for term {TermCode} by user {ModifiedBy}", termCode, modifiedBy); + + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + // Create a channel for progress events + var channel = Channel.CreateUnbounded(); + + // Start the harvest in a background task + var harvestTask = Task.Run(async () => + { + try + { + await _harvestService.ExecuteHarvestWithProgressAsync(termCode, modifiedBy, channel.Writer, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during harvest for term {TermCode}", termCode); + await channel.Writer.WriteAsync(HarvestProgressEvent.Failed("An unexpected error occurred during harvest."), ct); + channel.Writer.Complete(); + } + }, ct); + + try + { + // Read from the channel and stream to client + await foreach (var progressEvent in channel.Reader.ReadAllAsync(ct)) + { + var json = JsonSerializer.Serialize(progressEvent, jsonOptions); + await Response.WriteAsync($"event: {progressEvent.Type}\n", ct); + await Response.WriteAsync($"data: {json}\n\n", ct); + await Response.Body.FlushAsync(ct); + } + } + catch (OperationCanceledException ex) + { + _logger.LogInformation(ex, "Harvest stream cancelled for term {TermCode}", termCode); + } + + await harvestTask; + } +} diff --git a/web/Areas/Effort/Models/DTOs/Requests/UpdateEffortTypeRequest.cs b/web/Areas/Effort/Models/DTOs/Requests/UpdateEffortTypeRequest.cs index 91d9f380..64132437 100644 --- a/web/Areas/Effort/Models/DTOs/Requests/UpdateEffortTypeRequest.cs +++ b/web/Areas/Effort/Models/DTOs/Requests/UpdateEffortTypeRequest.cs @@ -13,10 +13,10 @@ public class UpdateEffortTypeRequest [RegularExpression(@".*\S.*", ErrorMessage = "Description cannot be empty or whitespace only.")] public string Description { get; set; } = string.Empty; - public bool UsesWeeks { get; set; } - public bool IsActive { get; set; } = true; - public bool FacultyCanEnter { get; set; } = true; - public bool AllowedOnDvm { get; set; } = true; - public bool AllowedOn199299 { get; set; } = true; - public bool AllowedOnRCourses { get; set; } = true; + public required bool UsesWeeks { get; set; } + public required bool IsActive { get; set; } + public required bool FacultyCanEnter { get; set; } + public required bool AllowedOnDvm { get; set; } + public required bool AllowedOn199299 { get; set; } + public required bool AllowedOnRCourses { get; set; } } diff --git a/web/Areas/Effort/Models/DTOs/Responses/HarvestPreviewDto.cs b/web/Areas/Effort/Models/DTOs/Responses/HarvestPreviewDto.cs new file mode 100644 index 00000000..bb40738f --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Responses/HarvestPreviewDto.cs @@ -0,0 +1,158 @@ +namespace Viper.Areas.Effort.Models.DTOs.Responses; + +/// +/// Preview of harvest data before committing. +/// +public class HarvestPreviewDto +{ + public int TermCode { get; set; } + public string TermName { get; set; } = string.Empty; + + // Phase 1: CREST + public List CrestInstructors { get; set; } = []; + public List CrestCourses { get; set; } = []; + public List CrestEffort { get; set; } = []; + + // Phase 2: Non-CREST + public List NonCrestCourses { get; set; } = []; + public List NonCrestInstructors { get; set; } = []; + public List NonCrestEffort { get; set; } = []; + + // Phase 3: Clinical Scheduler + public List ClinicalInstructors { get; set; } = []; + public List ClinicalCourses { get; set; } = []; + public List ClinicalEffort { get; set; } = []; + + // Phase 4: Guest Accounts + public List GuestAccounts { get; set; } = []; + + // Items that exist but won't be re-imported (will be deleted) + public List RemovedInstructors { get; set; } = []; + public List RemovedCourses { get; set; } = []; + + // Summary + public HarvestSummary Summary { get; set; } = new(); + + // Problems + public List Warnings { get; set; } = []; + public List Errors { get; set; } = []; +} + +/// +/// Preview of an instructor/person to be imported. +/// +public class HarvestPersonPreview +{ + /// + /// MothraID or guest account ID (e.g., APCGUEST). + /// + public string MothraId { get; set; } = string.Empty; + + /// + /// PersonId from users.Person table (0 for guest accounts). + /// + public int PersonId { get; set; } + + public string FullName { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Department { get; set; } = string.Empty; + + /// + /// Title code for database storage (max 6 chars). + /// + public string TitleCode { get; set; } = string.Empty; + + /// + /// Human-readable title description for display. + /// + public string TitleDescription { get; set; } = string.Empty; + + /// + /// Source of this data: CREST, NonCREST, Clinical, or Guest. + /// + public string Source { get; set; } = string.Empty; + + /// + /// True if this instructor does not exist in Effort_People for this term and will be created. + /// False if the instructor already exists and will be updated/replaced. + /// + public bool IsNew { get; set; } +} + +/// +/// Preview of a course to be imported. +/// +public class HarvestCoursePreview +{ + public string Crn { get; set; } = string.Empty; + public string SubjCode { get; set; } = string.Empty; + public string CrseNumb { get; set; } = string.Empty; + public string SeqNumb { get; set; } = string.Empty; + public int Enrollment { get; set; } + public decimal Units { get; set; } + public string CustDept { get; set; } = string.Empty; + + /// + /// Source of this data: CREST, NonCREST, or Clinical. + /// + public string Source { get; set; } = string.Empty; + + /// + /// True if this course does not exist in Effort_Courses for this term and will be created. + /// False if the course already exists and will be updated/replaced. + /// + public bool IsNew { get; set; } +} + +/// +/// Preview of an effort record to be imported. +/// +public class HarvestRecordPreview +{ + public string MothraId { get; set; } = string.Empty; + public string PersonName { get; set; } = string.Empty; + public string Crn { get; set; } = string.Empty; + public string CourseCode { get; set; } = string.Empty; + public string EffortType { get; set; } = string.Empty; + public int? Hours { get; set; } + public int? Weeks { get; set; } + public int RoleId { get; set; } + public string RoleName { get; set; } = string.Empty; + + /// + /// Source of this data: CREST or Clinical. + /// + public string Source { get; set; } = string.Empty; +} + +/// +/// Summary counts for harvest preview. +/// +public class HarvestSummary +{ + public int TotalInstructors { get; set; } + public int TotalCourses { get; set; } + public int TotalEffortRecords { get; set; } + public int GuestAccounts { get; set; } +} + +/// +/// Warning message during harvest preview/execution. +/// +public class HarvestWarning +{ + public string Phase { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Details { get; set; } = string.Empty; +} + +/// +/// Error that prevents harvest from completing. +/// +public class HarvestError +{ + public string Phase { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Details { get; set; } = string.Empty; +} diff --git a/web/Areas/Effort/Models/DTOs/Responses/HarvestProgressEvent.cs b/web/Areas/Effort/Models/DTOs/Responses/HarvestProgressEvent.cs new file mode 100644 index 00000000..ce68faee --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Responses/HarvestProgressEvent.cs @@ -0,0 +1,107 @@ +namespace Viper.Areas.Effort.Models.DTOs.Responses; + +/// +/// Progress event for SSE streaming during harvest. +/// +public class HarvestProgressEvent +{ + /// + /// Event type: "progress", "complete", or "error". + /// + public string Type { get; set; } = "progress"; + + /// + /// Current phase: "clearing", "instructors", "courses", "records", "clinical", "finalizing". + /// + public string Phase { get; set; } = string.Empty; + + /// + /// Progress within current phase (0.0 to 1.0). + /// + public double Progress { get; set; } + + /// + /// Human-readable message for current operation. + /// + public string Message { get; set; } = string.Empty; + + /// + /// Current item count being processed (e.g., "150 of 266 instructors"). + /// + public string? Detail { get; set; } + + /// + /// Final result (only set when Type is "complete"). + /// + public HarvestResultDto? Result { get; set; } + + /// + /// Error message (only set when Type is "error"). + /// + public string? Error { get; set; } + + // Factory methods for common events + public static HarvestProgressEvent Clearing() => new() + { + Phase = "clearing", + Progress = 0.05, + Message = "Clearing existing data..." + }; + + public static HarvestProgressEvent ImportingInstructors(int current, int total) => new() + { + Phase = "instructors", + Progress = 0.1 + (0.2 * current / Math.Max(total, 1)), + Message = "Importing instructors...", + Detail = $"{current} of {total} instructors" + }; + + public static HarvestProgressEvent ImportingCourses(int current, int total) => new() + { + Phase = "courses", + Progress = 0.3 + (0.2 * current / Math.Max(total, 1)), + Message = "Importing courses...", + Detail = $"{current} of {total} courses" + }; + + public static HarvestProgressEvent ImportingRecords(int current, int total) => new() + { + Phase = "records", + Progress = 0.5 + (0.25 * current / Math.Max(total, 1)), + Message = "Importing effort records...", + Detail = $"{current} of {total} records" + }; + + public static HarvestProgressEvent ImportingClinical(int current, int total) => new() + { + Phase = "clinical", + Progress = 0.75 + (0.15 * current / Math.Max(total, 1)), + Message = "Importing clinical data...", + Detail = $"{current} of {total} clinical records" + }; + + public static HarvestProgressEvent Finalizing() => new() + { + Phase = "finalizing", + Progress = 0.95, + Message = "Finalizing harvest..." + }; + + public static HarvestProgressEvent Complete(HarvestResultDto result) => new() + { + Type = "complete", + Phase = "complete", + Progress = 1.0, + Message = "Harvest complete!", + Result = result + }; + + public static HarvestProgressEvent Failed(string error) => new() + { + Type = "error", + Phase = "error", + Progress = 0, + Message = "Harvest failed", + Error = error + }; +} diff --git a/web/Areas/Effort/Models/DTOs/Responses/HarvestResultDto.cs b/web/Areas/Effort/Models/DTOs/Responses/HarvestResultDto.cs new file mode 100644 index 00000000..65c505bd --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Responses/HarvestResultDto.cs @@ -0,0 +1,14 @@ +namespace Viper.Areas.Effort.Models.DTOs.Responses; + +/// +/// Result of a harvest operation. +/// +public class HarvestResultDto +{ + public bool Success { get; set; } + public int TermCode { get; set; } + public DateTime? HarvestedDate { get; set; } + public HarvestSummary Summary { get; set; } = new(); + public List Warnings { get; set; } = []; + public string? ErrorMessage { get; set; } +} diff --git a/web/Areas/Effort/Models/DTOs/Responses/TermDto.cs b/web/Areas/Effort/Models/DTOs/Responses/TermDto.cs index cd624eca..20c0414e 100644 --- a/web/Areas/Effort/Models/DTOs/Responses/TermDto.cs +++ b/web/Areas/Effort/Models/DTOs/Responses/TermDto.cs @@ -73,4 +73,9 @@ public class TermDto /// This value is populated by the service when fetching terms for management. /// public bool CanDelete { get; set; } + + /// + /// Whether the term can be harvested (status is Created or Harvested). + /// + public bool CanHarvest => Status is "Created" or "Harvested"; } diff --git a/web/Areas/Effort/Services/EffortAuditService.cs b/web/Areas/Effort/Services/EffortAuditService.cs index 32bf53d5..f335a40f 100644 --- a/web/Areas/Effort/Services/EffortAuditService.cs +++ b/web/Areas/Effort/Services/EffortAuditService.cs @@ -116,6 +116,64 @@ public void AddEffortTypeChangeAudit(string effortTypeId, string action, object? AddAuditEntry(EffortAuditTables.EffortTypes, 0, null, action, changes); } + public async Task ClearAuditForTermAsync(int termCode, CancellationToken ct = default) + { + // Clear audit records for the term (except ImportEffort actions) + // This matches legacy behavior: DELETE FROM tblAudit WHERE audit_TermCode = @TermCode AND audit_Action != 'ImportCrest' + await _context.Audits + .Where(a => a.TermCode == termCode && a.Action != EffortAuditActions.ImportEffort) + .ExecuteDeleteAsync(ct); + + _logger.LogInformation("Cleared audit records for term {TermCode}", termCode); + } + + public void AddHarvestPersonAudit(int personId, int termCode, string firstName, string lastName, string department) + { + var changes = JsonSerializer.Serialize(new Dictionary + { + ["PersonId"] = new ChangeDetail { OldValue = null, NewValue = personId.ToString() }, + ["Name"] = new ChangeDetail { OldValue = null, NewValue = $"{lastName}, {firstName}" }, + ["Department"] = new ChangeDetail { OldValue = null, NewValue = department } + }); + + AddAuditEntry(EffortAuditTables.Persons, personId, termCode, EffortAuditActions.CreatePerson, changes); + } + + public void AddHarvestCourseAudit(int courseId, int termCode, string subjCode, string crseNumb, string crn) + { + var changes = JsonSerializer.Serialize(new Dictionary + { + ["CourseId"] = new ChangeDetail { OldValue = null, NewValue = courseId.ToString() }, + ["Course"] = new ChangeDetail { OldValue = null, NewValue = $"{subjCode} {crseNumb}" }, + ["CRN"] = new ChangeDetail { OldValue = null, NewValue = crn } + }); + + AddAuditEntry(EffortAuditTables.Courses, courseId, termCode, EffortAuditActions.CreateCourse, changes); + } + + public void AddHarvestRecordAudit(int recordId, int termCode, string mothraId, string courseCode, string effortType, int? hours, int? weeks) + { + var changesDict = new Dictionary + { + ["MothraId"] = new ChangeDetail { OldValue = null, NewValue = mothraId }, + ["Course"] = new ChangeDetail { OldValue = null, NewValue = courseCode }, + ["Type"] = new ChangeDetail { OldValue = null, NewValue = effortType } + }; + + if (hours.HasValue && hours.Value > 0) + { + changesDict["Hours"] = new ChangeDetail { OldValue = null, NewValue = hours.Value.ToString() }; + } + + if (weeks.HasValue && weeks.Value > 0) + { + changesDict["Weeks"] = new ChangeDetail { OldValue = null, NewValue = weeks.Value.ToString() }; + } + + var changes = JsonSerializer.Serialize(changesDict); + AddAuditEntry(EffortAuditTables.Records, recordId, termCode, EffortAuditActions.CreateEffort, changes); + } + /// /// Add an audit entry to the context without saving. /// Use within a transaction where the caller manages SaveChangesAsync. @@ -511,10 +569,19 @@ private IQueryable BuildFilteredQuery(EffortAuditFilter filter) if (filter.TermCode.HasValue) { var termCode = filter.TermCode.Value; - // Include term-specific entries, term record entries, and term-independent entries (like Units) + + // Look up the term's active date range to filter term-independent entries + var term = _context.Terms.AsNoTracking().FirstOrDefault(t => t.TermCode == termCode); + var termStartDate = term?.HarvestedDate; + var termEndDate = term?.ClosedDate ?? DateTime.Now; + + // Include term-specific entries, term record entries, and term-independent entries + // For term-independent entries (TermCode == null), only show those that occurred + // during the term's active period (from harvest to close) query = query.Where(a => a.TermCode == termCode || - a.TermCode == null || + (a.TermCode == null && termStartDate != null && + a.ChangedDate >= termStartDate && a.ChangedDate <= termEndDate) || (a.TableName == EffortAuditTables.Terms && a.RecordId == termCode)); } diff --git a/web/Areas/Effort/Services/Harvest/ClinicalHarvestPhase.cs b/web/Areas/Effort/Services/Harvest/ClinicalHarvestPhase.cs new file mode 100644 index 00000000..84f83d5a --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/ClinicalHarvestPhase.cs @@ -0,0 +1,378 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Responses; + +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Phase 3: Clinical Scheduler import. +/// Imports clinical rotation data from the Clinical Scheduler system. +/// Only executes for semester terms (not quarter terms). +/// +public sealed class ClinicalHarvestPhase : HarvestPhaseBase +{ + /// + public override string PhaseName => EffortConstants.PhaseClinical; + + /// + public override int Order => 30; + + /// + public override bool ShouldExecute(int termCode) => IsSemesterTerm(termCode); + + /// + /// Clinical course priority lookup. Lower values = higher priority. + /// When an instructor is assigned to multiple rotations in the same week, + /// only the highest priority course is credited. + /// + private static readonly Dictionary ClinicalCoursePriority = new() + { + ["DVM 453"] = -100, // Comm surgery - highest priority + ["DVM 491"] = 10, // SA emergency over ICU + ["DVM 492"] = 11, + ["DVM 476"] = 20, // LA anest over SA anest + ["DVM 490"] = 21, + ["DVM 477"] = 30, // LA radio over SA radio + ["DVM 494"] = 31, + ["DVM 482"] = 40, // Med onc over rad onc + ["DVM 487"] = 41, + ["DVM 493"] = 50, // Internal med: Med A first + ["DVM 466"] = 51, + ["DVM 443"] = 52, + ["DVM 451"] = 60, // Clin path over anat path + ["DVM 485"] = 61, + ["DVM 457"] = 70, // Equine emergency over S&L + ["DVM 462"] = 71, + ["DVM 459"] = 80, // Equine field over in-house + ["DVM 460"] = 81, + ["DVM 447"] = 100, // EduLead - lowest priority + }; + + /// + public override async Task GeneratePreviewAsync(HarvestContext context, CancellationToken ct = default) + { + if (!ShouldExecute(context.TermCode)) + { + context.Logger.LogInformation( + "Skipping Clinical Scheduler import for quarter term {TermCode}", + context.TermCode); + context.Preview.Warnings.Add(new HarvestWarning + { + Phase = EffortConstants.PhaseClinical, + Message = "Clinical Scheduler import skipped", + Details = "Clinical Scheduler data is only imported for semester terms, not quarter terms." + }); + return; + } + + // Get week IDs for this term + var weekIds = await context.ViperContext.Weeks + .AsNoTracking() + .Where(w => w.TermCode == context.TermCode) + .Select(w => w.WeekId) + .ToListAsync(ct); + + // Get clinical instructor schedules + var clinicalData = await context.ViperContext.InstructorSchedules + .AsNoTracking() + .Where(s => weekIds.Contains(s.WeekId)) + .Where(s => !string.IsNullOrEmpty(s.SubjCode) && !string.IsNullOrEmpty(s.CrseNumb)) + .Select(s => new + { + s.MothraId, + s.WeekId, + s.SubjCode, + s.CrseNumb, + s.RotationId, + s.Role, + PersonName = s.LastName + ", " + s.FirstName + }) + .ToListAsync(ct); + + // Group by person and course to count weeks + var personWeekCourses = new Dictionary>>(); + + foreach (var schedule in clinicalData) + { + if (string.IsNullOrEmpty(schedule.MothraId)) continue; + + if (!personWeekCourses.ContainsKey(schedule.MothraId)) + { + personWeekCourses[schedule.MothraId] = new Dictionary>(); + } + + if (!personWeekCourses[schedule.MothraId].ContainsKey(schedule.WeekId)) + { + personWeekCourses[schedule.MothraId][schedule.WeekId] = []; + } + + var courseKey = $"{schedule.SubjCode} {schedule.CrseNumb}"; + if (!personWeekCourses[schedule.MothraId][schedule.WeekId].Contains(courseKey)) + { + personWeekCourses[schedule.MothraId][schedule.WeekId].Add(courseKey); + } + } + + // Build effort records with priority resolution + var effortByPersonCourse = new Dictionary(); + + foreach (var schedule in clinicalData) + { + if (string.IsNullOrEmpty(schedule.MothraId) || string.IsNullOrEmpty(schedule.SubjCode)) continue; + + var courseKey = $"{schedule.SubjCode} {schedule.CrseNumb}"; + var effortKey = $"{schedule.MothraId}-{courseKey}"; + + // Check if this course is the priority for this person/week + var weekCourses = personWeekCourses[schedule.MothraId][schedule.WeekId]; + var priorityCourse = GetPriorityCourse(weekCourses); + + if (courseKey != priorityCourse) continue; + + if (!effortByPersonCourse.ContainsKey(effortKey)) + { + effortByPersonCourse[effortKey] = new HarvestRecordPreview + { + MothraId = schedule.MothraId, + PersonName = schedule.PersonName ?? "", + Crn = "", + CourseCode = courseKey, + EffortType = EffortConstants.ClinicalEffortType, + Weeks = 0, + RoleId = EffortConstants.ClinicalInstructorRoleId, + RoleName = "Instructor", + Source = EffortConstants.SourceClinical + }; + } + + effortByPersonCourse[effortKey].Weeks = (effortByPersonCourse[effortKey].Weeks ?? 0) + 1; + } + + context.Preview.ClinicalEffort.AddRange(effortByPersonCourse.Values.Where(e => e.Weeks > 0)); + + // Get unique clinical courses + var clinicalCourses = clinicalData + .Where(c => !string.IsNullOrEmpty(c.SubjCode) && !string.IsNullOrEmpty(c.CrseNumb)) + .Select(c => new { c.SubjCode, c.CrseNumb }) + .Distinct() + .ToList(); + + // Look up CRNs from Banner for clinical courses + var termCodeStr = context.TermCode.ToString(); + var bannerCrns = await context.CoursesContext.Baseinfos + .AsNoTracking() + .Where(b => b.BaseinfoTermCode == termCodeStr && b.BaseinfoSeqNumb == "001") + .Select(b => new { b.BaseinfoSubjCode, b.BaseinfoCrseNumb, b.BaseinfoCrn }) + .ToListAsync(ct); + + var crnLookup = bannerCrns + .GroupBy(b => $"{b.BaseinfoSubjCode}-{b.BaseinfoCrseNumb}") + .ToDictionary(g => g.Key, g => g.First().BaseinfoCrn, StringComparer.OrdinalIgnoreCase); + + foreach (var course in clinicalCourses) + { + var lookupKey = $"{course.SubjCode}-{course.CrseNumb}"; + var crn = crnLookup.GetValueOrDefault(lookupKey, ""); + + context.Preview.ClinicalCourses.Add(new HarvestCoursePreview + { + Crn = crn, + SubjCode = course.SubjCode ?? "", + CrseNumb = course.CrseNumb ?? "", + SeqNumb = "001", + Enrollment = EffortConstants.DefaultClinicalEnrollment, + Units = EffortConstants.DefaultClinicalUnits, + CustDept = EffortConstants.ClinicalCustodialDept, + Source = EffortConstants.SourceClinical + }); + } + + // Build clinical instructor previews + await BuildClinicalInstructorPreviewsAsync(context, clinicalData.Select(c => ((string?)c.MothraId, (string?)c.PersonName)).ToList(), ct); + } + + /// + public override async Task ExecuteAsync(HarvestContext context, CancellationToken ct = default) + { + if (!ShouldExecute(context.TermCode)) + { + return; + } + + // Import clinical courses + context.Logger.LogInformation( + "Importing {Count} clinical courses for term {TermCode}", + context.Preview.ClinicalCourses.Count, + context.TermCode); + + foreach (var course in context.Preview.ClinicalCourses) + { + var key = BuildCourseLookupKey(course); + if (!context.CourseIdLookup.ContainsKey(key)) + { + var courseId = await ImportCourseAsync(course, context, ct); + context.CourseIdLookup[key] = courseId; + } + } + + // Import clinical effort records + context.Logger.LogInformation( + "Importing {Count} clinical effort records for term {TermCode}", + context.Preview.ClinicalEffort.Count, + context.TermCode); + + // Get all instructors from all phases for lookup + var allInstructors = context.Preview.CrestInstructors + .Concat(context.Preview.NonCrestInstructors) + .Concat(context.Preview.GuestAccounts) + .DistinctBy(p => p.MothraId) + .ToList(); + + foreach (var effort in context.Preview.ClinicalEffort) + { + // Look up instructor - first check existing preview lists + var instructorPreview = allInstructors.FirstOrDefault(i => i.MothraId == effort.MothraId); + + // If not found, look up directly from VIPER (clinical-only instructor) + if (instructorPreview == null) + { + var person = await context.ViperContext.People + .AsNoTracking() + .FirstOrDefaultAsync(p => p.MothraId == effort.MothraId, ct); + + if (person == null) + { + context.Warnings.Add(new HarvestWarning + { + Phase = EffortConstants.PhaseClinical, + Message = $"Instructor {effort.MothraId} not found in VIPER", + Details = effort.CourseCode + }); + continue; + } + + // Create preview record for this clinical-only instructor + var dept = await ResolveDepartmentAsync(person.PersonId, context, ct); + instructorPreview = new HarvestPersonPreview + { + MothraId = person.MothraId ?? "", + PersonId = person.PersonId, + FullName = $"{person.LastName}, {person.FirstName}", + FirstName = person.FirstName ?? "", + LastName = person.LastName ?? "", + Department = dept, + TitleCode = "", + TitleDescription = "", + Source = EffortConstants.SourceClinical + }; + } + + // Import instructor if not already imported + await ImportPersonAsync(instructorPreview, context, ct); + + var result = await ImportEffortRecordAsync(effort, context, ct); + if (result.Record != null && result.Preview != null) + { + context.CreatedRecords.Add((result.Record, result.Preview)); + } + } + } + + private async Task BuildClinicalInstructorPreviewsAsync( + HarvestContext context, + List<(string? MothraId, string? PersonName)> clinicalPersonData, + CancellationToken ct) + { + var termCodeStr = context.TermCode.ToString(); + + // Get unique clinical instructor MothraIds + var clinicalMothraIds = clinicalPersonData + .Where(c => !string.IsNullOrEmpty(c.MothraId)) + .Select(c => c.MothraId!) + .Distinct() + .ToList(); + + // Look up AAUD info for clinical instructors + var clinicalAaudInfo = await context.AaudContext.Ids + .AsNoTracking() + .Where(ids => ids.IdsMothraid != null && clinicalMothraIds.Contains(ids.IdsMothraid)) + .Where(ids => ids.IdsTermCode == termCodeStr) + .Join(context.AaudContext.Employees.Where(e => e.EmpTermCode == termCodeStr), + ids => ids.IdsPKey, + emp => emp.EmpPKey, + (ids, emp) => new + { + MothraId = ids.IdsMothraid, + emp.EmpEffortTitleCode, + emp.EmpEffortHomeDept + }) + .ToListAsync(ct); + + var clinicalAaudLookup = clinicalAaudInfo + .Where(x => !string.IsNullOrEmpty(x.MothraId)) + .GroupBy(x => x.MothraId!) + .ToDictionary(g => g.Key, g => g.First()); + + // Get title and department lookups + context.TitleLookup ??= (await context.InstructorService.GetTitleCodesAsync(ct)) + .ToDictionary(t => t.Code, t => t.Name, StringComparer.OrdinalIgnoreCase); + + context.DeptSimpleNameLookup ??= await context.InstructorService.GetDepartmentSimpleNameLookupAsync(ct); + + // Get existing instructor MothraIds for IsNew determination + var existingMothraIds = context.Preview.CrestInstructors + .Concat(context.Preview.NonCrestInstructors) + .Select(i => i.MothraId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Build instructor name lookup + var clinicalInstructorNames = clinicalPersonData + .Where(c => !string.IsNullOrEmpty(c.MothraId)) + .GroupBy(c => c.MothraId!) + .ToDictionary(g => g.Key, g => g.First().PersonName ?? ""); + + foreach (var mothraId in clinicalMothraIds) + { + var personName = clinicalInstructorNames.GetValueOrDefault(mothraId, ""); + var aaudInfo = clinicalAaudLookup.GetValueOrDefault(mothraId); + + var titleCode = aaudInfo?.EmpEffortTitleCode?.Trim() ?? ""; + var titleDesc = !string.IsNullOrEmpty(titleCode) && context.TitleLookup.TryGetValue(titleCode, out var desc) + ? desc + : titleCode; + + var effortDept = aaudInfo?.EmpEffortHomeDept?.Trim() ?? ""; + var deptName = context.DeptSimpleNameLookup != null && + !string.IsNullOrEmpty(effortDept) && + context.DeptSimpleNameLookup.TryGetValue(effortDept, out var dn) + ? dn + : effortDept; + + context.Preview.ClinicalInstructors.Add(new HarvestPersonPreview + { + MothraId = mothraId, + FullName = personName, + Department = deptName, + TitleCode = titleCode, + TitleDescription = titleDesc, + Source = EffortConstants.SourceClinical, + IsNew = !existingMothraIds.Contains(mothraId) + }); + } + + // Sort clinical instructors by name + context.Preview.ClinicalInstructors = context.Preview.ClinicalInstructors + .OrderBy(i => i.FullName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string GetPriorityCourse(List courses) + { + if (courses.Count == 1) + { + return courses[0]; + } + + return courses.MinBy(c => ClinicalCoursePriority.GetValueOrDefault(c, 0)) ?? courses[0]; + } +} diff --git a/web/Areas/Effort/Services/Harvest/CrestHarvestPhase.cs b/web/Areas/Effort/Services/Harvest/CrestHarvestPhase.cs new file mode 100644 index 00000000..12a0fe7b --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/CrestHarvestPhase.cs @@ -0,0 +1,485 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Responses; + +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Phase 1: CREST course and instructor import. +/// Imports course data from the CREST scheduling system. +/// +public sealed class CrestHarvestPhase : HarvestPhaseBase +{ + /// + public override string PhaseName => EffortConstants.SourceCrest; + + /// + public override int Order => 10; + + /// + /// DTO for CREST instructor data. + /// + private sealed record CrestInstructorDto( + string MothraId, + string FirstName, + string LastName, + string TitleCode, + string HomeDept); + + /// + public override async Task GeneratePreviewAsync(HarvestContext context, CancellationToken ct = default) + { + var termCodeStr = context.TermCode.ToString(); + + // Step 1: Get master course list from tbl_Block (excluding DVM/clinical courses) + var blocks = await context.CrestContext.Blocks + .AsNoTracking() + .Where(b => b.AcademicYear == termCodeStr) + .Where(b => b.SsaCourseNum != null && !b.SsaCourseNum.Trim().StartsWith("DVM")) + .ToListAsync(ct); + + if (blocks.Count == 0) + { + context.Logger.LogInformation("No CREST courses found in tbl_Block for term {TermCode}", context.TermCode); + return; + } + + var blockCourseIds = blocks.Select(b => b.EdutaskId).Distinct().ToHashSet(); + + // Step 2: Get course session offerings filtered by block course IDs and term + var courseOfferings = await context.CrestContext.CourseSessionOfferings + .AsNoTracking() + .Where(cso => cso.AcademicYear == termCodeStr) + .Where(cso => blockCourseIds.Contains(cso.CourseId)) + .Where(cso => cso.SessionType != EffortConstants.SessionTypeDebrief) + .ToListAsync(ct); + + // Step 3: Build unique courses from session offerings + var uniqueCourses = courseOfferings + .Where(c => !string.IsNullOrEmpty(c.Crn) && !string.IsNullOrEmpty(c.SsaCourseNum)) + .GroupBy(c => new { c.Crn, c.SsaCourseNum, c.SeqNumb }) + .Select(g => g.First()) + .ToList(); + + foreach (var course in uniqueCourses) + { + var subjCode = course.SsaCourseNum?.Length >= 3 ? course.SsaCourseNum[..3] : ""; + var crseNumb = course.SsaCourseNum?.Length > 3 ? course.SsaCourseNum[3..] : ""; + + context.Preview.CrestCourses.Add(new HarvestCoursePreview + { + Crn = course.Crn ?? "", + SubjCode = subjCode, + CrseNumb = crseNumb, + SeqNumb = course.SeqNumb ?? "001", + Enrollment = 0, + Units = 0, + CustDept = "VET", + Source = EffortConstants.SourceCrest + }); + } + + // Populate units and enrollment from roster data + var crestCrns = context.Preview.CrestCourses + .Select(c => c.Crn) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToList(); + + var rosterSummaries = await context.CoursesContext.Rosters + .AsNoTracking() + .Where(r => r.RosterTermCode == termCodeStr && crestCrns.Contains(r.RosterCrn!)) + .GroupBy(r => r.RosterCrn!) + .Select(g => new { Crn = g.Key, Units = g.Max(r => r.RosterUnit ?? 0), Enrollment = g.Count() }) + .ToDictionaryAsync(x => x.Crn, x => x, ct); + + foreach (var course in context.Preview.CrestCourses) + { + if (rosterSummaries.TryGetValue(course.Crn, out var summary)) + { + course.Units = (decimal)summary.Units; + course.Enrollment = summary.Enrollment; + } + } + + // Step 4: Get CREST instructors + var crestInstructors = await GetCrestInstructorsAsync(context, ct); + + // Get title descriptions and department lookups + context.TitleLookup ??= (await context.InstructorService.GetTitleCodesAsync(ct)) + .ToDictionary(t => t.Code, t => t.Name, StringComparer.OrdinalIgnoreCase); + + context.DeptSimpleNameLookup ??= await context.InstructorService.GetDepartmentSimpleNameLookupAsync(ct); + + // Get VIPER person IDs for the instructors + var mothraIds = crestInstructors.Select(i => i.MothraId).Distinct().ToList(); + var personDetails = await context.ViperContext.People + .AsNoTracking() + .Where(p => mothraIds.Contains(p.MothraId)) + .Select(p => new { p.PersonId, p.MothraId }) + .ToDictionaryAsync(p => p.MothraId ?? "", p => p.PersonId, ct); + + foreach (var instructor in crestInstructors) + { + if (!personDetails.TryGetValue(instructor.MothraId, out var personId)) + { + context.Logger.LogWarning( + "Skipping CREST instructor {MothraId} ({LastName}, {FirstName}): no matching VIPER person record", + instructor.MothraId, instructor.LastName, instructor.FirstName); + continue; + } + + var titleDesc = context.TitleLookup.TryGetValue(instructor.TitleCode, out var desc) ? desc : instructor.TitleCode; + var dept = context.DeptSimpleNameLookup != null && context.DeptSimpleNameLookup.TryGetValue(instructor.HomeDept, out var deptName) + ? deptName + : instructor.HomeDept; + if (string.IsNullOrEmpty(dept)) dept = "UNK"; + + context.Preview.CrestInstructors.Add(new HarvestPersonPreview + { + MothraId = instructor.MothraId, + PersonId = personId, + FullName = $"{instructor.LastName}, {instructor.FirstName}", + FirstName = instructor.FirstName, + LastName = instructor.LastName, + Department = dept, + TitleCode = instructor.TitleCode, + TitleDescription = titleDesc, + Source = EffortConstants.SourceCrest + }); + } + + // Step 5: Build effort records + await BuildCrestEffortRecordsAsync(context, courseOfferings, blockCourseIds, ct); + + // Sort instructors by name + var sortedInstructors = context.Preview.CrestInstructors.OrderBy(i => i.FullName).ToList(); + context.Preview.CrestInstructors.Clear(); + context.Preview.CrestInstructors.AddRange(sortedInstructors); + } + + /// + public override async Task ExecuteAsync(HarvestContext context, CancellationToken ct = default) + { + // Import CREST instructors + context.Logger.LogInformation( + "Importing {Count} CREST instructors for term {TermCode}", + context.Preview.CrestInstructors.Count, + context.TermCode); + + foreach (var instructor in context.Preview.CrestInstructors) + { + await ImportPersonAsync(instructor, context, ct); + } + + // Import CREST courses + context.Logger.LogInformation( + "Importing {Count} CREST courses for term {TermCode}", + context.Preview.CrestCourses.Count, + context.TermCode); + + foreach (var course in context.Preview.CrestCourses) + { + var courseId = await ImportCourseAsync(course, context, ct); + var key = BuildCourseLookupKey(course); + context.CourseIdLookup[key] = courseId; + } + + // Import CREST effort records + context.Logger.LogInformation( + "Importing {Count} CREST effort records for term {TermCode}", + context.Preview.CrestEffort.Count, + context.TermCode); + + foreach (var effort in context.Preview.CrestEffort) + { + var result = await ImportEffortRecordAsync(effort, context, ct); + if (result.Record != null && result.Preview != null) + { + context.CreatedRecords.Add((result.Record, result.Preview)); + } + } + } + + private static async Task> GetCrestInstructorsAsync( + HarvestContext context, + CancellationToken ct) + { + var termCodeStr = context.TermCode.ToString(); + + // Step 1: Get unique candidate PIDs from CREST offerings + var courseOfferings = await context.CrestContext.CourseSessionOfferings + .AsNoTracking() + .Where(cso => cso.AcademicYear == termCodeStr) + .Select(cso => cso.EdutaskOfferId) + .Distinct() + .ToListAsync(ct); + + var offerPersons = await context.CrestContext.EdutaskOfferPersons + .AsNoTracking() + .Where(eop => courseOfferings.Contains(eop.EdutaskOfferId)) + .Select(eop => eop.PersonId) + .Distinct() + .ToListAsync(ct); + + var pidmStrings = offerPersons.Select(p => p.ToString()).ToList(); + + // Step 2: Get MothraIDs for these PIDs from AAUD + var idsRecords = await context.AaudContext.Ids + .AsNoTracking() + .Where(i => i.IdsTermCode == termCodeStr && i.IdsPidm != null && pidmStrings.Contains(i.IdsPidm)) + .Select(i => new { i.IdsMothraid, i.IdsPKey }) + .Distinct() + .ToListAsync(ct); + + var candidateMothraIds = idsRecords + .Where(i => !string.IsNullOrEmpty(i.IdsMothraid)) + .Select(i => i.IdsMothraid!) + .Distinct() + .ToList(); + + context.Logger.LogInformation( + "Found {Count} candidate MothraIDs from CREST for term {TermCode}", + candidateMothraIds.Count, context.TermCode); + + // Step 3: Get AAUD employee details + var aaudIds = await context.AaudContext.Ids + .AsNoTracking() + .Where(i => i.IdsTermCode == termCodeStr && i.IdsMothraid != null && candidateMothraIds.Contains(i.IdsMothraid)) + .Select(i => new { i.IdsMothraid, i.IdsPKey }) + .ToListAsync(ct); + + var pKeys = aaudIds.Where(i => !string.IsNullOrEmpty(i.IdsPKey)).Select(i => i.IdsPKey!).Distinct().ToList(); + + var employees = await context.AaudContext.Employees + .AsNoTracking() + .Where(e => e.EmpTermCode == termCodeStr && pKeys.Contains(e.EmpPKey)) + .Select(e => new { e.EmpPKey, e.EmpEffortTitleCode, e.EmpHomeDept }) + .ToListAsync(ct); + + var persons = await context.AaudContext.People + .AsNoTracking() + .Where(p => pKeys.Contains(p.PersonPKey)) + .Select(p => new { p.PersonPKey, p.PersonFirstName, p.PersonLastName }) + .ToListAsync(ct); + + // Get valid title codes + var titleCodes = employees + .Where(e => !string.IsNullOrEmpty(e.EmpEffortTitleCode)) + .Select(e => e.EmpEffortTitleCode!.Trim()) + .Distinct() + .ToList(); + + var validTitleCodes = await context.DictionaryContext.Titles + .AsNoTracking() + .Where(t => t.Code != null && titleCodes.Contains(t.Code)) + .Select(t => t.Code!) + .Distinct() + .ToListAsync(ct); + + var validTitleCodesSet = validTitleCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); + + context.DeptSimpleNameLookup ??= await context.InstructorService.GetDepartmentSimpleNameLookupAsync(ct); + + // Build lookups + var pKeyToEmployee = employees.ToDictionary(e => e.EmpPKey ?? "", e => e, StringComparer.OrdinalIgnoreCase); + var pKeyToPerson = persons.ToDictionary(p => p.PersonPKey ?? "", p => p, StringComparer.OrdinalIgnoreCase); + var mothraIdToPKey = aaudIds + .Where(i => !string.IsNullOrEmpty(i.IdsMothraid) && !string.IsNullOrEmpty(i.IdsPKey)) + .GroupBy(i => i.IdsMothraid!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Select(i => i.IdsPKey!).ToList(), StringComparer.OrdinalIgnoreCase); + + // Step 4: Build instructor details + var instructorDetails = new List(); + + foreach (var mothraId in candidateMothraIds) + { + if (!mothraIdToPKey.TryGetValue(mothraId, out var pKeyList) || pKeyList.Count == 0) + { + continue; + } + + var detailsForMothraId = new List(); + + foreach (var pKey in pKeyList) + { + if (!pKeyToEmployee.TryGetValue(pKey, out var emp) || + !pKeyToPerson.TryGetValue(pKey, out var person)) + { + continue; + } + + var titleCode = emp.EmpEffortTitleCode?.Trim() ?? ""; + + if (string.IsNullOrEmpty(titleCode) || !validTitleCodesSet.Contains(titleCode)) + { + continue; + } + + var homeDept = ResolveDeptSimpleName(emp.EmpHomeDept, context.DeptSimpleNameLookup) ?? ""; + + detailsForMothraId.Add(new CrestInstructorDto( + MothraId: mothraId, + FirstName: (person.PersonFirstName ?? "").ToUpperInvariant(), + LastName: (person.PersonLastName ?? "").ToUpperInvariant(), + TitleCode: titleCode, + HomeDept: homeDept)); + } + + // Only include if single record (legacy HAVING COUNT(*) = 1) + if (detailsForMothraId.Count == 1) + { + instructorDetails.Add(detailsForMothraId[0]); + } + } + + var results = instructorDetails + .OrderBy(i => i.LastName) + .ThenBy(i => i.FirstName) + .ToList(); + + context.Logger.LogInformation( + "EF query returned {Count} CREST instructors for term {TermCode}", + results.Count, context.TermCode); + + return results; + } + + private async Task BuildCrestEffortRecordsAsync( + HarvestContext context, + List courseOfferings, + HashSet blockCourseIds, + CancellationToken ct) + { + var termCodeStr = context.TermCode.ToString(); + + // Get offer persons and edutask persons + var offeringIds = courseOfferings.Select(c => c.EdutaskOfferId).Distinct().ToList(); + + var offerPersons = await context.CrestContext.EdutaskOfferPersons + .AsNoTracking() + .Where(eop => offeringIds.Contains(eop.EdutaskOfferId)) + .ToListAsync(ct); + + var edutaskPersons = await context.CrestContext.EdutaskPersons + .AsNoTracking() + .Where(ep => blockCourseIds.Contains(ep.EdutaskId)) + .ToListAsync(ct); + + var personRoleLookup = edutaskPersons + .GroupBy(ep => new { ep.EdutaskId, ep.PersonId }) + .ToDictionary( + g => (g.Key.EdutaskId, g.Key.PersonId), + g => g.FirstOrDefault(ep => ep.RoleCode == EffortConstants.CrestDirectorRoleCode)?.RoleCode); + + // Build PIDM to MothraId lookup + var pidms = offerPersons.Select(op => op.PersonId.ToString()).Distinct().ToList(); + var idsRecords = await context.AaudContext.Ids + .AsNoTracking() + .Where(i => i.IdsPidm != null && pidms.Contains(i.IdsPidm) && i.IdsTermCode == termCodeStr) + .Select(i => new { i.IdsPidm, i.IdsMothraid }) + .Distinct() + .ToListAsync(ct); + + var pidmToMothraId = idsRecords + .Where(i => !string.IsNullOrEmpty(i.IdsPidm) && !string.IsNullOrEmpty(i.IdsMothraid)) + .GroupBy(i => i.IdsPidm!) + .ToDictionary(g => g.Key, g => g.First().IdsMothraid ?? ""); + + // Build MothraId -> PersonName lookup + var allMothraIds = pidmToMothraId.Values.Distinct().ToList(); + var personNames = await context.AaudContext.Ids + .AsNoTracking() + .Where(i => i.IdsMothraid != null && allMothraIds.Contains(i.IdsMothraid) && i.IdsTermCode == termCodeStr) + .Join(context.AaudContext.People, + ids => ids.IdsPKey, + person => person.PersonPKey, + (ids, person) => new { ids.IdsMothraid, person.PersonFirstName, person.PersonLastName }) + .Distinct() + .ToListAsync(ct); + + var mothraIdToName = personNames + .Where(p => !string.IsNullOrEmpty(p.IdsMothraid)) + .GroupBy(p => p.IdsMothraid!) + .ToDictionary( + g => g.Key, + g => $"{g.First().PersonLastName?.ToUpper()}, {g.First().PersonFirstName?.ToUpper()}"); + + // Log time data availability + var offeringsWithTime = courseOfferings.Count(o => !string.IsNullOrEmpty(o.FromTime) && !string.IsNullOrEmpty(o.ThruTime)); + var offeringsWithDate = courseOfferings.Count(o => o.FromDate.HasValue && o.ThruDate.HasValue); + context.Logger.LogInformation( + "CREST offerings: {Total} total, {WithDate} with dates, {WithTime} with times", + courseOfferings.Count, offeringsWithDate, offeringsWithTime); + + // Build effort records + var effortByPersonCourse = new Dictionary(); + + var offeringAssignments = courseOfferings + .Join( + offerPersons, + offering => offering.EdutaskOfferId, + assignment => assignment.EdutaskOfferId, + (offering, assignment) => new { Offering = offering, Assignment = assignment }); + + foreach (var item in offeringAssignments) + { + var offering = item.Offering; + var assignment = item.Assignment; + + if (!pidmToMothraId.TryGetValue(assignment.PersonId.ToString(), out var mothraId) || string.IsNullOrEmpty(mothraId)) + { + continue; + } + + var instructor = context.Preview.CrestInstructors.FirstOrDefault(i => i.MothraId == mothraId); + var personName = instructor?.FullName ?? + (mothraIdToName.TryGetValue(mothraId, out var name) ? name : mothraId); + + var minutes = HarvestTimeParser.CalculateSessionMinutes( + offering.FromDate, offering.FromTime, + offering.ThruDate, offering.ThruTime, + context.Logger, $"offering {offering.EdutaskOfferId}"); + + var isDirector = personRoleLookup.TryGetValue((offering.CourseId, assignment.PersonId), out var roleCode) + && roleCode == EffortConstants.CrestDirectorRoleCode; + var roleId = isDirector ? EffortConstants.DirectorRoleId : EffortConstants.ClinicalInstructorRoleId; + + var subjCode = offering.SsaCourseNum?.Length >= 3 ? offering.SsaCourseNum[..3] : ""; + var crseNumb = offering.SsaCourseNum?.Length > 3 ? offering.SsaCourseNum[3..] : ""; + var courseCode = $"{subjCode} {crseNumb}"; + var effortKey = $"{mothraId}-{offering.Crn}-{courseCode}-{offering.SeqNumb}-{offering.SessionType}-{roleId}"; + + if (!effortByPersonCourse.TryGetValue(effortKey, out var existing)) + { + var record = new HarvestRecordPreview + { + MothraId = mothraId, + PersonName = personName, + Crn = offering.Crn ?? "", + CourseCode = courseCode, + EffortType = offering.SessionType ?? "VAR", + Hours = 0, + RoleId = roleId, + RoleName = isDirector ? "Director" : "Instructor", + Source = EffortConstants.SourceCrest + }; + effortByPersonCourse[effortKey] = (record, minutes); + } + else + { + effortByPersonCourse[effortKey] = (existing.Record, existing.TotalMinutes + minutes); + } + } + + // Convert minutes to hours + foreach (var (record, totalMinutes) in effortByPersonCourse.Values) + { + record.Hours = (int)Math.Round(totalMinutes / 60.0); + context.Preview.CrestEffort.Add(record); + } + + context.Logger.LogInformation( + "Extracted {Count} CREST effort records for term {TermCode}", + context.Preview.CrestEffort.Count, context.TermCode); + } +} diff --git a/web/Areas/Effort/Services/Harvest/GuestAccountPhase.cs b/web/Areas/Effort/Services/Harvest/GuestAccountPhase.cs new file mode 100644 index 00000000..0dd46ddf --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/GuestAccountPhase.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Classes.Utilities; + +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Phase 4: Guest Account import. +/// Imports department-level guest accounts (APCGUEST, PHRGUEST, etc.) for effort tracking. +/// +public sealed class GuestAccountPhase : HarvestPhaseBase +{ + /// + public override string PhaseName => "Guest Accounts"; + + /// + public override int Order => 40; + + /// + public override async Task GeneratePreviewAsync(HarvestContext context, CancellationToken ct = default) + { + // Look up guest account PersonIds from users.Person by MothraId + var guestPersons = await context.ViperContext.People + .AsNoTracking() + .Where(p => EffortConstants.GuestAccountIds.Contains(p.MothraId)) + .Select(p => new { p.MothraId, p.PersonId }) + .ToListAsync(ct); + + var personIdLookup = guestPersons.ToDictionary(p => p.MothraId, p => p.PersonId); + + foreach (var guestId in EffortConstants.GuestAccountIds) + { + if (!personIdLookup.TryGetValue(guestId, out var personId)) + { + context.Logger.LogWarning( + "Guest account {GuestId} not found in users.Person", + LogSanitizer.SanitizeId(guestId)); + continue; + } + + var dept = guestId[..3]; // First 3 chars are department code + + context.Preview.GuestAccounts.Add(new HarvestPersonPreview + { + MothraId = guestId, + PersonId = personId, + FullName = $"{dept}, GUEST", + FirstName = "GUEST", + LastName = dept, + Department = dept, + TitleCode = "", + Source = EffortConstants.SourceGuest + }); + } + } + + /// + public override async Task ExecuteAsync(HarvestContext context, CancellationToken ct = default) + { + context.Logger.LogInformation( + "Importing {Count} guest accounts for term {TermCode}", + context.Preview.GuestAccounts.Count, + context.TermCode); + + foreach (var guest in context.Preview.GuestAccounts) + { + await ImportPersonAsync(guest, context, ct); + } + } +} diff --git a/web/Areas/Effort/Services/Harvest/HarvestContext.cs b/web/Areas/Effort/Services/Harvest/HarvestContext.cs new file mode 100644 index 00000000..0d764c1f --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/HarvestContext.cs @@ -0,0 +1,199 @@ +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Models.Entities; +using Viper.Classes.SQLContext; + +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Shared state container for harvest operations. +/// Passed between harvest phases to share data and track progress. +/// +public sealed class HarvestContext +{ + /// + /// The term code being harvested. + /// + public int TermCode { get; init; } + + /// + /// PersonId of the user performing the harvest. + /// + public int ModifiedBy { get; init; } + + /// + /// Preview data accumulated across phases. + /// + public HarvestPreviewDto Preview { get; } = new(); + + /// + /// MothraIds that have been imported into effort.Persons. + /// Used to track which instructors are available for effort records. + /// + public HashSet ImportedMothraIds { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Lookup from course key to CourseId for imported courses. + /// Keys are either "CRN:{crn}" or "{SubjCode}{CrseNumb}-{Units}". + /// + public Dictionary CourseIdLookup { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Title code to description lookup from dictionary.dbo.dvtTitle. + /// + public Dictionary? TitleLookup { get; set; } + + /// + /// Department code to simple name lookup. + /// + public Dictionary? DeptSimpleNameLookup { get; set; } + + /// + /// Warnings accumulated during harvest. + /// + public List Warnings { get; } = []; + + /// + /// Records created during harvest, pending audit logging. + /// + public List<(EffortRecord Record, HarvestRecordPreview Preview)> CreatedRecords { get; } = []; + + #region Progress Tracking + + /// + /// Callback to report progress during execution. Set by orchestrator for live updates. + /// Parameters: (currentInstructors, totalInstructors, currentCourses, totalCourses, currentRecords, totalRecords, phaseName) + /// + public Func? ProgressCallback { get; set; } + + /// + /// Total instructors to import (set by orchestrator before execution). + /// + public int TotalInstructors { get; set; } + + /// + /// Total courses to import (set by orchestrator before execution). + /// + public int TotalCourses { get; set; } + + /// + /// Total records to import (set by orchestrator before execution). + /// + public int TotalRecords { get; set; } + + /// + /// Instructors imported so far across all phases. + /// + public int InstructorsImported { get; set; } + + /// + /// Courses imported so far across all phases. + /// + public int CoursesImported { get; set; } + + /// + /// Records imported so far across all phases. + /// + public int RecordsImported { get; set; } + + /// + /// Last reported total for throttling. + /// + private int _lastReportedTotal; + + /// + /// Report progress if callback is set. + /// Throttled to report every 10 items to avoid flooding the SSE channel. + /// + public async Task ReportProgressAsync(string phaseName) + { + if (ProgressCallback == null) return; + + var currentTotal = InstructorsImported + CoursesImported + RecordsImported; + + // Report every 10 items, or if this is the first item + if (currentTotal - _lastReportedTotal >= 10 || currentTotal == 1) + { + _lastReportedTotal = currentTotal; + await ProgressCallback( + InstructorsImported, TotalInstructors, + CoursesImported, TotalCourses, + RecordsImported, TotalRecords, + phaseName); + } + } + + /// + /// Force a progress report (used at end of phases). + /// + public async Task ForceReportProgressAsync(string phaseName) + { + if (ProgressCallback == null) return; + + _lastReportedTotal = InstructorsImported + CoursesImported + RecordsImported; + await ProgressCallback( + InstructorsImported, TotalInstructors, + CoursesImported, TotalCourses, + RecordsImported, TotalRecords, + phaseName); + } + + #endregion + + #region Database Contexts (passed through, not owned) + + /// + /// Effort database context. + /// + public required EffortDbContext EffortContext { get; init; } + + /// + /// CREST database context. + /// + public required CrestContext CrestContext { get; init; } + + /// + /// Courses database context. + /// + public required CoursesContext CoursesContext { get; init; } + + /// + /// VIPER database context. + /// + public required VIPERContext ViperContext { get; init; } + + /// + /// AAUD database context. + /// + public required AAUDContext AaudContext { get; init; } + + /// + /// Dictionary database context. + /// + public required DictionaryContext DictionaryContext { get; init; } + + #endregion + + #region Services + + /// + /// Audit service for logging harvest operations. + /// + public required IEffortAuditService AuditService { get; init; } + + /// + /// Instructor service for department resolution and title lookups. + /// + public required IInstructorService InstructorService { get; init; } + + /// + /// Term service for term name lookups. + /// + public required ITermService TermService { get; init; } + + /// + /// Logger for diagnostic messages. + /// + public required ILogger Logger { get; init; } + + #endregion +} diff --git a/web/Areas/Effort/Services/Harvest/HarvestPhaseBase.cs b/web/Areas/Effort/Services/Harvest/HarvestPhaseBase.cs new file mode 100644 index 00000000..fd9efe0b --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/HarvestPhaseBase.cs @@ -0,0 +1,301 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Models.Entities; +using Viper.Classes.Utilities; + +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Base class for harvest phases with shared helper methods. +/// +public abstract class HarvestPhaseBase : IHarvestPhase +{ + /// + public abstract string PhaseName { get; } + + /// + public abstract int Order { get; } + + /// + public virtual bool ShouldExecute(int termCode) => true; + + /// + public abstract Task GeneratePreviewAsync(HarvestContext context, CancellationToken ct = default); + + /// + public abstract Task ExecuteAsync(HarvestContext context, CancellationToken ct = default); + + #region Import Helpers + + /// + /// Import a person into effort.Persons. + /// De-duplicates by MothraId. + /// + protected async Task ImportPersonAsync( + HarvestPersonPreview person, + HarvestContext ctx, + CancellationToken ct) + { + // De-duplicate by MothraId + if (ctx.ImportedMothraIds.Contains(person.MothraId)) + { + return; + } + + // Check if person already exists in database + var exists = await ctx.EffortContext.Persons + .AsNoTracking() + .AnyAsync(p => p.TermCode == ctx.TermCode && p.PersonId == person.PersonId, ct); + + if (exists) + { + ctx.ImportedMothraIds.Add(person.MothraId); + return; + } + + var effortPerson = new EffortPerson + { + PersonId = person.PersonId, + TermCode = ctx.TermCode, + FirstName = person.FirstName.ToUpperInvariant(), + LastName = person.LastName.ToUpperInvariant(), + MiddleInitial = null, + EffortTitleCode = person.TitleCode, + EffortDept = person.Department, + PercentAdmin = 0, + JobGroupId = null, + Title = null, + AdminUnit = null + }; + + ctx.EffortContext.Persons.Add(effortPerson); + ctx.ImportedMothraIds.Add(person.MothraId); + + ctx.AuditService.AddHarvestPersonAudit( + person.PersonId, ctx.TermCode, person.FirstName, person.LastName, person.Department); + + // Update progress + ctx.InstructorsImported++; + await ctx.ReportProgressAsync(PhaseName); + } + + /// + /// Import a course into effort.Courses. + /// Returns the course ID (existing or newly created). + /// + protected async Task ImportCourseAsync( + HarvestCoursePreview course, + HarvestContext ctx, + CancellationToken ct) + { + // Check if course already exists + var existing = await ctx.EffortContext.Courses + .AsNoTracking() + .FirstOrDefaultAsync(c => c.TermCode == ctx.TermCode && + (string.IsNullOrWhiteSpace(course.Crn) + ? c.SubjCode == course.SubjCode && + c.CrseNumb == course.CrseNumb && + c.SeqNumb == course.SeqNumb && + c.Units == course.Units + : c.Crn == course.Crn), ct); + + if (existing != null) + { + return existing.Id; + } + + var effortCourse = new EffortCourse + { + Crn = course.Crn, + TermCode = ctx.TermCode, + SubjCode = course.SubjCode, + CrseNumb = course.CrseNumb, + SeqNumb = course.SeqNumb, + Enrollment = course.Enrollment, + Units = course.Units, + CustDept = course.CustDept + }; + + ctx.EffortContext.Courses.Add(effortCourse); + await ctx.EffortContext.SaveChangesAsync(ct); + + ctx.AuditService.AddHarvestCourseAudit( + effortCourse.Id, ctx.TermCode, course.SubjCode, course.CrseNumb, course.Crn); + + // Update progress + ctx.CoursesImported++; + await ctx.ReportProgressAsync(PhaseName); + + return effortCourse.Id; + } + + /// + /// Import an effort record into effort.Records. + /// Returns the created record and preview, or nulls if skipped. + /// + protected async Task<(EffortRecord? Record, HarvestRecordPreview? Preview)> ImportEffortRecordAsync( + HarvestRecordPreview effort, + HarvestContext ctx, + CancellationToken ct) + { + // Skip effort records for persons who weren't imported + if (!ctx.ImportedMothraIds.Contains(effort.MothraId)) + { + ctx.Logger.LogDebug( + "Skipping effort record for non-imported person: {MothraId}", + LogSanitizer.SanitizeId(effort.MothraId)); + return (null, null); + } + + // Skip effort records with neither Hours nor Weeks + if (effort.Hours.GetValueOrDefault() == 0 && effort.Weeks.GetValueOrDefault() == 0) + { + ctx.Logger.LogDebug( + "Skipping effort record with no hours or weeks: {MothraId} {CourseCode}", + LogSanitizer.SanitizeId(effort.MothraId), + LogSanitizer.SanitizeId(effort.CourseCode)); + return (null, null); + } + + // Find the course ID + int courseId; + if (!string.IsNullOrWhiteSpace(effort.Crn) && ctx.CourseIdLookup.TryGetValue($"CRN:{effort.Crn}", out courseId)) + { + // Exact CRN match found + } + else + { + // Fall back to course code prefix match + var courseKey = effort.CourseCode.Replace(" ", "") + "-"; + var matchingKeys = ctx.CourseIdLookup.Keys + .Where(k => !k.StartsWith("CRN:") && k.StartsWith(courseKey)) + .ToList(); + + if (matchingKeys.Count == 0) + { + ctx.Logger.LogWarning( + "Course not found for effort record: {CourseCode}", + LogSanitizer.SanitizeId(effort.CourseCode)); + return (null, null); + } + + if (matchingKeys.Count > 1) + { + ctx.Logger.LogWarning( + "Multiple courses match effort record {CourseCode}: {MatchingKeys}. Skipping ambiguous match.", + LogSanitizer.SanitizeId(effort.CourseCode), + LogSanitizer.SanitizeString(string.Join(", ", matchingKeys))); + return (null, null); + } + + courseId = ctx.CourseIdLookup[matchingKeys[0]]; + } + + // Find the person ID + var person = await ctx.ViperContext.People + .AsNoTracking() + .FirstOrDefaultAsync(p => p.MothraId == effort.MothraId, ct); + + if (person == null) + { + ctx.Logger.LogWarning( + "Person not found for effort record: {MothraId}", + LogSanitizer.SanitizeId(effort.MothraId)); + return (null, null); + } + + var effortRecord = new EffortRecord + { + CourseId = courseId, + PersonId = person.PersonId, + TermCode = ctx.TermCode, + EffortTypeId = effort.EffortType, + RoleId = effort.RoleId, + Hours = effort.Hours, + Weeks = effort.Weeks, + Crn = effort.Crn, + ModifiedDate = DateTime.Now, + ModifiedBy = ctx.ModifiedBy + }; + + ctx.EffortContext.Records.Add(effortRecord); + + // Update progress + ctx.RecordsImported++; + await ctx.ReportProgressAsync(PhaseName); + + return (effortRecord, effort); + } + + #endregion + + #region Key Building Helpers + + /// + /// Builds a unique key for a course preview, used for deduplication and tracking. + /// + protected static string BuildCourseKey(HarvestCoursePreview course) + { + return string.IsNullOrWhiteSpace(course.Crn) + ? $"{course.SubjCode}-{course.CrseNumb}-{course.SeqNumb}-{course.Units}" + : $"CRN:{course.Crn}-{course.Units}"; + } + + /// + /// Builds a course lookup key for the CourseIdLookup dictionary. + /// + protected static string BuildCourseLookupKey(HarvestCoursePreview course) + { + return string.IsNullOrWhiteSpace(course.Crn) + ? $"{course.SubjCode}{course.CrseNumb}-{course.Units}" + : $"CRN:{course.Crn}"; + } + + #endregion + + #region Term Helpers + + /// + /// Check if a term code represents a semester term (not a quarter term). + /// Clinical scheduler data is only imported for semester terms. + /// + protected static bool IsSemesterTerm(int termCode) + { + // Term code format: YYYYTT where TT is the term type + // Semester terms: 2 (Spring), 4 (Summer), 9 (Fall) + var termType = termCode % 100; + return termType == 2 || termType == 4 || termType == 9; + } + + #endregion + + #region Department Resolution + + /// + /// Resolve department code to simple name using the lookup dictionary. + /// + protected static string? ResolveDeptSimpleName(string? deptCode, Dictionary? lookup) + { + if (string.IsNullOrWhiteSpace(deptCode) || lookup == null) + { + return null; + } + + var trimmed = deptCode.Trim(); + return lookup.TryGetValue(trimmed, out var simpleName) ? simpleName : null; + } + + /// + /// Resolve the effort department for an instructor using the instructor service. + /// + protected static async Task ResolveDepartmentAsync( + int personId, + HarvestContext ctx, + CancellationToken ct) + { + var dept = await ctx.InstructorService.ResolveInstructorDepartmentAsync(personId, ctx.TermCode, ct); + return dept ?? "UNK"; + } + + #endregion +} diff --git a/web/Areas/Effort/Services/Harvest/HarvestTimeParser.cs b/web/Areas/Effort/Services/Harvest/HarvestTimeParser.cs new file mode 100644 index 00000000..fd5ceae4 --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/HarvestTimeParser.cs @@ -0,0 +1,121 @@ +using System.Globalization; + +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Utility for parsing time strings from CREST data. +/// Handles various time formats: "8:00 AM", "1:30 PM", "1430", "900", "2400", "0000". +/// +public static class HarvestTimeParser +{ + /// + /// Parse time string into TimeSpan. + /// Handles formats: "8:00 AM", "1:30 PM", "1430", "900", "2400", "0000". + /// + /// The time string to parse. + /// Optional logger for warning/debug messages. + /// Optional context string for log messages (e.g., offering ID). + /// TimeSpan if parsing succeeded, null otherwise. + public static TimeSpan? ParseTimeString(string? timeStr, ILogger? logger = null, string? context = null) + { + if (string.IsNullOrWhiteSpace(timeStr)) + { + return null; + } + + var trimmed = timeStr.Trim(); + var isDigitsOnly = trimmed.All(char.IsDigit); + + // Try parsing as DateTime (handles "8:00 AM", "1:30 PM" formats) + // Skip for digits-only input to avoid DateTime.TryParse interpreting "1430" as year 1430 + if (!isDigitsOnly && DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, out var dateTime)) + { + return dateTime.TimeOfDay; + } + + // Fallback: Try parsing as military time (e.g., "1430", "900", "2400", "0000") + var digitsOnly = new string(trimmed.Where(char.IsDigit).ToArray()); + if (digitsOnly.Length >= 3 && digitsOnly.Length <= 4) + { + var paddedTime = digitsOnly.PadLeft(4, '0'); + if (int.TryParse(paddedTime[..2], out var hour) && + int.TryParse(paddedTime[2..4], out var minute)) + { + // Handle "2400" as end of day (24:00 = next day 00:00) + if (hour == 24 && minute == 0) + { + return new TimeSpan(24, 0, 0); + } + + // Standard time validation + if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) + { + return new TimeSpan(hour, minute, 0); + } + } + } + + logger?.LogWarning( + "Failed to parse time string '{TimeStr}' {Context}", + timeStr, + string.IsNullOrEmpty(context) ? "" : $"for {context}"); + + return null; + } + + /// + /// Calculate session duration in minutes from CREST date/time values. + /// FromDate/ThruDate are DateTime, FromTime/ThruTime are strings (e.g., "1430" for 14:30). + /// + /// Session start date. + /// Session start time string. + /// Session end date. + /// Session end time string. + /// Optional logger for warnings. + /// Optional session ID for log context. + /// Duration in minutes, or 0 if times cannot be calculated. + public static int CalculateSessionMinutes( + DateTime? fromDate, + string? fromTime, + DateTime? thruDate, + string? thruTime, + ILogger? logger = null, + string? sessionId = null) + { + if (fromDate == null || string.IsNullOrEmpty(fromTime) || + thruDate == null || string.IsNullOrEmpty(thruTime)) + { + return 0; + } + + var startTime = ParseTimeString(fromTime, logger, sessionId); + var endTime = ParseTimeString(thruTime, logger, sessionId); + + if (startTime == null || endTime == null) + { + return 0; + } + + // Combine date and time components + var startDateTime = fromDate.Value.Date.Add(startTime.Value); + var endDateTime = thruDate.Value.Date.Add(endTime.Value); + + // Handle 24:00 (end of day) - treat as next day 00:00 + if (endTime.Value.Hours == 24) + { + endDateTime = thruDate.Value.Date.AddDays(1); + } + + if (endDateTime <= startDateTime) + { + logger?.LogWarning( + "Negative or zero duration calculated: {Start} to {End} {Context}", + startDateTime, + endDateTime, + string.IsNullOrEmpty(sessionId) ? "" : $"for {sessionId}"); + return 0; + } + + return (int)(endDateTime - startDateTime).TotalMinutes; + } +} diff --git a/web/Areas/Effort/Services/Harvest/IHarvestPhase.cs b/web/Areas/Effort/Services/Harvest/IHarvestPhase.cs new file mode 100644 index 00000000..fd685a64 --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/IHarvestPhase.cs @@ -0,0 +1,42 @@ +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Interface for harvest phase implementations. +/// Each phase handles a specific data source (CREST, Non-CREST, Clinical, Guest). +/// +public interface IHarvestPhase +{ + /// + /// Display name of this phase for logging and UI. + /// + string PhaseName { get; } + + /// + /// Execution order (lower numbers execute first). + /// + int Order { get; } + + /// + /// Determine if this phase should execute for the given term. + /// For example, Clinical phase only runs for semester terms. + /// + /// The term code being harvested. + /// True if this phase should execute. + bool ShouldExecute(int termCode); + + /// + /// Generate preview data for this phase without saving. + /// Populates the context's Preview with instructors, courses, and effort records. + /// + /// Shared harvest context. + /// Cancellation token. + Task GeneratePreviewAsync(HarvestContext context, CancellationToken ct = default); + + /// + /// Execute the import for this phase. + /// Imports instructors, courses, and effort records to the database. + /// + /// Shared harvest context. + /// Cancellation token. + Task ExecuteAsync(HarvestContext context, CancellationToken ct = default); +} diff --git a/web/Areas/Effort/Services/Harvest/NonCrestHarvestPhase.cs b/web/Areas/Effort/Services/Harvest/NonCrestHarvestPhase.cs new file mode 100644 index 00000000..42e1ca5a --- /dev/null +++ b/web/Areas/Effort/Services/Harvest/NonCrestHarvestPhase.cs @@ -0,0 +1,331 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Responses; + +namespace Viper.Areas.Effort.Services.Harvest; + +/// +/// Phase 2: Non-CREST course import. +/// Imports courses from Banner (courses.dbo.baseinfo) that are not in CREST. +/// +public sealed class NonCrestHarvestPhase : HarvestPhaseBase +{ + /// + public override string PhaseName => "Non-CREST"; + + /// + public override int Order => 20; + + /// + public override async Task GeneratePreviewAsync(HarvestContext context, CancellationToken ct = default) + { + var termCodeStr = context.TermCode.ToString(); + + // Get CRNs already in CREST (from Phase 1) + var existingCrns = context.Preview.CrestCourses.Select(c => c.Crn).ToHashSet(); + + // Get courses with enrollment from roster (excluding DVM courses which are clinical) + var coursesWithEnrollment = await context.CoursesContext.Rosters + .AsNoTracking() + .Where(r => r.RosterTermCode == termCodeStr && r.RosterUnit > 0 && r.RosterCrn != null) + .Join( + context.CoursesContext.Baseinfos.AsNoTracking(), + r => new { TermCode = r.RosterTermCode!, Crn = r.RosterCrn! }, + b => new { TermCode = b.BaseinfoTermCode, Crn = b.BaseinfoCrn }, + (r, b) => new { Roster = r, Baseinfo = b }) + .Where(x => x.Baseinfo.BaseinfoSubjCode != "DVM") + .GroupBy(x => new + { + x.Baseinfo.BaseinfoTermCode, + x.Baseinfo.BaseinfoCrn, + x.Roster.RosterUnit, + x.Baseinfo.BaseinfoSubjCode, + x.Baseinfo.BaseinfoCrseNumb, + x.Baseinfo.BaseinfoSeqNumb, + x.Baseinfo.BaseinfoDeptCode + }) + .Select(g => new + { + Crn = g.Key.BaseinfoCrn ?? "", + SubjCode = g.Key.BaseinfoSubjCode ?? "", + CrseNumb = g.Key.BaseinfoCrseNumb ?? "", + SeqNumb = g.Key.BaseinfoSeqNumb ?? "001", + Units = g.Key.RosterUnit ?? 0, + Enrollment = g.Count(), + DeptCode = g.Key.BaseinfoDeptCode ?? "" + }) + .ToListAsync(ct); + + // Get research courses (course number ending in 'R') - these may have 0 enrollment + var researchCourses = await context.CoursesContext.Baseinfos + .AsNoTracking() + .Where(b => b.BaseinfoTermCode == termCodeStr && + b.BaseinfoCrseNumb != null && EF.Functions.Like(b.BaseinfoCrseNumb, "%R")) + .Select(b => new + { + Crn = b.BaseinfoCrn, + SubjCode = b.BaseinfoSubjCode, + CrseNumb = b.BaseinfoCrseNumb, + SeqNumb = b.BaseinfoSeqNumb, + Units = (double)b.BaseinfoUnitLow, + Enrollment = 0, + DeptCode = b.BaseinfoDeptCode + }) + .ToListAsync(ct); + + // Combine courses and separate into those to import vs those already in CREST + var allScheduleCourses = coursesWithEnrollment + .Concat(researchCourses) + .DistinctBy(c => new { c.Crn, c.Units }) + .ToList(); + + // Add courses not in CREST (these will be imported) + var allNonCrestCourses = allScheduleCourses + .Where(c => !existingCrns.Contains(c.Crn)) + .ToList(); + + foreach (var course in allNonCrestCourses) + { + context.Preview.NonCrestCourses.Add(new HarvestCoursePreview + { + Crn = course.Crn, + SubjCode = course.SubjCode, + CrseNumb = course.CrseNumb, + SeqNumb = course.SeqNumb, + Enrollment = course.Enrollment, + Units = (decimal)course.Units, + CustDept = course.DeptCode, + Source = EffortConstants.SourceNonCrest + }); + } + + // Add courses that ARE in CREST (for transparency) + var crestDuplicates = allScheduleCourses + .Where(c => existingCrns.Contains(c.Crn)) + .ToList(); + + foreach (var course in crestDuplicates) + { + context.Preview.NonCrestCourses.Add(new HarvestCoursePreview + { + Crn = course.Crn, + SubjCode = course.SubjCode, + CrseNumb = course.CrseNumb, + SeqNumb = course.SeqNumb, + Enrollment = course.Enrollment, + Units = (decimal)course.Units, + CustDept = course.DeptCode, + Source = EffortConstants.SourceInCrest + }); + } + + // Get instructors and effort records + var courseData = allNonCrestCourses + .Select(c => new NonCrestCourseData(c.Crn, c.SubjCode, c.CrseNumb)) + .ToList(); + await BuildNonCrestInstructorsAndEffortAsync(context, courseData, ct); + } + + /// + /// Data transfer record for Non-CREST course information. + /// + private sealed record NonCrestCourseData(string Crn, string SubjCode, string CrseNumb); + + /// + public override async Task ExecuteAsync(HarvestContext context, CancellationToken ct = default) + { + // Import Non-CREST courses (excluding "In CREST" courses) + var coursesToImport = context.Preview.NonCrestCourses + .Where(c => c.Source != EffortConstants.SourceInCrest) + .ToList(); + + context.Logger.LogInformation( + "Importing {Count} Non-CREST courses for term {TermCode}", + coursesToImport.Count, + context.TermCode); + + foreach (var course in coursesToImport) + { + var courseId = await ImportCourseAsync(course, context, ct); + var key = BuildCourseLookupKey(course); + context.CourseIdLookup[key] = courseId; + } + + // Import Non-CREST instructors + context.Logger.LogInformation( + "Importing {Count} Non-CREST instructors for term {TermCode}", + context.Preview.NonCrestInstructors.Count, + context.TermCode); + + foreach (var instructor in context.Preview.NonCrestInstructors) + { + await ImportPersonAsync(instructor, context, ct); + } + + // Import Non-CREST effort records + context.Logger.LogInformation( + "Importing {Count} Non-CREST effort records for term {TermCode}", + context.Preview.NonCrestEffort.Count, + context.TermCode); + + foreach (var effort in context.Preview.NonCrestEffort) + { + var result = await ImportEffortRecordAsync(effort, context, ct); + if (result.Record != null && result.Preview != null) + { + context.CreatedRecords.Add((result.Record, result.Preview)); + } + } + } + + private async Task BuildNonCrestInstructorsAndEffortAsync( + HarvestContext context, + List allNonCrestCourses, + CancellationToken ct) + { + var termCodeStr = context.TermCode.ToString(); + var nonCrestCrns = allNonCrestCourses + .Select(c => c.Crn) + .Distinct() + .ToList(); + + // Get POA entries for these courses + var poaEntries = await context.CoursesContext.Poas + .AsNoTracking() + .Where(p => p.PoaTermCode == termCodeStr && nonCrestCrns.Contains(p.PoaCrn)) + .Join( + context.CoursesContext.VwPoaPidmNames.AsNoTracking(), + p => p.PoaPidm, + v => v.IdsPidm, + (p, v) => new { p.PoaCrn, p.PoaPidm, v.PersonClientid }) + .Distinct() + .ToListAsync(ct); + + // Get person info from VIPER + var clientIds = poaEntries.Select(p => p.PersonClientid).Distinct().ToList(); + var instructorDetails = await context.ViperContext.People + .AsNoTracking() + .Where(p => clientIds.Contains(p.ClientId)) + .Select(p => new { p.PersonId, p.MothraId, p.FirstName, p.LastName, p.ClientId }) + .ToListAsync(ct); + + var clientIdToInstructor = instructorDetails.ToDictionary(i => i.ClientId, i => i); + + // Get AAUD employee data + var nonCrestPidms = poaEntries.Select(p => p.PoaPidm.ToString()).Distinct().ToList(); + var nonCrestIdsRecords = await context.AaudContext.Ids + .AsNoTracking() + .Where(i => i.IdsPidm != null && nonCrestPidms.Contains(i.IdsPidm) && i.IdsTermCode == termCodeStr) + .Select(i => new { i.IdsPidm, i.IdsMothraid, i.IdsPKey }) + .Distinct() + .ToListAsync(ct); + + var nonCrestPidmToPKey = nonCrestIdsRecords + .Where(i => !string.IsNullOrEmpty(i.IdsPidm) && !string.IsNullOrEmpty(i.IdsPKey)) + .GroupBy(i => i.IdsPidm!) + .ToDictionary(g => g.Key, g => g.First().IdsPKey ?? ""); + + var nonCrestPidmToMothraId = nonCrestIdsRecords + .Where(i => !string.IsNullOrEmpty(i.IdsPidm) && !string.IsNullOrEmpty(i.IdsMothraid)) + .GroupBy(i => i.IdsPidm!) + .ToDictionary(g => g.Key, g => g.First().IdsMothraid ?? ""); + + var nonCrestPKeys = nonCrestPidmToPKey.Values.Where(pk => !string.IsNullOrEmpty(pk)).Distinct().ToList(); + var nonCrestEmployees = await context.AaudContext.Employees + .AsNoTracking() + .Where(e => e.EmpTermCode == termCodeStr && nonCrestPKeys.Contains(e.EmpPKey)) + .ToDictionaryAsync(e => e.EmpPKey ?? "", e => e, ct); + + var nonCrestPersons = await context.AaudContext.People + .AsNoTracking() + .Where(p => nonCrestPKeys.Contains(p.PersonPKey)) + .ToDictionaryAsync(p => p.PersonPKey ?? "", p => p, ct); + + // Get lookups + context.TitleLookup ??= (await context.InstructorService.GetTitleCodesAsync(ct)) + .ToDictionary(t => t.Code, t => t.Name, StringComparer.OrdinalIgnoreCase); + + context.DeptSimpleNameLookup ??= await context.InstructorService.GetDepartmentSimpleNameLookupAsync(ct); + + // Build course lookup for effort records + var courseLookup = allNonCrestCourses + .ToDictionary(c => c.Crn, c => c); + + // Create instructor previews and effort records + foreach (var poa in poaEntries) + { + var pidmStr = poa.PoaPidm.ToString(); + + if (!courseLookup.TryGetValue(poa.PoaCrn, out var course)) continue; + + if (!nonCrestPidmToPKey.TryGetValue(pidmStr, out var pKey) || string.IsNullOrEmpty(pKey)) continue; + if (!nonCrestEmployees.TryGetValue(pKey, out var emp)) continue; + + var titleCode = emp.EmpEffortTitleCode?.Trim() ?? ""; + if (string.IsNullOrEmpty(titleCode)) continue; + + if (!nonCrestPidmToMothraId.TryGetValue(pidmStr, out var mothraId) || string.IsNullOrEmpty(mothraId)) continue; + if (!nonCrestPersons.TryGetValue(pKey, out var aaudPerson)) continue; + + var firstName = aaudPerson.PersonFirstName?.Trim() ?? ""; + var lastName = aaudPerson.PersonLastName?.Trim() ?? ""; + var fullName = $"{lastName}, {firstName}"; + + var rawDeptCode = emp.EmpHomeDept?.Trim() ?? ""; + var dept = context.DeptSimpleNameLookup != null && context.DeptSimpleNameLookup.TryGetValue(rawDeptCode, out var deptName) + ? deptName + : rawDeptCode; + if (string.IsNullOrEmpty(dept)) dept = "UNK"; + + var titleDesc = context.TitleLookup.TryGetValue(titleCode, out var desc) ? desc : titleCode; + + // Add instructor if not already added - skip if no valid VIPER person record + if (!context.Preview.NonCrestInstructors.Any(i => i.MothraId == mothraId)) + { + if (poa.PersonClientid == null || !clientIdToInstructor.TryGetValue(poa.PersonClientid, out var viperPerson)) + { + context.Logger.LogWarning( + "Skipping Non-CREST instructor {MothraId} ({FullName}): no matching VIPER person record", + mothraId, fullName); + continue; + } + + context.Preview.NonCrestInstructors.Add(new HarvestPersonPreview + { + MothraId = mothraId, + PersonId = viperPerson.PersonId, + FullName = fullName, + FirstName = firstName, + LastName = lastName, + Department = dept, + TitleCode = titleCode, + TitleDescription = titleDesc, + Source = EffortConstants.SourceNonCrest + }); + } + + // Determine effort type + var isResearchCourse = course.CrseNumb?.EndsWith('R') ?? false; + var effortType = isResearchCourse ? EffortConstants.ResearchEffortType : EffortConstants.VariableEffortType; + + context.Preview.NonCrestEffort.Add(new HarvestRecordPreview + { + MothraId = mothraId, + PersonName = fullName, + Crn = poa.PoaCrn, + CourseCode = $"{course.SubjCode} {course.CrseNumb}", + EffortType = effortType, + RoleId = EffortConstants.DirectorRoleId, + RoleName = "Director", + Hours = 0, + Weeks = 0, + Source = EffortConstants.SourceNonCrest + }); + } + + // Sort instructors + var sortedInstructors = context.Preview.NonCrestInstructors.OrderBy(i => i.FullName).ToList(); + context.Preview.NonCrestInstructors.Clear(); + context.Preview.NonCrestInstructors.AddRange(sortedInstructors); + } +} diff --git a/web/Areas/Effort/Services/HarvestService.cs b/web/Areas/Effort/Services/HarvestService.cs new file mode 100644 index 00000000..b46318ab --- /dev/null +++ b/web/Areas/Effort/Services/HarvestService.cs @@ -0,0 +1,572 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Services.Harvest; +using Viper.Classes.SQLContext; + +namespace Viper.Areas.Effort.Services; + +/// +/// Orchestrator service for harvesting instructor and course data into the effort system. +/// Coordinates execution of harvest phases (CREST, Non-CREST, Clinical, Guest). +/// +public class HarvestService : IHarvestService +{ + private readonly IEnumerable _phases; + private readonly EffortDbContext _context; + private readonly VIPERContext _viperContext; + private readonly CoursesContext _coursesContext; + private readonly CrestContext _crestContext; + private readonly AAUDContext _aaudContext; + private readonly DictionaryContext _dictionaryContext; + private readonly IEffortAuditService _auditService; + private readonly ITermService _termService; + private readonly IInstructorService _instructorService; + private readonly ILogger _logger; + + public HarvestService( + IEnumerable phases, + EffortDbContext context, + VIPERContext viperContext, + CoursesContext coursesContext, + CrestContext crestContext, + AAUDContext aaudContext, + DictionaryContext dictionaryContext, + IEffortAuditService auditService, + ITermService termService, + IInstructorService instructorService, + ILogger logger) + { + _phases = phases; + _context = context; + _viperContext = viperContext; + _coursesContext = coursesContext; + _crestContext = crestContext; + _aaudContext = aaudContext; + _dictionaryContext = dictionaryContext; + _auditService = auditService; + _termService = termService; + _instructorService = instructorService; + _logger = logger; + } + + public async Task GeneratePreviewAsync(int termCode, CancellationToken ct = default) + { + var harvestContext = CreateHarvestContext(termCode, modifiedBy: 0); + + harvestContext.Preview.TermCode = termCode; + harvestContext.Preview.TermName = _termService.GetTermName(termCode); + + // Run all phases in order + foreach (var phase in _phases.Where(p => p.ShouldExecute(termCode)).OrderBy(p => p.Order)) + { + await phase.GeneratePreviewAsync(harvestContext, ct); + } + + // Calculate summary + CalculateSummary(harvestContext); + + // Detect existing and removed items + await DetectExistingAndRemovedItemsAsync(harvestContext, ct); + + return harvestContext.Preview; + } + + public async Task ExecuteHarvestAsync(int termCode, int modifiedBy, CancellationToken ct = default) + { + var result = new HarvestResultDto { TermCode = termCode }; + + // Verify term exists + var term = await _context.Terms.AsNoTracking().FirstOrDefaultAsync(t => t.TermCode == termCode, ct); + if (term == null) + { + result.Success = false; + result.ErrorMessage = $"Term {termCode} not found."; + return result; + } + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + try + { + var harvestContext = CreateHarvestContext(termCode, modifiedBy); + + // Phase 0: Clear existing data + _logger.LogInformation("Clearing existing data for term {TermCode}", termCode); + await ClearExistingDataAsync(termCode, ct); + + // Generate preview data and execute each phase + foreach (var phase in _phases.Where(p => p.ShouldExecute(termCode)).OrderBy(p => p.Order)) + { + await phase.GeneratePreviewAsync(harvestContext, ct); + } + + // Validate data before import + var validationError = ValidatePreviewData(harvestContext); + if (validationError != null) + { + result.Success = false; + result.ErrorMessage = validationError; + await transaction.RollbackAsync(ct); + return result; + } + + // Execute phases in order + foreach (var phase in _phases.Where(p => p.ShouldExecute(termCode)).OrderBy(p => p.Order)) + { + await phase.ExecuteAsync(harvestContext, ct); + await _context.SaveChangesAsync(ct); + + // Write audit entries for created records + foreach (var (record, preview) in harvestContext.CreatedRecords) + { + _auditService.AddHarvestRecordAudit( + record.Id, termCode, preview.MothraId, + preview.CourseCode, preview.EffortType, preview.Hours, preview.Weeks); + } + harvestContext.CreatedRecords.Clear(); + } + + // Update term status + await UpdateTermStatusAsync(termCode, modifiedBy, ct); + + // Add import audit + var summaryText = BuildSummaryText(harvestContext); + _auditService.AddImportAudit(termCode, EffortAuditActions.ImportEffort, summaryText); + + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + // Calculate summary for result + CalculateSummary(harvestContext); + + result.Success = true; + result.HarvestedDate = DateTime.Now; + result.Summary = harvestContext.Preview.Summary; + result.Warnings = harvestContext.Warnings; + + _logger.LogInformation("Harvest completed for term {TermCode}: {Summary}", termCode, summaryText); + } + catch (DbUpdateException ex) + { + await transaction.RollbackAsync(ct); + _logger.LogError(ex, "Database error during harvest for term {TermCode}", termCode); + result.Success = false; + result.ErrorMessage = "Database error during harvest. Please try again."; + } + catch (OperationCanceledException) + { + await transaction.RollbackAsync(ct); + throw; + } + catch (InvalidOperationException ex) + { + await transaction.RollbackAsync(ct); + _logger.LogError(ex, "Operation error during harvest for term {TermCode}", termCode); + result.Success = false; + result.ErrorMessage = "Operation error during harvest. Please contact support."; + } + catch (ArgumentNullException ex) + { + await transaction.RollbackAsync(ct); + _logger.LogError(ex, "Null argument error during harvest for term {TermCode}", termCode); + result.Success = false; + result.ErrorMessage = "Harvest failed due to invalid input. Please contact support."; + } + + return result; + } + + public async Task ExecuteHarvestWithProgressAsync( + int termCode, + int modifiedBy, + System.Threading.Channels.ChannelWriter progressChannel, + CancellationToken ct = default) + { + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + try + { + var harvestContext = CreateHarvestContext(termCode, modifiedBy); + + // Phase 0: Clear existing data (5% of progress) + await progressChannel.WriteAsync(HarvestProgressEvent.Clearing(), ct); + _logger.LogInformation("Clearing existing data for term {TermCode}", termCode); + await ClearExistingDataAsync(termCode, ct); + + // Generate preview data (5-10% of progress) + await progressChannel.WriteAsync(new HarvestProgressEvent + { + Phase = "preview", + Progress = 0.08, + Message = "Analyzing data sources..." + }, ct); + + foreach (var phase in _phases.Where(p => p.ShouldExecute(termCode)).OrderBy(p => p.Order)) + { + await phase.GeneratePreviewAsync(harvestContext, ct); + } + + // Validate + var validationError = ValidatePreviewData(harvestContext); + if (validationError != null) + { + await transaction.RollbackAsync(ct); + await progressChannel.WriteAsync(HarvestProgressEvent.Failed(validationError), ct); + return; + } + + // Calculate totals for progress reporting (deduplicate instructors by MothraId) + var totalInstructors = GetAllInstructors(harvestContext.Preview) + .DistinctBy(p => p.MothraId) + .Count(); + var totalCourses = harvestContext.Preview.CrestCourses.Count + + harvestContext.Preview.NonCrestCourses.Count(c => c.Source != EffortConstants.SourceInCrest) + + harvestContext.Preview.ClinicalCourses.Count; + var totalRecords = harvestContext.Preview.CrestEffort.Count + + harvestContext.Preview.NonCrestEffort.Count + + harvestContext.Preview.ClinicalEffort.Count; + + // Set up progress tracking in context + harvestContext.TotalInstructors = totalInstructors; + harvestContext.TotalCourses = totalCourses; + harvestContext.TotalRecords = totalRecords; + + // Set up progress callback for live updates during phase execution + harvestContext.ProgressCallback = async (currentInst, totalInst, currentCourses, totalCrs, currentRecs, totalRecs, phaseName) => + { + // Calculate overall progress: 10% to 90% based on total items imported + var totalItems = totalInst + totalCrs + totalRecs; + var currentItems = currentInst + currentCourses + currentRecs; + var itemProgress = totalItems > 0 ? (double)currentItems / totalItems : 0; + var overallProgress = 0.10 + (0.80 * itemProgress); + + await progressChannel.WriteAsync(new HarvestProgressEvent + { + Phase = phaseName.ToLowerInvariant(), + Progress = overallProgress, + Message = $"Importing {phaseName} data...", + Detail = $"{currentInst}/{totalInst} instructors, {currentCourses}/{totalCrs} courses, {currentRecs}/{totalRecs} records" + }, ct); + }; + + // Execute phases + foreach (var phase in _phases.Where(p => p.ShouldExecute(termCode)).OrderBy(p => p.Order)) + { + await phase.ExecuteAsync(harvestContext, ct); + await _context.SaveChangesAsync(ct); + + // Force final progress report for this phase + await harvestContext.ForceReportProgressAsync(phase.PhaseName); + + // Write audit entries + foreach (var (record, preview) in harvestContext.CreatedRecords) + { + _auditService.AddHarvestRecordAudit( + record.Id, termCode, preview.MothraId, + preview.CourseCode, preview.EffortType, preview.Hours, preview.Weeks); + } + harvestContext.CreatedRecords.Clear(); + } + + // Finalize (95% to 100%) + await progressChannel.WriteAsync(HarvestProgressEvent.Finalizing(), ct); + await UpdateTermStatusAsync(termCode, modifiedBy, ct); + + var summaryText = BuildSummaryText(harvestContext); + _auditService.AddImportAudit(termCode, EffortAuditActions.ImportEffort, summaryText); + + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + CalculateSummary(harvestContext); + + var result = new HarvestResultDto + { + TermCode = termCode, + Success = true, + HarvestedDate = DateTime.Now, + Summary = harvestContext.Preview.Summary, + Warnings = harvestContext.Warnings + }; + + _logger.LogInformation("Harvest completed for term {TermCode}: {Summary}", termCode, summaryText); + await progressChannel.WriteAsync(HarvestProgressEvent.Complete(result), ct); + } + catch (DbUpdateException ex) + { + await transaction.RollbackAsync(ct); + _logger.LogError(ex, "Database error during harvest for term {TermCode}", termCode); + await progressChannel.WriteAsync(HarvestProgressEvent.Failed("Database error during harvest. Please try again."), ct); + } + catch (OperationCanceledException) + { + await transaction.RollbackAsync(ct); + throw; + } + catch (InvalidOperationException ex) + { + await transaction.RollbackAsync(ct); + _logger.LogError(ex, "Operation error during harvest for term {TermCode}", termCode); + await progressChannel.WriteAsync(HarvestProgressEvent.Failed("An error occurred during harvest."), ct); + } + finally + { + progressChannel.Complete(); + } + } + + #region Private Helpers + + /// + /// Get all instructors from all sources (CREST, Non-CREST, Clinical, and optionally Guest Accounts). + /// + private static IEnumerable GetAllInstructors(HarvestPreviewDto preview, bool includeGuests = true) + { + var instructors = preview.CrestInstructors + .Concat(preview.NonCrestInstructors) + .Concat(preview.ClinicalInstructors); + + return includeGuests ? instructors.Concat(preview.GuestAccounts) : instructors; + } + + private HarvestContext CreateHarvestContext(int termCode, int modifiedBy) + { + return new HarvestContext + { + TermCode = termCode, + ModifiedBy = modifiedBy, + EffortContext = _context, + CrestContext = _crestContext, + CoursesContext = _coursesContext, + ViperContext = _viperContext, + AaudContext = _aaudContext, + DictionaryContext = _dictionaryContext, + AuditService = _auditService, + InstructorService = _instructorService, + TermService = _termService, + Logger = _logger + }; + } + + private async Task ClearExistingDataAsync(int termCode, CancellationToken ct) + { + // Check if database supports ExecuteDeleteAsync (in-memory provider does not) + var isInMemory = _context.Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? false; + + if (isInMemory) + { + // Fall back to traditional delete for in-memory testing + var recordsToDelete = await _context.Records.Where(r => r.TermCode == termCode).ToListAsync(ct); + _context.Records.RemoveRange(recordsToDelete); + + var coursesToDelete = await _context.Courses.Where(c => c.TermCode == termCode).ToListAsync(ct); + _context.Courses.RemoveRange(coursesToDelete); + + var personsToDelete = await _context.Persons.Where(p => p.TermCode == termCode).ToListAsync(ct); + _context.Persons.RemoveRange(personsToDelete); + + await _context.SaveChangesAsync(ct); + } + else + { + // Use efficient bulk delete for production + await _context.Records.Where(r => r.TermCode == termCode).ExecuteDeleteAsync(ct); + await _context.Courses.Where(c => c.TermCode == termCode).ExecuteDeleteAsync(ct); + await _context.Persons.Where(p => p.TermCode == termCode).ExecuteDeleteAsync(ct); + } + + await _auditService.ClearAuditForTermAsync(termCode, ct); + } + + private static string? ValidatePreviewData(HarvestContext context) + { + // Guard: Abort if CREST courses exist but no effort data was extracted + if (context.Preview.CrestCourses.Count > 0 && context.Preview.CrestEffort.Count == 0) + { + return "CREST effort data is empty; aborting harvest to prevent data loss."; + } + + // Guard: Abort if CREST instructors exist but no courses + if (context.Preview.CrestInstructors.Count > 0 && context.Preview.CrestCourses.Count == 0) + { + return "CREST instructors found but no courses; possible data extraction issue. Aborting harvest."; + } + + return null; + } + + private async Task UpdateTermStatusAsync(int termCode, int modifiedBy, CancellationToken ct) + { + var term = await _context.Terms.FirstOrDefaultAsync(t => t.TermCode == termCode, ct); + if (term != null) + { + var oldStatus = term.Status; + term.Status = EffortConstants.TermStatusHarvested; + term.HarvestedDate = DateTime.Now; + term.ModifiedDate = DateTime.Now; + term.ModifiedBy = modifiedBy; + + _auditService.AddTermChangeAudit(termCode, EffortAuditActions.HarvestTerm, + new { Status = oldStatus }, + new { Status = term.Status, term.HarvestedDate }); + } + } + + private static void CalculateSummary(HarvestContext context) + { + var allInstructors = GetAllInstructors(context.Preview, includeGuests: false) + .DistinctBy(p => p.MothraId) + .ToList(); + + var allCourses = context.Preview.CrestCourses + .Concat(context.Preview.NonCrestCourses.Where(c => c.Source != EffortConstants.SourceInCrest)) + .Concat(context.Preview.ClinicalCourses) + .DistinctBy(c => string.IsNullOrWhiteSpace(c.Crn) + ? $"{c.SubjCode}-{c.CrseNumb}-{c.SeqNumb}-{c.Units}" + : $"CRN:{c.Crn}-{c.Units}") + .ToList(); + + context.Preview.Summary = new HarvestSummary + { + TotalInstructors = allInstructors.Count + context.Preview.GuestAccounts.Count, + TotalCourses = allCourses.Count, + TotalEffortRecords = context.Preview.CrestEffort.Count + + context.Preview.NonCrestEffort.Count + + context.Preview.ClinicalEffort.Count, + GuestAccounts = context.Preview.GuestAccounts.Count + }; + } + + private static string BuildSummaryText(HarvestContext context) + { + var allInstructors = GetAllInstructors(context.Preview) + .DistinctBy(p => p.MothraId) + .Count(); + + var allCourses = context.Preview.CrestCourses.Count + + context.Preview.NonCrestCourses.Count(c => c.Source != EffortConstants.SourceInCrest) + + context.Preview.ClinicalCourses.Count; + + var allEffort = context.Preview.CrestEffort.Count + + context.Preview.NonCrestEffort.Count + + context.Preview.ClinicalEffort.Count; + + return $"Harvested: {allInstructors} instructors, {allCourses} courses, {allEffort} effort records"; + } + + private async Task DetectExistingAndRemovedItemsAsync(HarvestContext context, CancellationToken ct) + { + var termCode = context.TermCode; + + // Get existing instructors + var existingPersonIds = await _context.Persons + .AsNoTracking() + .Where(p => p.TermCode == termCode) + .Select(p => new { p.PersonId, p.FirstName, p.LastName, Department = p.EffortDept, TitleCode = p.EffortTitleCode }) + .ToListAsync(ct); + + var personIdsToLookup = existingPersonIds.Select(p => p.PersonId).ToList(); + var personIdToMothraId = await _viperContext.People + .AsNoTracking() + .Where(p => personIdsToLookup.Contains(p.PersonId)) + .Select(p => new { p.PersonId, p.MothraId }) + .ToDictionaryAsync(p => p.PersonId, p => p.MothraId ?? "", ct); + + var existingInstructors = existingPersonIds + .Select(p => new + { + MothraId = personIdToMothraId.GetValueOrDefault(p.PersonId, ""), + p.FirstName, + p.LastName, + p.Department, + p.TitleCode + }) + .Where(p => !string.IsNullOrEmpty(p.MothraId)) + .ToList(); + + var existingCourses = await _context.Courses + .AsNoTracking() + .Where(c => c.TermCode == termCode) + .Select(c => new { c.Crn, c.SubjCode, c.CrseNumb, c.SeqNumb, c.Enrollment, c.Units, c.CustDept }) + .ToListAsync(ct); + + var existingMothraIds = existingInstructors.Select(i => i.MothraId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var existingCourseKeys = existingCourses + .Select(c => BuildExistingCourseKey(c.Crn, c.SubjCode, c.CrseNumb, c.SeqNumb, c.Units)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Set IsNew flag for instructors + var harvestMothraIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var instructor in GetAllInstructors(context.Preview)) + { + instructor.IsNew = !existingMothraIds.Contains(instructor.MothraId); + harvestMothraIds.Add(instructor.MothraId); + } + + // Set IsNew flag for courses + var harvestCourseKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var course in context.Preview.CrestCourses + .Concat(context.Preview.NonCrestCourses) + .Concat(context.Preview.ClinicalCourses)) + { + var courseKey = BuildExistingCourseKey(course.Crn, course.SubjCode, course.CrseNumb, course.SeqNumb, course.Units); + course.IsNew = !existingCourseKeys.Contains(courseKey); + harvestCourseKeys.Add(courseKey); + } + + // Find removed instructors + foreach (var existing in existingInstructors.Where(i => !harvestMothraIds.Contains(i.MothraId))) + { + context.Preview.RemovedInstructors.Add(new HarvestPersonPreview + { + MothraId = existing.MothraId, + FullName = $"{existing.LastName}, {existing.FirstName}", + FirstName = existing.FirstName, + LastName = existing.LastName, + Department = existing.Department, + TitleCode = existing.TitleCode, + Source = EffortConstants.SourceExisting + }); + } + + // Find removed courses + foreach (var existing in existingCourses.Where(c => + !harvestCourseKeys.Contains(BuildExistingCourseKey(c.Crn, c.SubjCode, c.CrseNumb, c.SeqNumb, c.Units)))) + { + context.Preview.RemovedCourses.Add(new HarvestCoursePreview + { + Crn = existing.Crn, + SubjCode = existing.SubjCode, + CrseNumb = existing.CrseNumb, + SeqNumb = existing.SeqNumb, + Enrollment = existing.Enrollment, + Units = existing.Units, + CustDept = existing.CustDept, + Source = EffortConstants.SourceExisting + }); + } + + // Check for existing data warning + var existingRecordCount = await _context.Records.CountAsync(r => r.TermCode == termCode, ct); + + if (existingRecordCount > 0 || existingInstructors.Count != 0 || existingCourses.Count != 0) + { + context.Preview.Warnings.Add(new HarvestWarning + { + Phase = "", + Message = "Existing data will be replaced", + Details = $"This term has existing data ({existingInstructors.Count} instructors, {existingCourses.Count} courses, {existingRecordCount} effort records) that will be permanently deleted when harvest is confirmed." + }); + } + } + + private static string BuildExistingCourseKey(string? crn, string subjCode, string crseNumb, string seqNumb, decimal units) + { + return string.IsNullOrWhiteSpace(crn) + ? $"{subjCode}{crseNumb}{seqNumb}-{units}" + : $"CRN:{crn}"; + } + + #endregion +} diff --git a/web/Areas/Effort/Services/IEffortAuditService.cs b/web/Areas/Effort/Services/IEffortAuditService.cs index 40199440..103ff104 100644 --- a/web/Areas/Effort/Services/IEffortAuditService.cs +++ b/web/Areas/Effort/Services/IEffortAuditService.cs @@ -43,6 +43,30 @@ Task LogTermChangeAsync(int termCode, string action, /// void AddImportAudit(int termCode, string action, string details); + /// + /// Clear audit records for a term (except ImportEffort actions). + /// Used during harvest to reset audit trail before re-importing data. + /// + Task ClearAuditForTermAsync(int termCode, CancellationToken ct = default); + + /// + /// Add a harvest person created audit entry to the context without saving. + /// Use this within a transaction where the caller manages SaveChangesAsync. + /// + void AddHarvestPersonAudit(int personId, int termCode, string firstName, string lastName, string department); + + /// + /// Add a harvest course created audit entry to the context without saving. + /// Use this within a transaction where the caller manages SaveChangesAsync. + /// + void AddHarvestCourseAudit(int courseId, int termCode, string subjCode, string crseNumb, string crn); + + /// + /// Add a harvest effort record created audit entry to the context without saving. + /// Use this within a transaction where the caller manages SaveChangesAsync. + /// + void AddHarvestRecordAudit(int recordId, int termCode, string mothraId, string courseCode, string effortType, int? hours, int? weeks); + /// /// Add a person (instructor) change audit entry to the context without saving. /// Use this within a transaction where the caller manages SaveChangesAsync. diff --git a/web/Areas/Effort/Services/IHarvestService.cs b/web/Areas/Effort/Services/IHarvestService.cs new file mode 100644 index 00000000..ca5168a3 --- /dev/null +++ b/web/Areas/Effort/Services/IHarvestService.cs @@ -0,0 +1,40 @@ +using Viper.Areas.Effort.Models.DTOs.Responses; + +namespace Viper.Areas.Effort.Services; + +/// +/// Service for harvesting instructor and course data into the effort system. +/// +public interface IHarvestService +{ + /// + /// Generate a preview of harvest data without saving. + /// + /// The term code to harvest. + /// Cancellation token. + /// Preview of all data that would be imported. + Task GeneratePreviewAsync(int termCode, CancellationToken ct = default); + + /// + /// Execute harvest: clear existing data and import all phases. + /// + /// The term code to harvest. + /// PersonId of the user performing the harvest. + /// Cancellation token. + /// Result of the harvest operation. + Task ExecuteHarvestAsync(int termCode, int modifiedBy, CancellationToken ct = default); + + /// + /// Execute harvest with real-time progress reporting via Channel. + /// Used for SSE streaming to provide live progress updates to the client. + /// + /// The term code to harvest. + /// PersonId of the user performing the harvest. + /// Channel to write progress events to. + /// Cancellation token. + Task ExecuteHarvestWithProgressAsync( + int termCode, + int modifiedBy, + System.Threading.Channels.ChannelWriter progressChannel, + CancellationToken ct = default); +} diff --git a/web/Areas/Effort/Services/IInstructorService.cs b/web/Areas/Effort/Services/IInstructorService.cs index 7de230d0..db031444 100644 --- a/web/Areas/Effort/Services/IInstructorService.cs +++ b/web/Areas/Effort/Services/IInstructorService.cs @@ -150,4 +150,12 @@ public interface IInstructorService /// Cancellation token. /// List of job groups with human-readable names. Task> GetJobGroupsAsync(CancellationToken ct = default); + + /// + /// Get a cached lookup of raw department codes to simple names from the dictionary database. + /// Maps codes like "072030" to simple names like "VME". + /// + /// Cancellation token. + /// Dictionary mapping raw codes to simple names, or null if unavailable. + Task?> GetDepartmentSimpleNameLookupAsync(CancellationToken ct = default); } diff --git a/web/Areas/Effort/Services/InstructorService.cs b/web/Areas/Effort/Services/InstructorService.cs index 28dd1832..bfaec972 100644 --- a/web/Areas/Effort/Services/InstructorService.cs +++ b/web/Areas/Effort/Services/InstructorService.cs @@ -1,5 +1,5 @@ +using System.Data.Common; using AutoMapper; -using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Viper.Areas.Effort.Constants; @@ -7,6 +7,7 @@ using Viper.Areas.Effort.Models.DTOs.Responses; using Viper.Areas.Effort.Models.Entities; using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; namespace Viper.Areas.Effort.Services; @@ -18,10 +19,10 @@ public class InstructorService : IInstructorService private readonly EffortDbContext _context; private readonly VIPERContext _viperContext; private readonly AAUDContext _aaudContext; + private readonly DictionaryContext _dictionaryContext; private readonly IEffortAuditService _auditService; private readonly IMapper _mapper; private readonly ILogger _logger; - private readonly IConfiguration _configuration; private readonly IMemoryCache _cache; private const string TitleCacheKey = "effort_title_lookup"; @@ -62,19 +63,19 @@ public InstructorService( EffortDbContext context, VIPERContext viperContext, AAUDContext aaudContext, + DictionaryContext dictionaryContext, IEffortAuditService auditService, IMapper mapper, ILogger logger, - IConfiguration configuration, IMemoryCache cache) { _context = context; _viperContext = viperContext; _aaudContext = aaudContext; + _dictionaryContext = dictionaryContext; _auditService = auditService; _mapper = mapper; _logger = logger; - _configuration = configuration; _cache = cache; } @@ -346,7 +347,7 @@ public async Task CreateInstructorAsync(CreateInstructorRequest reque await transaction.CommitAsync(ct); _logger.LogInformation("Created instructor: {PersonId} ({LastName}, {FirstName}) for term {TermCode}", - instructor.PersonId, instructor.LastName, instructor.FirstName, instructor.TermCode); + instructor.PersonId, LogSanitizer.SanitizeString(instructor.LastName), LogSanitizer.SanitizeString(instructor.FirstName), instructor.TermCode); return _mapper.Map(instructor); } @@ -443,7 +444,7 @@ public async Task DeleteInstructorAsync(int personId, int termCode, Cancel await transaction.CommitAsync(ct); _logger.LogInformation("Deleted instructor: {PersonId} ({LastName}, {FirstName}) for term {TermCode}", - personId, instructorInfo.LastName, instructorInfo.FirstName, termCode); + personId, LogSanitizer.SanitizeString(instructorInfo.LastName), LogSanitizer.SanitizeString(instructorInfo.FirstName), termCode); return true; } @@ -545,10 +546,11 @@ public async Task> GetReportUnitsAsync(CancellationToken ct Dictionary? deptSimpleNameLookup, CancellationToken ct = default) { - // Mison override - he only has a VMTH job, but needs his effort recorded to VSR - if (mothraId == "02493928") + // Check for department override + var deptOverride = EffortConstants.GetDepartmentOverride(mothraId); + if (deptOverride != null) { - return "VSR"; + return deptOverride; } // Helper to resolve raw code to simple name @@ -642,10 +644,11 @@ public async Task> GetReportUnitsAsync(CancellationToken ct Dictionary>? jobDeptsByMothraId, Dictionary? deptSimpleNameLookup) { - // Mison override - he only has a VMTH job, but needs his effort recorded to VSR - if (mothraId == "02493928") + // Check for department override + var deptOverride = EffortConstants.GetDepartmentOverride(mothraId); + if (deptOverride != null) { - return "VSR"; + return deptOverride; } // Helper to resolve raw code to simple name @@ -750,39 +753,20 @@ private async Task EnrichWithTitlesAsync(List instructors, Cancellati return cached; } - var connectionString = _configuration.GetConnectionString("VIPER"); - if (string.IsNullOrEmpty(connectionString)) - { - _logger.LogWarning("VIPER connection string not found, cannot load title lookup"); - return null; - } - try { - var titleLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - - await using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(ct); - - var query = @" - SELECT DISTINCT - dvtTitle_Code AS TitleCode, - dvtTitle_Abbrv AS TitleAbbrev - FROM [dictionary].[dbo].[dvtTitle] - WHERE dvtTitle_Code IS NOT NULL - AND dvtTitle_Abbrv IS NOT NULL"; - - await using var cmd = new SqlCommand(query, connection); - await using var reader = await cmd.ExecuteReaderAsync(ct); + var titles = await _dictionaryContext.Titles + .AsNoTracking() + .Where(t => t.Code != null && t.Abbreviation != null) + .Select(t => new { t.Code, t.Abbreviation }) + .Distinct() + .ToListAsync(ct); - while (await reader.ReadAsync(ct)) + var titleLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var title in titles.Where(t => + !string.IsNullOrEmpty(t.Code) && !string.IsNullOrEmpty(t.Abbreviation))) { - var code = reader["TitleCode"]?.ToString(); - var abbrev = reader["TitleAbbrev"]?.ToString(); - if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(abbrev)) - { - titleLookup[code] = abbrev; - } + titleLookup[title.Code!] = title.Abbreviation!; } var cacheOptions = new MemoryCacheEntryOptions() @@ -794,7 +778,7 @@ WHERE dvtTitle_Code IS NOT NULL return titleLookup; } - catch (SqlException ex) + catch (Exception ex) when (ex is InvalidOperationException or DbException) { _logger.LogWarning(ex, "Failed to load title lookup from dictionary database"); return null; @@ -805,53 +789,35 @@ WHERE dvtTitle_Code IS NOT NULL /// Gets a cached lookup of raw department codes to simple names from the dictionary database. /// Replicates the logic in dictionary.dbo.fn_get_deptSimpleName function using dvtSVMUnit table. /// - private async Task?> GetDepartmentSimpleNameLookupAsync(CancellationToken ct) + public async Task?> GetDepartmentSimpleNameLookupAsync(CancellationToken ct = default) { if (_cache.TryGetValue>(DeptSimpleNameCacheKey, out var cached) && cached != null) { return cached; } - var connectionString = _configuration.GetConnectionString("VIPER"); - if (string.IsNullOrEmpty(connectionString)) - { - _logger.LogWarning("VIPER connection string not found, cannot load department lookup"); - return null; - } - try { - var deptLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - - await using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(ct); - // Query dvtSVMUnit table to get raw code -> simple name mapping // This matches the logic in dictionary.dbo.fn_get_deptSimpleName function - var query = @" - SELECT DISTINCT - dvtSVMUnit_code AS DeptCode, - dvtSVMUnit_name_simple AS SimpleName - FROM [dictionary].[dbo].[dvtSVMUnit] - WHERE dvtSVMUnit_code IS NOT NULL - AND dvtSVMUnit_name_simple IS NOT NULL - AND dvtSVMUnit_name_simple <> '' - AND dvtSVMUnit_name_simple != 'CCEH' - AND ((dvtSvmUnit_Parent_ID IS NULL AND dvtSVMUnit_code = '072000') - OR (dvtSvmUnit_Parent_ID = 1 AND dvtSVMUnit_code != '072000') - OR (dvtSvmUnit_Parent_ID = 47 AND dvtSVMUnit_code = '072100'))"; - - await using var cmd = new SqlCommand(query, connection); - await using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) + var units = await _dictionaryContext.SvmUnits + .AsNoTracking() + .Where(u => u.Code != null && + u.SimpleName != null && + u.SimpleName != "" && + u.SimpleName != "CCEH" && + ((u.ParentId == null && u.Code == "072000") || + (u.ParentId == 1 && u.Code != "072000") || + (u.ParentId == 47 && u.Code == "072100"))) + .Select(u => new { u.Code, u.SimpleName }) + .Distinct() + .ToListAsync(ct); + + var deptLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var unit in units.Where(u => + !string.IsNullOrEmpty(u.Code) && !string.IsNullOrEmpty(u.SimpleName))) { - var code = reader["DeptCode"]?.ToString(); - var simpleName = reader["SimpleName"]?.ToString(); - if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(simpleName)) - { - deptLookup[code] = simpleName; - } + deptLookup[unit.Code!] = unit.SimpleName!; } var cacheOptions = new MemoryCacheEntryOptions() @@ -863,7 +829,7 @@ AND dvtSVMUnit_name_simple <> '' return deptLookup; } - catch (SqlException ex) + catch (Exception ex) when (ex is InvalidOperationException or DbException) { _logger.LogWarning(ex, "Failed to load department lookup from dictionary database"); return null; @@ -922,45 +888,25 @@ public async Task> GetTitleCodesAsync(CancellationToken ct = return cached; } - var connectionString = _configuration.GetConnectionString("VIPER"); - if (string.IsNullOrEmpty(connectionString)) - { - _logger.LogWarning("VIPER connection string not found, cannot load title codes"); - return []; - } - try { - var titleCodes = new List(); - - await using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(ct); - - var query = @" - SELECT DISTINCT - dvtTitle_Code AS TitleCode, - dvtTitle_name AS TitleName - FROM [dictionary].[dbo].[dvtTitle] - WHERE dvtTitle_Code IS NOT NULL - ORDER BY dvtTitle_name, dvtTitle_Code"; - - await using var cmd = new SqlCommand(query, connection); - await using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - var code = reader["TitleCode"]?.ToString()?.Trim(); - var name = reader["TitleName"]?.ToString()?.Trim(); + var titles = await _dictionaryContext.Titles + .AsNoTracking() + .Where(t => t.Code != null) + .Select(t => new { Code = t.Code!.Trim(), Name = t.Name ?? "" }) + .Distinct() + .OrderBy(t => t.Name) + .ThenBy(t => t.Code) + .ToListAsync(ct); - if (!string.IsNullOrEmpty(code)) + var titleCodes = titles + .Where(t => !string.IsNullOrEmpty(t.Code)) + .Select(t => new TitleCodeDto { - titleCodes.Add(new TitleCodeDto - { - Code = code, - Name = name ?? string.Empty - }); - } - } + Code = t.Code, + Name = t.Name.Trim() + }) + .ToList(); var cacheOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(TimeSpan.FromHours(24)); @@ -971,7 +917,7 @@ WHERE dvtTitle_Code IS NOT NULL return titleCodes; } - catch (SqlException ex) + catch (Exception ex) when (ex is InvalidOperationException or DbException) { _logger.LogWarning(ex, "Failed to load title codes from dictionary database"); return []; @@ -985,50 +931,44 @@ public async Task> GetJobGroupsAsync(CancellationToken ct = de return cached; } - var connectionString = _configuration.GetConnectionString("VIPER"); - if (string.IsNullOrEmpty(connectionString)) - { - _logger.LogWarning("VIPER connection string not found, cannot load job groups"); - return []; - } - try { - var jobGroups = new List(); - - await using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(ct); - - // Get job groups that are actually in use by instructors, joined with dictionary for names - var query = @" - SELECT DISTINCT - p.JobGroupId AS JobGroupCode, - t.dvtTitle_JobGroup_Name AS JobGroupName - FROM [effort].[Persons] p - LEFT JOIN [dictionary].[dbo].[dvtTitle] t - ON p.JobGroupId = t.dvtTitle_JobGroupID - WHERE p.JobGroupId IS NOT NULL - AND p.JobGroupId != '' - ORDER BY JobGroupName, JobGroupCode"; - - await using var cmd = new SqlCommand(query, connection); - await using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) + // Get job groups that are actually in use by instructors + var usedJobGroupIds = await _context.Persons + .AsNoTracking() + .Where(p => p.JobGroupId != null && p.JobGroupId != "") + .Select(p => p.JobGroupId!) + .Distinct() + .ToListAsync(ct); + + if (usedJobGroupIds.Count == 0) { - var code = reader["JobGroupCode"]?.ToString()?.Trim(); - var name = reader["JobGroupName"]?.ToString()?.Trim(); + return []; + } + + // Get job group names from dictionary + var titleJobGroups = await _dictionaryContext.Titles + .AsNoTracking() + .Where(t => t.JobGroupId != null && usedJobGroupIds.Contains(t.JobGroupId)) + .Select(t => new { t.JobGroupId, t.JobGroupName }) + .Distinct() + .ToListAsync(ct); + + var jobGroupNameLookup = titleJobGroups + .Where(t => !string.IsNullOrEmpty(t.JobGroupId)) + .GroupBy(t => t.JobGroupId!) + .ToDictionary(g => g.Key, g => g.First().JobGroupName ?? "", StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrEmpty(code)) + var jobGroups = usedJobGroupIds + .Where(id => !string.IsNullOrEmpty(id)) + .Select(id => new JobGroupDto { - jobGroups.Add(new JobGroupDto - { - Code = code, - // If name is NULL, just use empty string (UI will show code only) - Name = name ?? string.Empty - }); - } - } + Code = id.Trim(), + Name = jobGroupNameLookup.TryGetValue(id.Trim(), out var name) ? name.Trim() : "" + }) + .OrderBy(j => j.Name) + .ThenBy(j => j.Code) + .ToList(); var cacheOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(TimeSpan.FromHours(24)); @@ -1039,7 +979,7 @@ WHERE p.JobGroupId IS NOT NULL return jobGroups; } - catch (SqlException ex) + catch (Exception ex) when (ex is InvalidOperationException or DbException) { _logger.LogWarning(ex, "Failed to load job groups from database"); return []; diff --git a/web/Classes/SQLContext/CrestContext.cs b/web/Classes/SQLContext/CrestContext.cs new file mode 100644 index 00000000..a239a8f3 --- /dev/null +++ b/web/Classes/SQLContext/CrestContext.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Models.Crest; + +namespace Viper.Classes.SQLContext; + +/// +/// Context for read-only access to the CREST database. +/// Used by the Effort harvest process to extract instructor-course assignments. +/// +public class CrestContext : DbContext +{ + public CrestContext(DbContextOptions options) : base(options) + { + } + + public virtual DbSet Blocks { get; set; } + public virtual DbSet CourseSessionOfferings { get; set; } + public virtual DbSet EdutaskOfferPersons { get; set; } + public virtual DbSet EdutaskPersons { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (HttpHelper.Settings != null && !optionsBuilder.IsConfigured) + { + optionsBuilder.UseSqlServer(HttpHelper.Settings["ConnectionStrings:CREST"]); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("vw_vm_course_session_offering", schema: "dbo"); + + entity.Property(e => e.CourseId).HasColumnName("courseID"); + entity.Property(e => e.EdutaskOfferId).HasColumnName("edutaskofferid"); + entity.Property(e => e.AcademicYear).HasColumnName("academicYear"); + entity.Property(e => e.Crn).HasColumnName("crn"); + entity.Property(e => e.SsaCourseNum).HasColumnName("ssaCourseNum"); + entity.Property(e => e.SessionType).HasColumnName("sessionType"); + entity.Property(e => e.SeqNumb).HasColumnName("seqNumb"); + entity.Property(e => e.FromDate).HasColumnName("fromdate"); + entity.Property(e => e.FromTime).HasColumnName("fromtime"); + entity.Property(e => e.ThruDate).HasColumnName("thrudate"); + entity.Property(e => e.ThruTime).HasColumnName("thrutime"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("tbl_EdutaskOfferPerson", schema: "dbo"); + + entity.Property(e => e.EdutaskOfferId).HasColumnName("edutaskofferID"); + entity.Property(e => e.PersonId).HasColumnName("personID"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("tbl_EdutaskPerson", schema: "dbo"); + + entity.Property(e => e.EdutaskId).HasColumnName("edutaskID"); + entity.Property(e => e.PersonId).HasColumnName("personID"); + entity.Property(e => e.RoleCode).HasColumnName("rolecode"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("tbl_Block", schema: "dbo"); + + entity.Property(e => e.EdutaskId).HasColumnName("edutaskID"); + entity.Property(e => e.AcademicYear).HasColumnName("academicYear"); + entity.Property(e => e.SsaCourseNum).HasColumnName("ssaCourseNum"); + }); + } +} diff --git a/web/Classes/SQLContext/DictionaryContext.cs b/web/Classes/SQLContext/DictionaryContext.cs new file mode 100644 index 00000000..48e5ff8a --- /dev/null +++ b/web/Classes/SQLContext/DictionaryContext.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Models.Dictionary; + +namespace Viper.Classes.SQLContext; + +/// +/// Context for read-only access to the dictionary database. +/// Contains reference data like title codes and department mappings. +/// +public class DictionaryContext : DbContext +{ + public DictionaryContext(DbContextOptions options) : base(options) + { + } + + public virtual DbSet Titles { get; set; } + public virtual DbSet SvmUnits { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (HttpHelper.Settings != null && !optionsBuilder.IsConfigured) + { + optionsBuilder.UseSqlServer(HttpHelper.Settings["ConnectionStrings:Dictionary"]); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("dvtTitle", schema: "dbo"); + + entity.Property(e => e.Code).HasColumnName("dvtTitle_Code"); + entity.Property(e => e.Name).HasColumnName("dvtTitle_name"); + entity.Property(e => e.Abbreviation).HasColumnName("dvtTitle_Abbrv"); + entity.Property(e => e.JobGroupId).HasColumnName("dvtTitle_JobGroupID"); + entity.Property(e => e.JobGroupName).HasColumnName("dvtTitle_JobGroup_Name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("dvtSVMUnit", schema: "dbo"); + + entity.Property(e => e.Code).HasColumnName("dvtSVMUnit_code"); + entity.Property(e => e.SimpleName).HasColumnName("dvtSVMUnit_name_simple"); + entity.Property(e => e.ParentId).HasColumnName("dvtSvmUnit_Parent_ID"); + }); + } +} diff --git a/web/Models/Crest/CrestBlock.cs b/web/Models/Crest/CrestBlock.cs new file mode 100644 index 00000000..d26b47b4 --- /dev/null +++ b/web/Models/Crest/CrestBlock.cs @@ -0,0 +1,12 @@ +namespace Viper.Models.Crest; + +/// +/// CREST block (course) data from tbl_Block. +/// Used to get the master list of courses before querying session offerings. +/// +public class CrestBlock +{ + public int EdutaskId { get; set; } + public string AcademicYear { get; set; } = null!; + public string? SsaCourseNum { get; set; } +} diff --git a/web/Models/Crest/CrestCourseSessionOffering.cs b/web/Models/Crest/CrestCourseSessionOffering.cs new file mode 100644 index 00000000..37b1bbce --- /dev/null +++ b/web/Models/Crest/CrestCourseSessionOffering.cs @@ -0,0 +1,20 @@ +namespace Viper.Models.Crest; + +/// +/// CREST course session offering view data. +/// Maps to vw_vm_course_session_offering in CREST database. +/// +public class CrestCourseSessionOffering +{ + public int CourseId { get; set; } + public int EdutaskOfferId { get; set; } + public string AcademicYear { get; set; } = null!; + public string? Crn { get; set; } + public string? SsaCourseNum { get; set; } + public string? SessionType { get; set; } + public string? SeqNumb { get; set; } + public DateTime? FromDate { get; set; } + public string? FromTime { get; set; } + public DateTime? ThruDate { get; set; } + public string? ThruTime { get; set; } +} diff --git a/web/Models/Crest/EdutaskOfferPerson.cs b/web/Models/Crest/EdutaskOfferPerson.cs new file mode 100644 index 00000000..5f5ba2c3 --- /dev/null +++ b/web/Models/Crest/EdutaskOfferPerson.cs @@ -0,0 +1,11 @@ +namespace Viper.Models.Crest; + +/// +/// Links instructors (by PIDM) to specific course session offerings. +/// Read-only entity for extracting instructor assignments from CREST. +/// +public class EdutaskOfferPerson +{ + public int EdutaskOfferId { get; set; } + public int PersonId { get; set; } +} diff --git a/web/Models/Crest/EdutaskPerson.cs b/web/Models/Crest/EdutaskPerson.cs new file mode 100644 index 00000000..820650bd --- /dev/null +++ b/web/Models/Crest/EdutaskPerson.cs @@ -0,0 +1,13 @@ +namespace Viper.Models.Crest; + +/// +/// Links instructors (by PIDM) to courses (edutasks) with role information. +/// Used to determine if an instructor is the Director (IOR) for a course. +/// Read-only entity for extracting instructor role data from CREST. +/// +public class EdutaskPerson +{ + public int EdutaskId { get; set; } + public int PersonId { get; set; } + public string? RoleCode { get; set; } +} diff --git a/web/Models/Dictionary/DvtSvmUnit.cs b/web/Models/Dictionary/DvtSvmUnit.cs new file mode 100644 index 00000000..6c389286 --- /dev/null +++ b/web/Models/Dictionary/DvtSvmUnit.cs @@ -0,0 +1,12 @@ +namespace Viper.Models.Dictionary; + +/// +/// Entity for dictionary.dbo.dvtSVMUnit table. +/// Contains department codes and their simple names for the School of Veterinary Medicine. +/// +public class DvtSvmUnit +{ + public string? Code { get; set; } + public string? SimpleName { get; set; } + public int? ParentId { get; set; } +} diff --git a/web/Models/Dictionary/DvtTitle.cs b/web/Models/Dictionary/DvtTitle.cs new file mode 100644 index 00000000..6ceb066c --- /dev/null +++ b/web/Models/Dictionary/DvtTitle.cs @@ -0,0 +1,14 @@ +namespace Viper.Models.Dictionary; + +/// +/// Entity for dictionary.dbo.dvtTitle table. +/// Contains title codes and their descriptions for faculty/staff positions. +/// +public class DvtTitle +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Abbreviation { get; set; } + public string? JobGroupId { get; set; } + public string? JobGroupName { get; set; } +} diff --git a/web/Program.cs b/web/Program.cs index 36204e59..866ca9f6 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -190,6 +190,8 @@ void ConfigureDbContextOptions(DbContextOptionsBuilder options) builder.Services.AddDbContext(ConfigureDbContextOptions); builder.Services.AddDbContext(ConfigureDbContextOptions); + builder.Services.AddDbContext(ConfigureDbContextOptions); + builder.Services.AddDbContext(ConfigureDbContextOptions); builder.Services.AddDbContext(ConfigureDbContextOptions); builder.Services.AddDbContext(ConfigureDbContextOptions); builder.Services.AddDbContext(ConfigureDbContextOptions); @@ -232,6 +234,13 @@ void ConfigureDbContextOptions(DbContextOptionsBuilder options) builder.Services.AddScoped(); builder.Services.AddScoped(); + // Harvest phases (order matters for DI resolution, but phases self-order via Order property) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + // Add in a custom ClaimsTransformer that injects user ROLES builder.Services.AddTransient(); diff --git a/web/appsettings.Development.json b/web/appsettings.Development.json index 269a8690..ec001bff 100644 --- a/web/appsettings.Development.json +++ b/web/appsettings.Development.json @@ -11,6 +11,8 @@ "ConnectionStrings": { "AAUD": "", "Courses": "", + "CREST": "", + "Dictionary": "", "Effort": "", "RAPS": "", "SIS": "", diff --git a/web/appsettings.Production.json b/web/appsettings.Production.json index b9fea72c..c61fc1aa 100644 --- a/web/appsettings.Production.json +++ b/web/appsettings.Production.json @@ -10,6 +10,8 @@ "ConnectionStrings": { "AAUD": "", "Courses": "", + "CREST": "", + "Dictionary": "", "Effort": "", "RAPS": "", "SIS": "", diff --git a/web/appsettings.Test.json b/web/appsettings.Test.json index a4f0f6fd..210b5e23 100644 --- a/web/appsettings.Test.json +++ b/web/appsettings.Test.json @@ -10,6 +10,8 @@ "ConnectionStrings": { "AAUD": "", "Courses": "", + "CREST": "", + "Dictionary": "", "Effort": "", "RAPS": "", "SIS": "",