diff --git a/client/src/components/dataClassification/DataClassificationStage.tsx b/client/src/components/dataClassification/DataClassificationStage.tsx
index 36bc428..67ee75f 100644
--- a/client/src/components/dataClassification/DataClassificationStage.tsx
+++ b/client/src/components/dataClassification/DataClassificationStage.tsx
@@ -5,18 +5,20 @@ import {
segmentsForType,
unclassifiedCount,
} from './segments.ts';
+import { buildSegmentExport } from './exportSegments.ts';
import { SegmentGrid } from './SegmentGrid.tsx';
+import { ExportDataButton } from '@/shared/exportDataButton.tsx';
import {
- chartStringSegmentsQueryOptions,
- type ChartStringSegment,
+ segmentClassificationsQueryOptions,
+ type SegmentClassification,
useUpdateSegmentClassification,
-} from '@/queries/chartStringSegments.ts';
+} from '@/queries/segmentClassifications.ts';
import { Link } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
export function DataClassificationStage() {
const { data: segments = [], isLoading } = useQuery(
- chartStringSegmentsQueryOptions()
+ segmentClassificationsQueryOptions()
);
const updateClassification = useUpdateSegmentClassification();
const [activeType, setActiveType] = useState(SEGMENT_TABS[0].type);
@@ -26,7 +28,7 @@ export function DataClassificationStage() {
}
const handleClassify = (
- segment: ChartStringSegment,
+ segment: SegmentClassification,
includeInReport: boolean,
sfn: string | null
) => {
@@ -39,6 +41,10 @@ export function DataClassificationStage() {
};
const gateOpen = allClassified(segments);
+ const activeTab =
+ SEGMENT_TABS.find((tab) => tab.type === activeType) ?? SEGMENT_TABS[0];
+ const tabSegments = segmentsForType(segments, activeType);
+ const exportData = buildSegmentExport(tabSegments, activeTab);
return (
@@ -71,19 +77,25 @@ export function DataClassificationStage() {
})}
- {activeType === 'Ern' && (
+ {activeTab.note && (
-
- Note: ERN code classification affects FTE calculations only. It does not
- affect dollar-amount calculations.
-
+ {activeTab.note}
)}
+ }
/>
diff --git a/client/src/components/dataClassification/SegmentClassificationControl.tsx b/client/src/components/dataClassification/SegmentClassificationControl.tsx
index fcd65ad..9bca835 100644
--- a/client/src/components/dataClassification/SegmentClassificationControl.tsx
+++ b/client/src/components/dataClassification/SegmentClassificationControl.tsx
@@ -1,8 +1,8 @@
import {
- FUND_SFNS,
+ SFN_CATALOG,
SFN_MULTIPLE,
- type ChartStringSegment,
-} from '@/queries/chartStringSegments.ts';
+ type SegmentClassification,
+} from '@/queries/segmentClassifications.ts';
const EXCLUDED = 'Excluded';
@@ -11,7 +11,7 @@ export function SegmentClassificationControl({
segment,
}: {
onClassify: (includeInReport: boolean, sfn: string | null) => void;
- segment: ChartStringSegment;
+ segment: SegmentClassification;
}) {
if (segment.segmentType === 'Fund') {
const value =
@@ -36,9 +36,9 @@ export function SegmentClassificationControl({
- {FUND_SFNS.map((sfn) => (
-
))}
diff --git a/client/src/components/dataClassification/SegmentGrid.tsx b/client/src/components/dataClassification/SegmentGrid.tsx
index c3f7735..45096c7 100644
--- a/client/src/components/dataClassification/SegmentGrid.tsx
+++ b/client/src/components/dataClassification/SegmentGrid.tsx
@@ -1,13 +1,13 @@
-import { useState } from 'react';
+import { useState, type ReactNode } from 'react';
import { SegmentClassificationControl } from './SegmentClassificationControl.tsx';
import type {
- ChartStringSegment,
+ SegmentClassification,
SegmentType,
-} from '@/queries/chartStringSegments.ts';
+} from '@/queries/segmentClassifications.ts';
import { DataTable } from '@/shared/dataTable.tsx';
import type { ColumnDef } from '@tanstack/react-table';
-function classificationSortValue(segment: ChartStringSegment): string {
+function classificationSortValue(segment: SegmentClassification): string {
if (segment.includeInReport === null) {
return '';
}
@@ -15,7 +15,7 @@ function classificationSortValue(segment: ChartStringSegment): string {
}
// Codes ordered with unclassified (unset) rows first, otherwise stable.
-function unsetFirstCodes(segments: ChartStringSegment[]): string[] {
+function unsetFirstCodes(segments: SegmentClassification[]): string[] {
return [...segments]
.sort(
(a, b) =>
@@ -26,17 +26,21 @@ function unsetFirstCodes(segments: ChartStringSegment[]): string[] {
}
export function SegmentGrid({
+ classificationHeader,
onClassify,
segments,
segmentType,
+ tableActions,
}: {
+ classificationHeader: string;
onClassify: (
- segment: ChartStringSegment,
+ segment: SegmentClassification,
includeInReport: boolean,
sfn: string | null
) => void;
- segments: ChartStringSegment[];
+ segments: SegmentClassification[];
segmentType: SegmentType;
+ tableActions?: ReactNode;
}) {
const levelKeys = [
...new Set(
@@ -46,7 +50,7 @@ export function SegmentGrid({
),
].sort();
- const levelColumns: ColumnDef
[] = levelKeys.map(
+ const levelColumns: ColumnDef[] = levelKeys.map(
(levelKey) => ({
accessorFn: (segment) =>
segment.hierarchy.find((level) => level.level === levelKey)?.code ?? '',
@@ -71,7 +75,7 @@ export function SegmentGrid({
})
);
- const columns: ColumnDef[] = [
+ const columns: ColumnDef[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'description', header: 'Name' },
...levelColumns,
@@ -85,7 +89,7 @@ export function SegmentGrid({
segment={row.original}
/>
),
- header: 'Classification',
+ header: classificationHeader,
id: 'classification',
},
];
@@ -113,6 +117,7 @@ export function SegmentGrid({
globalFilter="right"
initialState={{ pagination: { pageSize: 25 } }}
key={segmentType}
+ tableActions={tableActions}
/>
);
}
diff --git a/client/src/components/dataClassification/exportSegments.ts b/client/src/components/dataClassification/exportSegments.ts
new file mode 100644
index 0000000..1caed41
--- /dev/null
+++ b/client/src/components/dataClassification/exportSegments.ts
@@ -0,0 +1,79 @@
+import type { SegmentTab } from './segments.ts';
+import type { CsvColumn } from '@/lib/csv.ts';
+import {
+ SFN_CATALOG,
+ type SegmentClassification,
+} from '@/queries/segmentClassifications.ts';
+
+type ExportRow = Record;
+
+function classificationLabel(segment: SegmentClassification): string {
+ if (segment.includeInReport === null) {
+ return 'Unset';
+ }
+ return segment.includeInReport ? 'Included' : 'Excluded';
+}
+
+// Exports every row of the tab's segment type (intentionally ignores the grid
+// search filter). SFN code and description stay separate columns; description
+// resolves through the catalog.
+export function buildSegmentExport(
+ segments: SegmentClassification[],
+ tab: SegmentTab
+): { columns: CsvColumn[]; filename: string; rows: ExportRow[] } {
+ const levelKeys = [
+ ...new Set(
+ segments.flatMap((segment) =>
+ segment.hierarchy.map((level) => level.level)
+ )
+ ),
+ ].sort();
+
+ const columns: CsvColumn[] = [
+ { header: 'Code', key: 'code' },
+ { header: 'Name', key: 'name' },
+ ...levelKeys.flatMap((levelKey): CsvColumn[] => [
+ { header: `Level ${levelKey} Code`, key: `level${levelKey}Code` },
+ { header: `Level ${levelKey} Name`, key: `level${levelKey}Name` },
+ ]),
+ { header: 'Classification', key: 'classification' },
+ ];
+
+ if (tab.type === 'Fund') {
+ columns.push(
+ { header: 'SFN', key: 'sfn' },
+ { header: 'SFN Description', key: 'sfnDescription' }
+ );
+ }
+
+ const rows = segments.map((segment) => {
+ const row: ExportRow = {
+ classification: classificationLabel(segment),
+ code: segment.code,
+ name: segment.description ?? '',
+ };
+
+ for (const levelKey of levelKeys) {
+ const level = segment.hierarchy.find(
+ (candidate) => candidate.level === levelKey
+ );
+ row[`level${levelKey}Code`] = level?.code ?? '';
+ row[`level${levelKey}Name`] = level?.name ?? '';
+ }
+
+ if (tab.type === 'Fund') {
+ row.sfn = segment.sfn ?? '';
+ row.sfnDescription =
+ SFN_CATALOG.find((entry) => entry.code === segment.sfn)?.description ??
+ '';
+ }
+
+ return row;
+ });
+
+ return {
+ columns,
+ filename: `ad419-${tab.slug}-classification.csv`,
+ rows,
+ };
+}
diff --git a/client/src/components/dataClassification/segments.ts b/client/src/components/dataClassification/segments.ts
index d9b092c..9cd6f94 100644
--- a/client/src/components/dataClassification/segments.ts
+++ b/client/src/components/dataClassification/segments.ts
@@ -1,25 +1,70 @@
import type {
- ChartStringSegment,
+ SegmentClassification,
SegmentType,
-} from '@/queries/chartStringSegments.ts';
+} from '@/queries/segmentClassifications.ts';
-export const SEGMENT_TABS: { label: string; type: SegmentType }[] = [
- { label: 'Financial Dept', type: 'FinancialDepartment' },
- { label: 'Natural Account', type: 'Account' },
- { label: 'Fund', type: 'Fund' },
- { label: 'Activity', type: 'Activity' },
- { label: 'ERN', type: 'Ern' },
+export interface SegmentTab {
+ classificationHeader: string;
+ label: string;
+ note: string;
+ slug: string;
+ type: SegmentType;
+}
+
+export const SEGMENT_TABS: SegmentTab[] = [
+ {
+ classificationHeader: 'Is AES?',
+ label: 'Financial Dept',
+ note: 'Departments marked AES have their expenses considered for the AD419 report.',
+ slug: 'financial-dept',
+ type: 'FinancialDepartment',
+ },
+ {
+ classificationHeader: 'Include in AD419?',
+ label: 'Natural Account',
+ note: 'Whether expenses on this natural account are considered for the AD419 report.',
+ slug: 'natural-account',
+ type: 'Account',
+ },
+ {
+ classificationHeader: 'SFN',
+ label: 'Fund',
+ note: 'Included funds must be assigned an SFN. If a fund does not map to a single SFN, select Multiple.',
+ slug: 'fund',
+ type: 'Fund',
+ },
+ {
+ classificationHeader: 'Include in AD419?',
+ label: 'Activity',
+ note: 'Whether expenses on this activity are considered for the AD419 report.',
+ slug: 'activity',
+ type: 'Activity',
+ },
+ {
+ classificationHeader: 'Include in AD419?',
+ label: 'Purpose',
+ note: 'All purposes are included for transactions with fund 13U02, regardless of classification.',
+ slug: 'purpose',
+ type: 'Purpose',
+ },
+ {
+ classificationHeader: 'Include in FTE?',
+ label: 'ERN',
+ note: 'ERN code classification affects FTE calculations only. It does not affect dollar-amount calculations.',
+ slug: 'ern',
+ type: 'Ern',
+ },
];
export function segmentsForType(
- segments: ChartStringSegment[],
+ segments: SegmentClassification[],
type: SegmentType
-): ChartStringSegment[] {
+): SegmentClassification[] {
return segments.filter((segment) => segment.segmentType === type);
}
export function unclassifiedCount(
- segments: ChartStringSegment[],
+ segments: SegmentClassification[],
type: SegmentType
): number {
return segmentsForType(segments, type).filter(
@@ -27,6 +72,6 @@ export function unclassifiedCount(
).length;
}
-export function allClassified(segments: ChartStringSegment[]): boolean {
+export function allClassified(segments: SegmentClassification[]): boolean {
return segments.every((segment) => segment.includeInReport !== null);
}
diff --git a/client/src/queries/chartStringSegments.ts b/client/src/queries/segmentClassifications.ts
similarity index 55%
rename from client/src/queries/chartStringSegments.ts
rename to client/src/queries/segmentClassifications.ts
index 8674bca..bf1c169 100644
--- a/client/src/queries/chartStringSegments.ts
+++ b/client/src/queries/segmentClassifications.ts
@@ -10,6 +10,7 @@ export type SegmentType =
| 'Account'
| 'Fund'
| 'Activity'
+ | 'Purpose'
| 'Ern';
export interface HierarchyLevel {
@@ -18,7 +19,7 @@ export interface HierarchyLevel {
name: string | null;
}
-export interface ChartStringSegment {
+export interface SegmentClassification {
code: string;
description: string | null;
hierarchy: HierarchyLevel[];
@@ -27,15 +28,35 @@ export interface ChartStringSegment {
sfn: string | null;
}
-export const FUND_SFNS = ['201', '202', '203', '205', '220', '221', '223'] as const;
+export interface SfnEntry {
+ code: string;
+ description: string;
+}
+
+// Mirrors the server's SfnCatalog (code and description stored separately).
+export const SFN_CATALOG: SfnEntry[] = [
+ { code: '201', description: 'Hatch Funds' },
+ { code: '202', description: 'Multi-State Research Funds' },
+ { code: '203', description: 'McIntire-Stennis Funds' },
+ { code: '204', description: 'Contracts, Grants, Research Coop Agreements' },
+ { code: '205', description: 'OtherFunds(AnimalHealthSec1433,Evans-Allen)' },
+ { code: '209', description: 'National Science Foundation' },
+ { code: '219', description: 'USDA Contracts, Grants, Coop Agreements' },
+ { code: '220', description: 'State Appropriations' },
+ { code: '221', description: 'Self-Generated Funds' },
+ { code: '222', description: 'Industry Grants and Agreements' },
+ { code: '223', description: 'Other Non-Federal Funds' },
+];
+
+export const FUND_SFNS = SFN_CATALOG.map((entry) => entry.code);
export const SFN_MULTIPLE = 'Multiple';
-const SEGMENTS_KEY = ['chartStringSegments'] as const;
+const SEGMENTS_KEY = ['segmentClassifications'] as const;
-export const chartStringSegmentsQueryOptions = () =>
+export const segmentClassificationsQueryOptions = () =>
queryOptions({
queryFn: () =>
- fetchJson('/api/chartstringsegments'),
+ fetchJson('/api/segmentclassifications'),
queryKey: SEGMENTS_KEY,
});
@@ -51,16 +72,16 @@ export const useUpdateSegmentClassification = () => {
return useMutation({
mutationFn: (input: UpdateClassificationInput) =>
- fetchJson('/api/chartstringsegments', {
+ fetchJson('/api/segmentclassifications', {
body: JSON.stringify(input),
method: 'PATCH',
}),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: SEGMENTS_KEY });
const previous =
- queryClient.getQueryData(SEGMENTS_KEY);
+ queryClient.getQueryData(SEGMENTS_KEY);
- queryClient.setQueryData(SEGMENTS_KEY, (old) =>
+ queryClient.setQueryData(SEGMENTS_KEY, (old) =>
(old ?? []).map((segment) =>
segment.segmentType === input.segmentType &&
segment.code === input.code
diff --git a/client/src/test/components/FlatFileImportPanel.test.tsx b/client/src/test/components/FlatFileImportPanel.test.tsx
index e6b9af8..3de16a0 100644
--- a/client/src/test/components/FlatFileImportPanel.test.tsx
+++ b/client/src/test/components/FlatFileImportPanel.test.tsx
@@ -601,11 +601,15 @@ describe('FlatFileImportPanel', () => {
],
errors: [],
rowNum: 2,
+ // Key order mirrors the flat file's column order, which the panel
+ // must preserve; keep it unsorted.
+ /* eslint-disable perfectionist/sort-objects */
values: {
ProjectNumber: 'PRJ-1',
AccessionNumber: 'A-2',
OrganizationName: '',
},
+ /* eslint-enable perfectionist/sort-objects */
},
],
succeeded: false,
diff --git a/client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx b/client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx
index 8068fd4..7b6f480 100644
--- a/client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx
+++ b/client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
-import type { ChartStringSegment } from '@/queries/chartStringSegments.ts';
+import type { SegmentClassification } from '@/queries/segmentClassifications.ts';
import { SegmentClassificationControl } from '@/components/dataClassification/SegmentClassificationControl.tsx';
-const unsetAccount: ChartStringSegment = {
+const unsetAccount: SegmentClassification = {
code: '500000',
description: 'Supplies',
hierarchy: [],
@@ -12,7 +12,7 @@ const unsetAccount: ChartStringSegment = {
sfn: null,
};
-const unsetFund: ChartStringSegment = {
+const unsetFund: SegmentClassification = {
code: '70575',
description: 'Berry',
hierarchy: [],
@@ -34,12 +34,33 @@ describe('SegmentClassificationControl', () => {
it('renders a dropdown with SFN options for a fund', () => {
render();
- expect(screen.getByRole('option', { name: '201' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: '201 - Hatch Funds' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Multiple' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Excluded' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Include' })).not.toBeInTheDocument();
});
+ it('shows SFN descriptions in the fund dropdown', () => {
+ render(
+
+ );
+ expect(
+ screen.getByRole('option', {
+ name: '204 - Contracts, Grants, Research Coop Agreements',
+ })
+ ).toBeInTheDocument();
+ });
+
it('calls onClassify with (true, sfn) when an SFN is selected', () => {
const onClassify = vi.fn();
render();
diff --git a/client/src/test/components/dataClassification/SegmentGrid.test.tsx b/client/src/test/components/dataClassification/SegmentGrid.test.tsx
index adfd935..ffa47a9 100644
--- a/client/src/test/components/dataClassification/SegmentGrid.test.tsx
+++ b/client/src/test/components/dataClassification/SegmentGrid.test.tsx
@@ -1,12 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
-import type { ChartStringSegment } from '@/queries/chartStringSegments.ts';
+import type { SegmentClassification } from '@/queries/segmentClassifications.ts';
import { SegmentGrid } from '@/components/dataClassification/SegmentGrid.tsx';
function account(
code: string,
includeInReport: boolean | null
-): ChartStringSegment {
+): SegmentClassification {
return { code, description: `Name ${code}`, hierarchy: [], includeInReport, segmentType: 'Account', sfn: null };
}
@@ -17,13 +17,13 @@ function isBefore(first: HTMLElement, second: HTMLElement): boolean {
);
}
-const fundSegments: ChartStringSegment[] = [
+const fundSegments: SegmentClassification[] = [
{
code: '45530',
description: 'AES State Appropriations',
hierarchy: [
- { code: 'STATE', level: '0', name: 'State Funds' },
- { code: 'APPROP', level: '1', name: 'Appropriations' },
+ { code: 'STATE', level: 'A', name: 'State Funds' },
+ { code: 'APPROP', level: 'B', name: 'Appropriations' },
],
includeInReport: true,
segmentType: 'Fund',
@@ -31,7 +31,7 @@ const fundSegments: ChartStringSegment[] = [
},
];
-const accountSegments: ChartStringSegment[] = [
+const accountSegments: SegmentClassification[] = [
{
code: '500000',
description: 'Supplies and Expense',
@@ -44,10 +44,10 @@ const accountSegments: ChartStringSegment[] = [
describe('SegmentGrid', () => {
it('renders a column per hierarchy level with the code and hover title', () => {
- render();
+ render();
- expect(screen.getByRole('columnheader', { name: 'Level 0' })).toBeInTheDocument();
- expect(screen.getByRole('columnheader', { name: 'Level 1' })).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'Level A' })).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'Level B' })).toBeInTheDocument();
const state = screen.getByText('STATE');
expect(state).toHaveAttribute('data-tip', 'State Funds');
@@ -55,8 +55,15 @@ describe('SegmentGrid', () => {
expect(screen.getByText('APPROP')).toHaveAttribute('data-tip', 'Appropriations');
});
+ it('renders the passed classification header', () => {
+ render();
+
+ expect(screen.getByRole('columnheader', { name: 'Is AES?' })).toBeInTheDocument();
+ expect(screen.queryByRole('columnheader', { name: 'Classification' })).not.toBeInTheDocument();
+ });
+
it('renders no level columns when no segment has a hierarchy', () => {
- render();
+ render();
expect(screen.queryByRole('columnheader', { name: /^Level / })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Include' })).toBeInTheDocument();
@@ -65,6 +72,7 @@ describe('SegmentGrid', () => {
it('sorts unclassified rows to the top by default', () => {
render(
{
it('keeps a row in place when it is classified in the same tab', () => {
const { rerender } = render(
{
// BBB gets classified: order is frozen for the tab, so it stays above AAA.
rerender(
tab.type === 'Fund')!;
+const deptTab = SEGMENT_TABS.find((tab) => tab.type === 'FinancialDepartment')!;
+
+const fund: SegmentClassification = {
+ code: '45530',
+ description: 'AES State Funds',
+ hierarchy: [
+ { code: 'STATE', level: 'A', name: 'State Funds' },
+ { code: 'APPROP', level: 'B', name: 'Appropriations' },
+ ],
+ includeInReport: true,
+ segmentType: 'Fund',
+ sfn: '220',
+};
+
+describe('buildSegmentExport', () => {
+ it('builds level columns from the letters present and maps rows', () => {
+ const { columns, filename, rows } = buildSegmentExport([fund], fundTab);
+
+ expect(filename).toBe('ad419-fund-classification.csv');
+ expect(columns.map((c) => c.header)).toEqual([
+ 'Code',
+ 'Name',
+ 'Level A Code',
+ 'Level A Name',
+ 'Level B Code',
+ 'Level B Name',
+ 'Classification',
+ 'SFN',
+ 'SFN Description',
+ ]);
+ expect(rows[0]).toMatchObject({
+ classification: 'Included',
+ code: '45530',
+ levelACode: 'STATE',
+ levelAName: 'State Funds',
+ name: 'AES State Funds',
+ sfn: '220',
+ sfnDescription: 'State Appropriations',
+ });
+ });
+
+ it('omits SFN columns for non-fund types and labels unset rows', () => {
+ const dept: SegmentClassification = {
+ ...fund,
+ code: 'APLS001',
+ hierarchy: [],
+ includeInReport: null,
+ segmentType: 'FinancialDepartment',
+ sfn: null,
+ };
+
+ const { columns, rows } = buildSegmentExport([dept], deptTab);
+
+ expect(columns.map((c) => c.header)).toEqual(['Code', 'Name', 'Classification']);
+ expect(rows[0].classification).toBe('Unset');
+ });
+});
diff --git a/client/src/test/components/dataClassification/segments.test.ts b/client/src/test/components/dataClassification/segments.test.ts
index 0d5610f..9fccb7a 100644
--- a/client/src/test/components/dataClassification/segments.test.ts
+++ b/client/src/test/components/dataClassification/segments.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import type { ChartStringSegment } from '@/queries/chartStringSegments.ts';
+import type { SegmentClassification } from '@/queries/segmentClassifications.ts';
import {
allClassified,
SEGMENT_TABS,
@@ -7,22 +7,34 @@ import {
unclassifiedCount,
} from '@/components/dataClassification/segments.ts';
-const segments: ChartStringSegment[] = [
+const segments: SegmentClassification[] = [
{ code: '45530', description: 'AES', hierarchy: [], includeInReport: true, segmentType: 'Fund', sfn: '220' },
{ code: '70575', description: 'Berry', hierarchy: [], includeInReport: null, segmentType: 'Fund', sfn: '219' },
{ code: '500000', description: 'S and E', hierarchy: [], includeInReport: null, segmentType: 'Account', sfn: null },
];
describe('data classification segment helpers', () => {
- it('exposes the tabs in display order, including ERN', () => {
- expect(SEGMENT_TABS.map((tab) => tab.type)).toEqual([
+ it('defines six tabs with headers, notes, and slugs', () => {
+ expect(SEGMENT_TABS.map((t) => t.type)).toEqual([
'FinancialDepartment',
'Account',
'Fund',
'Activity',
+ 'Purpose',
'Ern',
]);
- expect(SEGMENT_TABS.at(-1)).toEqual({ label: 'ERN', type: 'Ern' });
+ expect(SEGMENT_TABS.map((t) => t.classificationHeader)).toEqual([
+ 'Is AES?',
+ 'Include in AD419?',
+ 'SFN',
+ 'Include in AD419?',
+ 'Include in AD419?',
+ 'Include in FTE?',
+ ]);
+ for (const tab of SEGMENT_TABS) {
+ expect(tab.note.length).toBeGreaterThan(0);
+ expect(tab.slug).toMatch(/^[a-z-]+$/);
+ }
});
it('filters segments by type', () => {
diff --git a/client/src/test/routes/(authenticated)/dataClassification.test.tsx b/client/src/test/routes/(authenticated)/dataClassification.test.tsx
index 4371379..2682660 100644
--- a/client/src/test/routes/(authenticated)/dataClassification.test.tsx
+++ b/client/src/test/routes/(authenticated)/dataClassification.test.tsx
@@ -27,8 +27,8 @@ function mockApi() {
let current = [...segments];
server.use(
http.get('/api/user/me', () => HttpResponse.json(mockUser)),
- http.get('/api/chartstringsegments', () => HttpResponse.json(current)),
- http.patch('/api/chartstringsegments', async ({ request }) => {
+ http.get('/api/segmentclassifications', () => HttpResponse.json(current)),
+ http.patch('/api/segmentclassifications', async ({ request }) => {
const body = await request.json() as { code: string; includeInReport: boolean; segmentType: string; sfn: string | null };
current = current.map((s) =>
s.code === body.code ? { ...s, includeInReport: body.includeInReport } : s
diff --git a/database/data/Indexes/IX_AETransactions_Project.sql b/database/data/Indexes/IX_AETransactions_Project.sql
new file mode 100644
index 0000000..fc75ccf
--- /dev/null
+++ b/database/data/Indexes/IX_AETransactions_Project.sql
@@ -0,0 +1,2 @@
+CREATE NONCLUSTERED INDEX [IX_AETransactions_Project]
+ ON [data].[AETransactions] ([Project]);
diff --git a/database/data/Indexes/IX_UcPathTransactions_Account.sql b/database/data/Indexes/IX_UcPathTransactions_Account.sql
new file mode 100644
index 0000000..3754ef9
--- /dev/null
+++ b/database/data/Indexes/IX_UcPathTransactions_Account.sql
@@ -0,0 +1,2 @@
+CREATE NONCLUSTERED INDEX [IX_UcPathTransactions_Account]
+ ON [data].[UcPathTransactions] ([Account]);
diff --git a/database/data/Indexes/IX_UcPathTransactions_Project.sql b/database/data/Indexes/IX_UcPathTransactions_Project.sql
new file mode 100644
index 0000000..5442a69
--- /dev/null
+++ b/database/data/Indexes/IX_UcPathTransactions_Project.sql
@@ -0,0 +1,2 @@
+CREATE NONCLUSTERED INDEX [IX_UcPathTransactions_Project]
+ ON [data].[UcPathTransactions] ([Project]);
diff --git a/database/data/StoredProcedures/BuildProjects.sql b/database/data/StoredProcedures/BuildProjects.sql
new file mode 100644
index 0000000..578d07a
--- /dev/null
+++ b/database/data/StoredProcedures/BuildProjects.sql
@@ -0,0 +1,82 @@
+CREATE PROCEDURE [data].[BuildProjects]
+AS
+BEGIN
+ SET NOCOUNT ON;
+
+ -- Materializes the cycle's consolidated project list from ActiveProjects,
+ -- AllProjects and PGMProjects: one row per NIFA project x AE project pair.
+ -- Runs as an import stage after step 1 settles the project list; downstream
+ -- consumers (expense views, associations) read this table instead of
+ -- re-deriving the joins. SFN comes from the NIFA project number suffix;
+ -- NIFA/PGM SFN agreement is checked during Project Identification, so a
+ -- single SFN is stored.
+
+ IF NOT EXISTS (SELECT 1 FROM [data].[ActiveProjects])
+ THROW 50000, 'ActiveProjects is empty; complete Project Identification before building the project list.', 1;
+
+ -- Every included active project must map to a PGM master data record; an
+ -- unmatched project means Project Identification is not finished.
+ IF EXISTS
+ (
+ SELECT 1
+ FROM [data].[ActiveProjects] a
+ LEFT JOIN [data].[AllProjects] ap
+ ON ap.[ProjectNumber] = a.[ProjectNumber]
+ LEFT JOIN [data].[v_PgmProjectSfnBuckets] pc
+ ON ap.[AwardNumber] IS NOT NULL
+ AND pc.[AwardKey] = REPLACE(ap.[AwardNumber], '-', '')
+ WHERE ISNULL(a.[ExcludeFromUi], 0) = 0
+ AND pc.[ProjectNumber] IS NULL
+ )
+ THROW 50000, 'Active projects exist without a PGM master data match; resolve them in Project Identification first.', 1;
+
+ DELETE FROM [data].[Projects];
+
+ INSERT INTO [data].[Projects]
+ (
+ [AccessionNumber],
+ [NifaProjectNumber],
+ [NifaAwardNumber],
+ [Title],
+ [ProjectStartDate],
+ [ProjectEndDate],
+ [ProjectDirector],
+ [UcpEmployeeId],
+ [Is204],
+ [Sfn],
+ [AEProjectNumber],
+ [SponsorAwardNumber],
+ [PrincipalInvestigatorNames]
+ )
+ SELECT
+ a.[AccessionNumber],
+ a.[ProjectNumber],
+ ap.[AwardNumber],
+ ap.[Title],
+ ap.[ProjectStartDate],
+ ap.[ProjectEndDate],
+ a.[ProjectDirector],
+ a.[UcpEmployeeId],
+ a.[Is204],
+ CASE
+ WHEN a.[ProjectNumber] LIKE '%-H' THEN '201'
+ WHEN a.[ProjectNumber] LIKE '%-RR' THEN '202'
+ WHEN a.[ProjectNumber] LIKE '%-CG' THEN '204'
+ WHEN a.[ProjectNumber] LIKE '%-AH' THEN '205'
+ ELSE 'UNKNOWN' -- unrecognized suffix: fail closed, never silently classified
+ END,
+ pc.[ProjectNumber],
+ pc.[SponsorAwardNumber],
+ pgm.[PrincipalInvestigatorNames]
+ FROM [data].[ActiveProjects] a
+ JOIN [data].[AllProjects] ap
+ ON ap.[ProjectNumber] = a.[ProjectNumber]
+ JOIN [data].[v_PgmProjectSfnBuckets] pc
+ ON pc.[AwardKey] = REPLACE(ap.[AwardNumber], '-', '')
+ LEFT JOIN [data].[PGMProjects] pgm
+ ON pgm.[ProjectId] = pc.[ProjectId]
+ WHERE ISNULL(a.[ExcludeFromUi], 0) = 0;
+
+ -- Row count for the import run stage.
+ SELECT COUNT(*) AS ProjectRowsBuilt FROM [data].[Projects];
+END
diff --git a/database/data/StoredProcedures/ClassifyTransactions.sql b/database/data/StoredProcedures/ClassifyTransactions.sql
new file mode 100644
index 0000000..a6caae9
--- /dev/null
+++ b/database/data/StoredProcedures/ClassifyTransactions.sql
@@ -0,0 +1,69 @@
+CREATE PROCEDURE [data].[ClassifyTransactions]
+ @cycleStart DATE,
+ @cycleEnd DATE
+AS
+BEGIN
+ SET NOCOUNT ON;
+
+ -- Stamps the persisted exclusion columns on the imported transactions: the
+ -- rules step 2 classification does not own. Step 2 based exclusions (fund,
+ -- account, financial dept, activity, ern, purpose) are derived at read time
+ -- from SegmentClassifications and are never stamped here.
+
+ IF @cycleStart IS NULL OR @cycleEnd IS NULL
+ THROW 50000, '@cycleStart and @cycleEnd are required.', 1;
+
+ IF @cycleStart > @cycleEnd
+ THROW 50000, '@cycleStart must not be after @cycleEnd.', 1;
+
+ -- AccountNotInAE compares against the AE chart of accounts; an empty
+ -- reference table would flag every UCPath row, so fail loudly instead.
+ IF NOT EXISTS (SELECT 1 FROM [data].[ChartSegments] WHERE [SegmentName] = 'Account')
+ THROW 50000, 'ChartSegments has no Account rows; run the segment reference import first.', 1;
+
+ -- ExcludedByDate, AE: by accounting period membership, consistent with how
+ -- the rows are pulled (period_name list). The cycle months are generated as
+ -- period names ('Oct-24' style, culture pinned) the same way the import
+ -- builds its pull list; a row is in the cycle iff its period is in this
+ -- set, so buffer periods and NULLs are excluded by date.
+ DECLARE @cyclePeriods TABLE ([PeriodName] NVARCHAR(30) PRIMARY KEY);
+
+ DECLARE @month DATE = DATEFROMPARTS(YEAR(@cycleStart), MONTH(@cycleStart), 1);
+ WHILE @month <= @cycleEnd
+ BEGIN
+ INSERT INTO @cyclePeriods VALUES (FORMAT(@month, 'MMM-yy', 'en-US'));
+ SET @month = DATEADD(MONTH, 1, @month);
+ END;
+
+ UPDATE t
+ SET [ExcludedByDate] = CASE WHEN cp.[PeriodName] IS NULL THEN 1 ELSE 0 END
+ FROM [data].[AETransactions] t
+ LEFT JOIN @cyclePeriods cp ON cp.[PeriodName] = t.[PeriodName];
+
+ -- ExcludedByDate, UCPath: by pay period end date (authoritative; fiscal
+ -- year/period are unreliable on payroll corrections).
+ UPDATE [data].[UcPathTransactions]
+ SET [ExcludedByDate] =
+ CASE
+ WHEN CAST([PayPeriodEndDate] AS DATE) BETWEEN @cycleStart AND @cycleEnd THEN 0
+ ELSE 1
+ END;
+
+ -- AccountNotInAE: UCPath accounts that do not exist in the AE chart of
+ -- accounts (this should not happen but does). Missing accounts fail closed.
+ UPDATE t
+ SET [AccountNotInAE] =
+ CASE
+ WHEN t.[Account] IS NULL THEN 1
+ WHEN cs.[Code] IS NULL THEN 1
+ ELSE 0
+ END
+ FROM [data].[UcPathTransactions] t
+ LEFT JOIN [data].[ChartSegments] cs
+ ON cs.[SegmentName] = 'Account' AND cs.[Code] = t.[Account];
+
+ -- Row counts for the import run stage.
+ SELECT
+ (SELECT COUNT(*) FROM [data].[AETransactions]) AS AeRowsClassified,
+ (SELECT COUNT(*) FROM [data].[UcPathTransactions]) AS UcPathRowsClassified;
+END
diff --git a/database/data/StoredProcedures/SeedSegmentClassifications.sql b/database/data/StoredProcedures/SeedSegmentClassifications.sql
new file mode 100644
index 0000000..05a5aa0
--- /dev/null
+++ b/database/data/StoredProcedures/SeedSegmentClassifications.sql
@@ -0,0 +1,67 @@
+CREATE PROCEDURE [data].[SeedSegmentClassifications]
+AS
+BEGIN
+ SET NOCOUNT ON;
+
+ -- Insert any segment value present in the imported transactions but missing
+ -- from SegmentClassifications, as an unclassified row (IncludeInReport NULL).
+ -- Existing rows and their classifications are never modified: unclassified
+ -- fails closed downstream until someone classifies the code in step 2.
+ --
+ -- Descriptions come from the ChartSegments reference data where available.
+ -- ERN codes (UCPath earnings codes) have no local description source;
+ -- the UCPath import fills those in from the PeopleSoft earnings table.
+ -- 'XXX' is the placeholder ERN code on fringe rows, which carry no FTE, so
+ -- classifying it is meaningless and it is not seeded.
+
+ DECLARE @inserted TABLE ([SegmentType] NVARCHAR(20) NOT NULL);
+
+ WITH TransactionValues AS
+ (
+ SELECT 'Fund' AS SegmentType, [Fund] AS Code FROM [data].[AETransactions] WHERE [Fund] IS NOT NULL
+ UNION
+ SELECT 'Fund', [Fund] FROM [data].[UcPathTransactions] WHERE [Fund] IS NOT NULL
+ UNION
+ SELECT 'Account', [Account] FROM [data].[AETransactions] WHERE [Account] IS NOT NULL
+ UNION
+ SELECT 'Account', [Account] FROM [data].[UcPathTransactions] WHERE [Account] IS NOT NULL
+ UNION
+ SELECT 'FinancialDepartment', [FinancialDepartment] FROM [data].[AETransactions] WHERE [FinancialDepartment] IS NOT NULL
+ UNION
+ SELECT 'FinancialDepartment', [FinancialDepartment] FROM [data].[UcPathTransactions] WHERE [FinancialDepartment] IS NOT NULL
+ UNION
+ SELECT 'Activity', [Activity] FROM [data].[AETransactions] WHERE [Activity] IS NOT NULL
+ UNION
+ SELECT 'Activity', [Activity] FROM [data].[UcPathTransactions] WHERE [Activity] IS NOT NULL
+ UNION
+ SELECT 'Purpose', [Purpose] FROM [data].[AETransactions] WHERE [Purpose] IS NOT NULL
+ UNION
+ SELECT 'Purpose', [Purpose] FROM [data].[UcPathTransactions] WHERE [Purpose] IS NOT NULL
+ UNION
+ SELECT 'Ern', [ErnCode] FROM [data].[UcPathTransactions] WHERE [ErnCode] <> 'XXX'
+ )
+ INSERT INTO [data].[SegmentClassifications] ([SegmentType], [Code], [Description])
+ OUTPUT inserted.[SegmentType] INTO @inserted ([SegmentType])
+ SELECT
+ tv.SegmentType,
+ tv.Code,
+ LEFT(cs.[Description], 300)
+ FROM TransactionValues tv
+ LEFT JOIN [data].[ChartSegments] cs
+ ON cs.[SegmentName] = tv.SegmentType AND cs.[Code] = tv.Code
+ WHERE NOT EXISTS
+ (
+ SELECT 1
+ FROM [data].[SegmentClassifications] existing
+ WHERE existing.[SegmentType] = tv.SegmentType
+ AND existing.[Code] = tv.Code
+ );
+
+ -- One row per segment type with the number of newly seeded codes
+ -- (types with nothing new are omitted).
+ SELECT
+ CAST([SegmentType] AS NVARCHAR(20)) AS SegmentType,
+ COUNT(*) AS InsertedCount
+ FROM @inserted
+ GROUP BY [SegmentType];
+END
diff --git a/database/data/Tables/AETransactions.sql b/database/data/Tables/AETransactions.sql
new file mode 100644
index 0000000..81965e0
--- /dev/null
+++ b/database/data/Tables/AETransactions.sql
@@ -0,0 +1,49 @@
+CREATE TABLE [data].[AETransactions]
+(
+ [Id] BIGINT NOT NULL IDENTITY(1, 1),
+
+ -- Chart string segments (column names aligned with UcPathTransactions for a future union)
+ [Entity] NVARCHAR(50) NULL,
+ [Fund] NVARCHAR(50) NULL,
+ [FinancialDepartment] NVARCHAR(50) NULL,
+ [Account] NVARCHAR(50) NULL,
+ [Purpose] NVARCHAR(50) NULL,
+ [Program] NVARCHAR(50) NULL,
+ [Project] NVARCHAR(50) NULL,
+ [Activity] NVARCHAR(50) NULL,
+
+ [EntityDescription] NVARCHAR(400) NULL,
+ [FundDescription] NVARCHAR(400) NULL,
+ [FinancialDepartmentDescription] NVARCHAR(400) NULL,
+ [AccountDescription] NVARCHAR(400) NULL,
+ [PurposeDescription] NVARCHAR(400) NULL,
+ [ProgramDescription] NVARCHAR(400) NULL,
+ [ProjectDescription] NVARCHAR(400) NULL,
+ [ActivityDescription] NVARCHAR(400) NULL,
+
+ [DocumentType] NVARCHAR(10) NULL,
+ [AccountingSequenceNumber] BIGINT NULL,
+ [TrackingNo] NVARCHAR(300) NULL,
+ [Reference] NVARCHAR(300) NULL,
+ [JournalLineDescription] NVARCHAR(400) NULL,
+ [JournalAcctDate] DATE NULL,
+ [JournalName] NVARCHAR(200) NULL,
+ [JournalReference] NVARCHAR(160) NULL,
+ [PeriodName] NVARCHAR(30) NULL,
+ [JournalBatchName] NVARCHAR(200) NULL,
+ [JournalSource] NVARCHAR(50) NULL,
+ [JournalCategory] NVARCHAR(50) NULL,
+ [BatchStatus] NVARCHAR(2) NULL,
+
+ [Amount] DECIMAL(19, 4) NULL, -- actual_amount; import filters to actual_flag = 'A'
+ [CommitmentAmount] DECIMAL(19, 4) NULL,
+ [ObligationAmount] DECIMAL(19, 4) NULL,
+ [EtlLoadDt] DATETIME2(7) NULL,
+
+ -- Persisted exclusions for rules not owned by step 2 classification.
+ -- NULL = not yet classified (fails closed downstream).
+ [ExcludedByDate] BIT NULL,
+
+ [LoadedAt] DATETIME2(3) NULL CONSTRAINT [DF_AETransactions_LoadedAt] DEFAULT (SYSUTCDATETIME()),
+ CONSTRAINT [PK_AETransactions] PRIMARY KEY CLUSTERED ([Id])
+);
diff --git a/database/data/Tables/ChartSegments.sql b/database/data/Tables/ChartSegments.sql
new file mode 100644
index 0000000..1ebecfe
--- /dev/null
+++ b/database/data/Tables/ChartSegments.sql
@@ -0,0 +1,21 @@
+CREATE TABLE [data].[ChartSegments]
+(
+ [SegmentName] NVARCHAR(30) NOT NULL, -- Entity | Fund | FinancialDepartment | Account | Purpose | Program | Project | Activity
+ [Code] NVARCHAR(300) NOT NULL,
+ [ValueId] BIGINT NULL,
+ [Description] NVARCHAR(500) NULL,
+ [ValueDesc] NVARCHAR(800) NULL,
+ [HierarchyDepth] INT NULL,
+ [SummaryFlag] NVARCHAR(60) NULL,
+ [EnabledFlag] NVARCHAR(4) NULL,
+ [StartDateActive] DATE NULL,
+ [EndDateActive] DATE NULL,
+ [ParentLevel0Code] NVARCHAR(200) NULL,
+ [ParentLevel1Code] NVARCHAR(200) NULL,
+ [ParentLevel2Code] NVARCHAR(200) NULL,
+ [ParentLevel3Code] NVARCHAR(200) NULL,
+ [ParentLevel4Code] NVARCHAR(200) NULL,
+ [ParentLevel5Code] NVARCHAR(200) NULL,
+ [LoadedAt] DATETIME2(3) NULL CONSTRAINT [DF_ChartSegments_LoadedAt] DEFAULT (SYSUTCDATETIME()),
+ CONSTRAINT [PK_ChartSegments] PRIMARY KEY CLUSTERED ([SegmentName], [Code])
+);
diff --git a/database/data/Tables/Projects.sql b/database/data/Tables/Projects.sql
new file mode 100644
index 0000000..cc8165d
--- /dev/null
+++ b/database/data/Tables/Projects.sql
@@ -0,0 +1,26 @@
+CREATE TABLE [data].[Projects]
+(
+ [Id] BIGINT NOT NULL IDENTITY(1, 1),
+
+ -- NIFA side (from ActiveProjects joined to AllProjects)
+ [AccessionNumber] NVARCHAR(7) NOT NULL,
+ [NifaProjectNumber] NVARCHAR(20) NOT NULL,
+ [NifaAwardNumber] NVARCHAR(16) NULL,
+ [Title] NVARCHAR(MAX) NULL,
+ [ProjectStartDate] DATE NULL,
+ [ProjectEndDate] DATE NULL,
+ [ProjectDirector] NVARCHAR(200) NULL,
+ [UcpEmployeeId] NVARCHAR(8) NULL,
+ [Is204] BIT NOT NULL,
+ [Sfn] NVARCHAR(7) NOT NULL, -- from project number suffix: 201 | 202 | 204 | 205 | UNKNOWN
+
+ -- AE side (from PGMProjects via award number match). Required: every
+ -- included active project has a PGM master data record by build time, and
+ -- NIFA/PGM SFN agreement is checked during Project Identification.
+ [AEProjectNumber] NVARCHAR(50) NOT NULL,
+ [SponsorAwardNumber] NVARCHAR(100) NULL,
+ [PrincipalInvestigatorNames] NVARCHAR(MAX) NULL,
+
+ [LoadedAt] DATETIME2(3) NULL CONSTRAINT [DF_Projects_LoadedAt] DEFAULT (SYSUTCDATETIME()),
+ CONSTRAINT [PK_Projects] PRIMARY KEY CLUSTERED ([Id])
+);
diff --git a/database/data/Tables/PurposeHierarchy.sql b/database/data/Tables/PurposeHierarchy.sql
new file mode 100644
index 0000000..78532c2
--- /dev/null
+++ b/database/data/Tables/PurposeHierarchy.sql
@@ -0,0 +1,19 @@
+CREATE TABLE [data].[PurposeHierarchy]
+(
+ [Code] NVARCHAR(50) NOT NULL,
+ [Description] NVARCHAR(1000) NULL,
+ [ParentLevel0Code] VARCHAR(20) NULL,
+ [ParentLevel0Name] NVARCHAR(1000) NULL,
+ [ParentLevel1Code] VARCHAR(20) NULL,
+ [ParentLevel1Name] NVARCHAR(1000) NULL,
+ [ParentLevel2Code] VARCHAR(20) NULL,
+ [ParentLevel2Name] NVARCHAR(1000) NULL,
+ [ParentLevel3Code] VARCHAR(20) NULL,
+ [ParentLevel3Name] NVARCHAR(1000) NULL,
+ [ParentLevel4Code] VARCHAR(20) NULL,
+ [ParentLevel4Name] NVARCHAR(1000) NULL,
+ [ParentLevel5Code] VARCHAR(20) NULL,
+ [ParentLevel5Name] NVARCHAR(1000) NULL,
+ [LoadedAt] DATETIME2(3) NOT NULL CONSTRAINT [DF_PurposeHierarchy_LoadedAt] DEFAULT (SYSUTCDATETIME()),
+ CONSTRAINT [PK_PurposeHierarchy] PRIMARY KEY CLUSTERED ([Code])
+);
diff --git a/database/data/Tables/ChartStringSegments.sql b/database/data/Tables/SegmentClassifications.sql
similarity index 55%
rename from database/data/Tables/ChartStringSegments.sql
rename to database/data/Tables/SegmentClassifications.sql
index e1116d7..e7ac733 100644
--- a/database/data/Tables/ChartStringSegments.sql
+++ b/database/data/Tables/SegmentClassifications.sql
@@ -1,10 +1,10 @@
-CREATE TABLE [data].[ChartStringSegments]
+CREATE TABLE [data].[SegmentClassifications]
(
- [SegmentType] NVARCHAR(20) NOT NULL, -- FinancialDepartment | Account | Fund | Activity
+ [SegmentType] NVARCHAR(20) NOT NULL, -- FinancialDepartment | Account | Fund | Activity | Ern
[Code] NVARCHAR(50) NOT NULL,
[Description] NVARCHAR(300) NULL,
[IncludeInReport] BIT NULL, -- NULL = unclassified, needs review
[Sfn] NVARCHAR(10) NULL, -- SFN code (201..223) or 'Multiple'; only for Fund
- CONSTRAINT [PK_ChartStringSegments] PRIMARY KEY CLUSTERED ([SegmentType], [Code]),
- CONSTRAINT [CK_ChartStringSegments_Sfn] CHECK ([Sfn] IS NULL OR [SegmentType] = 'Fund')
+ CONSTRAINT [PK_SegmentClassifications] PRIMARY KEY CLUSTERED ([SegmentType], [Code]),
+ CONSTRAINT [CK_SegmentClassifications_Sfn] CHECK ([Sfn] IS NULL OR [SegmentType] = 'Fund')
);
diff --git a/database/data/Tables/UcPathTransactions.sql b/database/data/Tables/UcPathTransactions.sql
new file mode 100644
index 0000000..da9db73
--- /dev/null
+++ b/database/data/Tables/UcPathTransactions.sql
@@ -0,0 +1,52 @@
+CREATE TABLE [data].[UcPathTransactions]
+(
+ -- Composite natural key from the source: journal id, journal line, addl seq,
+ -- emplid, empl_rcd, erncd ('XXX' for fringe rows), run id.
+ [LaborTransactionId] NVARCHAR(125) NOT NULL,
+
+ -- Chart string segments (column names aligned with AETransactions for a future
+ -- union). Warehouse profiling (salary and fringe views, 2026-07) shows every
+ -- segment except Program fully populated on the filtered population, so only
+ -- Program stays nullable. The source encodes empty as a blank string, so the
+ -- import must trim and blank-to-NULL on the way in for the constraints to mean
+ -- anything.
+ [Entity] NVARCHAR(50) NOT NULL,
+ [Fund] NVARCHAR(50) NOT NULL,
+ [FinancialDepartment] NVARCHAR(50) NOT NULL,
+ [ParentDepartment] NVARCHAR(50) NOT NULL,
+ [Account] NVARCHAR(50) NOT NULL,
+ [Purpose] NVARCHAR(50) NOT NULL,
+ [Program] NVARCHAR(50) NULL,
+ [Project] NVARCHAR(50) NOT NULL,
+ [Activity] NVARCHAR(50) NOT NULL,
+
+ [FinanceDocTypeCd] NVARCHAR(4) NULL,
+ -- Components of the natural key are required.
+ [ErnCode] NVARCHAR(3) NOT NULL, -- ERNCD for salary rows, 'XXX' for fringe rows (source ERNCD is blank on most fringe)
+ [EmployeeId] NVARCHAR(10) NOT NULL,
+ [EmployeeName] NVARCHAR(100) NULL,
+ [PositionNumber] NVARCHAR(8) NOT NULL, -- rare source rows without one are excluded at import
+ [EffDt] DATETIME2(7) NULL,
+ [JobCode] NVARCHAR(4) NULL, -- blank at source for many rows; backfilled by the title-code step
+ [RateTypeCd] NVARCHAR(1) NULL,
+ [Hours] DECIMAL(18, 6) NOT NULL, -- 0 on fringe rows (source fringe view has no hours)
+ [Amount] DECIMAL(19, 4) NOT NULL,
+ [PayRate] DECIMAL(17, 4) NULL, -- amount/hours; undefined on fringe rows
+ [CalculatedFte] DECIMAL(9, 6) NOT NULL, -- hours / hours in federal fiscal year (2088 or 2096); 0 on fringe rows
+ [PayPeriodEndDate] DATETIME2(7) NOT NULL, -- authoritative date for cycle-window logic (fiscal year/period unreliable on corrections)
+ [FringeBenefitSalaryCd] NVARCHAR(1) NOT NULL, -- 'S' salary, 'F' fringe (assigned by the import per source view)
+ [PaidPercent] DECIMAL(7, 4) NULL, -- salary rows only; source fringe view has no percent columns
+ [ErnDerivedPercent] DECIMAL(7, 4) NULL, -- salary rows only
+ [FiscalYear] INT NOT NULL, -- PeopleSoft fiscal bookkeeping, kept for QA totals by period
+ [Period] NVARCHAR(2) NOT NULL,
+ [EmpRcd] SMALLINT NOT NULL,
+ [EffSeq] SMALLINT NULL,
+
+ -- Persisted exclusions for rules not owned by step 2 classification.
+ -- NULL = not yet classified (fails closed downstream).
+ [ExcludedByDate] BIT NULL,
+ [AccountNotInAE] BIT NULL,
+
+ [LoadedAt] DATETIME2(3) NULL CONSTRAINT [DF_UcPathTransactions_LoadedAt] DEFAULT (SYSUTCDATETIME()),
+ CONSTRAINT [PK_UcPathTransactions] PRIMARY KEY CLUSTERED ([LaborTransactionId])
+);
diff --git a/database/data/Views/v_PgmProjectSfnBuckets.sql b/database/data/Views/v_PgmProjectSfnBuckets.sql
new file mode 100644
index 0000000..0ef8d0f
--- /dev/null
+++ b/database/data/Views/v_PgmProjectSfnBuckets.sql
@@ -0,0 +1,32 @@
+CREATE VIEW [data].[v_PgmProjectSfnBuckets]
+AS
+-- Each PGM award classified to an SFN bucket from its CFDA (same logic as the
+-- PgmClassified CTE in [data].[GetProjectList]). The CFDA is zero-padded to
+-- NN.NNN first because the warehouse stores 10.310 as the number 10.31, then
+-- joined to the ALN catalog. When the catalog has no match (or is not yet
+-- loaded) the bucket is NULL.
+SELECT
+ pgm.ProjectId,
+ pgm.ProjectNumber,
+ pgm.SponsorAwardNumber,
+ REPLACE(pgm.SponsorAwardNumber, '-', '') AS AwardKey,
+ CASE
+ WHEN aln.ProgramNumber IS NULL THEN NULL
+ WHEN aln.ProgramNumber = '10.203' THEN 'HATCH' -- Hatch, matches NIFA 201/202
+ WHEN aln.ProgramNumber = '10.202' THEN '203' -- McIntire-Stennis
+ WHEN aln.ProgramNumber IN ('10.205', '10.207') THEN '205' -- Evans-Allen / Animal Health
+ WHEN aln.FederalAgency030 LIKE 'NATIONAL INSTITUTE OF FOOD AND AGRICULTURE%' THEN '204' -- NIFA competitive
+ ELSE 'NON-NIFA'
+ END AS PgmSfnBucket
+FROM [data].[PGMProjects] pgm
+OUTER APPLY
+(
+ SELECT CASE
+ WHEN CHARINDEX('.', pgm.Cfda) > 0
+ THEN LEFT(pgm.Cfda, CHARINDEX('.', pgm.Cfda))
+ + LEFT(SUBSTRING(pgm.Cfda, CHARINDEX('.', pgm.Cfda) + 1, 10) + '000', 3)
+ ELSE pgm.Cfda
+ END AS PaddedCfda
+) p
+LEFT JOIN [data].[AssistanceListingNumbers] aln
+ ON aln.ProgramNumber = p.PaddedCfda;
diff --git a/server.core/Data/DataDbContext.cs b/server.core/Data/DataDbContext.cs
index 275aea3..582410c 100644
--- a/server.core/Data/DataDbContext.cs
+++ b/server.core/Data/DataDbContext.cs
@@ -7,12 +7,13 @@ public class DataDbContext(DbContextOptions options) : DbContext(
{
public const string DataSchema = "data";
- public DbSet ChartStringSegments => Set();
+ public DbSet SegmentClassifications => Set();
public DbSet DepartmentHierarchies => Set();
public DbSet AccountHierarchies => Set();
public DbSet FundHierarchies => Set();
public DbSet ActivityHierarchies => Set();
+ public DbSet PurposeHierarchies => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -20,9 +21,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.HasDefaultSchema(DataSchema);
- modelBuilder.Entity(entity =>
+ modelBuilder.Entity(entity =>
{
- entity.ToTable("ChartStringSegments", DataSchema, table => table.ExcludeFromMigrations());
+ entity.ToTable("SegmentClassifications", DataSchema, table => table.ExcludeFromMigrations());
entity.HasKey(segment => new { segment.SegmentType, segment.Code });
entity.Property(segment => segment.SegmentType).HasConversion().HasMaxLength(20);
entity.Property(segment => segment.Code).HasMaxLength(50);
@@ -34,6 +35,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
ConfigureHierarchy(modelBuilder, "AccountHierarchy");
ConfigureHierarchy(modelBuilder, "FundHierarchy");
ConfigureHierarchy(modelBuilder, "ActivityHierarchy");
+ ConfigureHierarchy(modelBuilder, "PurposeHierarchy");
}
private static void ConfigureHierarchy(ModelBuilder modelBuilder, string table)
@@ -49,7 +51,7 @@ private static void ConfigureHierarchy(ModelBuilder modelBuilder, string tabl
{
var maxLength = property.Name switch
{
- // Match ChartStringSegment.Code so joins/lookups on Code stay aligned.
+ // Match SegmentClassification.Code so joins/lookups on Code stay aligned.
nameof(ISegmentHierarchy.Code) => 50,
"Description" => 1000,
_ when property.Name.EndsWith("Name") => 1000,
diff --git a/server.core/Data/DbInitializer.cs b/server.core/Data/DbInitializer.cs
index a57a13e..d3fb22d 100644
--- a/server.core/Data/DbInitializer.cs
+++ b/server.core/Data/DbInitializer.cs
@@ -40,7 +40,7 @@ private async Task SeedDevelopmentAsync(CancellationToken ct)
{
// Hierarchy first: chart-string segments are derived from the hierarchy tables.
await HierarchySeed.EnsureSeededAsync(_dataDb, ct);
- await ChartStringSegmentSeed.EnsureSeededAsync(_dataDb, ct);
+ await SegmentClassificationSeed.EnsureSeededAsync(_dataDb, ct);
}
// just a placeholder for any production-safe seeding
diff --git a/server.core/Data/HierarchySeed.cs b/server.core/Data/HierarchySeed.cs
index d82f065..b2511f9 100644
--- a/server.core/Data/HierarchySeed.cs
+++ b/server.core/Data/HierarchySeed.cs
@@ -16,17 +16,22 @@ public static async Task EnsureSeededAsync(DataDbContext db, CancellationToken c
{
if (!await db.AccountHierarchies.AnyAsync(ct))
{
- db.AccountHierarchies.AddRange(SeedCsv.ReadRows("AccountHierarchy.csv", AccountFrom));
+ db.AccountHierarchies.AddRange(SeedCsv.ReadRows("AccountHierarchy.csv", f => AccountFrom(WithoutSelfLevels(f))));
}
if (!await db.FundHierarchies.AnyAsync(ct))
{
- db.FundHierarchies.AddRange(SeedCsv.ReadRows("FundHierarchy.csv", FundFrom));
+ db.FundHierarchies.AddRange(SeedCsv.ReadRows("FundHierarchy.csv", f => FundFrom(WithoutSelfLevels(f))));
}
if (!await db.ActivityHierarchies.AnyAsync(ct))
{
- db.ActivityHierarchies.AddRange(SeedCsv.ReadRows("ActivityHierarchy.csv", ActivityFrom));
+ db.ActivityHierarchies.AddRange(SeedCsv.ReadRows("ActivityHierarchy.csv", f => ActivityFrom(WithoutSelfLevels(f))));
+ }
+
+ if (!await db.PurposeHierarchies.AnyAsync(ct))
+ {
+ db.PurposeHierarchies.AddRange(SeedCsv.ReadRows("PurposeHierarchy.csv", f => PurposeFrom(WithoutSelfLevels(f))));
}
if (!await db.DepartmentHierarchies.AnyAsync(ct))
@@ -37,6 +42,23 @@ public static async Task EnsureSeededAsync(DataDbContext db, CancellationToken c
await db.SaveChangesAsync(ct);
}
+ // A level cell that repeats the row's own code is a storage artifact of the
+ // warehouse's fixed-width hierarchy format and carries no information.
+ // Blank it (code and name) at load so stored rows are clean for every consumer.
+ private static string[] WithoutSelfLevels(string[] f)
+ {
+ for (var i = 2; i <= 12; i += 2)
+ {
+ if (i < f.Length && f[i].Trim() == f[0].Trim())
+ {
+ f[i] = string.Empty;
+ if (i + 1 < f.Length) { f[i + 1] = string.Empty; }
+ }
+ }
+
+ return f;
+ }
+
private static AccountHierarchy AccountFrom(string[] f) => new()
{
Code = f[0],
@@ -73,6 +95,18 @@ public static async Task EnsureSeededAsync(DataDbContext db, CancellationToken c
ParentLevel5Code = SeedCsv.Nullable(f[12]), ParentLevel5Name = SeedCsv.Nullable(f[13]),
};
+ private static PurposeHierarchy PurposeFrom(string[] f) => new()
+ {
+ Code = f[0],
+ Description = SeedCsv.Nullable(f[1]),
+ ParentLevel0Code = SeedCsv.Nullable(f[2]), ParentLevel0Name = SeedCsv.Nullable(f[3]),
+ ParentLevel1Code = SeedCsv.Nullable(f[4]), ParentLevel1Name = SeedCsv.Nullable(f[5]),
+ ParentLevel2Code = SeedCsv.Nullable(f[6]), ParentLevel2Name = SeedCsv.Nullable(f[7]),
+ ParentLevel3Code = SeedCsv.Nullable(f[8]), ParentLevel3Name = SeedCsv.Nullable(f[9]),
+ ParentLevel4Code = SeedCsv.Nullable(f[10]), ParentLevel4Name = SeedCsv.Nullable(f[11]),
+ ParentLevel5Code = SeedCsv.Nullable(f[12]), ParentLevel5Name = SeedCsv.Nullable(f[13]),
+ };
+
// DepartmentSource.csv columns:
// 0 D code, 1 D name, 2 E code, 3 E name, 4 F code, 5 F name, 6 G code, 7 G name, ... (fact columns ignored)
private static List DepartmentRows() =>
@@ -89,7 +123,6 @@ private static List DepartmentRows() =>
ParentLevelDCode = SeedCsv.Nullable(f[0]), ParentLevelDName = SeedCsv.Nullable(f[1]),
ParentLevelECode = SeedCsv.Nullable(f[2]), ParentLevelEName = SeedCsv.Nullable(f[3]),
ParentLevelFCode = SeedCsv.Nullable(f[4]), ParentLevelFName = SeedCsv.Nullable(f[5]),
- ParentLevelGCode = SeedCsv.Nullable(f[6]), ParentLevelGName = SeedCsv.Nullable(f[7]),
})
.ToList();
}
diff --git a/server.core/Data/Seed/ErnCodes.csv b/server.core/Data/Seed/ErnCodes.csv
index 8ba0505..9124700 100644
--- a/server.core/Data/Seed/ErnCodes.csv
+++ b/server.core/Data/Seed/ErnCodes.csv
@@ -1,4 +1,4 @@
-DOS_Code,Description,IncludeInAD419FTE,IsNewInUCP
+ErnCode,Description,IncludeInAD419FTE,IsNewInUCP
8EF,Retro-EPSL-8EF,true,1
901,Retro Regular Pay-Non Base,false,1
92L,Retro Holiday-Premium Hourly,false,
diff --git a/server.core/Data/Seed/PurposeHierarchy.csv b/server.core/Data/Seed/PurposeHierarchy.csv
new file mode 100644
index 0000000..938d9ff
--- /dev/null
+++ b/server.core/Data/Seed/PurposeHierarchy.csv
@@ -0,0 +1,19 @@
+Code,Description,ParentLevel0Code,ParentLevel0Name,ParentLevel1Code,ParentLevel1Name,ParentLevel2Code,ParentLevel2Name,ParentLevel3Code,ParentLevel3Name,ParentLevel4Code,ParentLevel4Name,ParentLevel5Code,ParentLevel5Name
+00,Purpose Default,1A,Purpose Categories,PD,Purpose Value Default,,,,,,,,
+40,Instruction Dept Research,1A,Purpose Categories,1C,Instruction C,,,,,,,,
+41,Summer Session,1A,Purpose Categories,1C,Instruction C,,,,,,,,
+42,Teaching Hospitals,1A,Purpose Categories,1E,Teaching Hospitals B,,,,,,,,
+43,Academic Support,1A,Purpose Categories,1B,Academic Support B,,,,,,,,
+44,Research,1A,Purpose Categories,1D,Organized Research D,,,,,,,,
+45,AES Research,1A,Purpose Categories,1D,Organized Research D,,,,,,,,
+60,Libraries,1A,Purpose Categories,1B,Academic Support B,,,,,,,,
+61,University Extension,1A,Purpose Categories,1C,Instruction C,,,,,,,,
+62,Public Service,1A,Purpose Categories,1F,Public Service B,,,,,,,,
+64,Operations Maintenance of Plant,1A,Purpose Categories,1G,Operations Maintenance of Plant B,,,,,,,,
+65,Depreciation,1A,Purpose Categories,1H,Depreciation B,,,,,,,,
+66,Impairment,1A,Purpose Categories,1I,Impairment B,,,,,,,,
+68,Student Services,1A,Purpose Categories,1J,Student Services B,,,,,,,,
+72,Institutional Support General Administration,1A,Purpose Categories,1L,Institutional Support General Administration B,,,,,,,,
+76,Auxiliary Enterprises,1A,Purpose Categories,1M,Auxiliary Enterprises B,,,,,,,,
+78,Student Financial Aid,1A,Purpose Categories,1K,Student Financial Aid B,,,,,,,,
+80,Provisions for Allocations,1A,Purpose Categories,1P,Provisions for Allocations B,,,,,,,,
diff --git a/server.core/Data/ChartStringSegmentSeed.cs b/server.core/Data/SegmentClassificationSeed.cs
similarity index 65%
rename from server.core/Data/ChartStringSegmentSeed.cs
rename to server.core/Data/SegmentClassificationSeed.cs
index 471e6ee..e22d227 100644
--- a/server.core/Data/ChartStringSegmentSeed.cs
+++ b/server.core/Data/SegmentClassificationSeed.cs
@@ -9,7 +9,7 @@ namespace Server.Core.Data;
/// Segments start unclassified (IncludeInReport = null); a couple per type are
/// pre-classified so the grid shows a mix of unset and classified rows.
///
-public static class ChartStringSegmentSeed
+public static class SegmentClassificationSeed
{
// Leave roughly this many rows per segment type unclassified so there is still
// work to do; the rest are classified (include/exclude) below.
@@ -19,30 +19,31 @@ public static class ChartStringSegmentSeed
// "Multiple" marker). Used to give seeded, included funds a real SFN so the Fund
// dropdown reflects a classified state rather than reading "Unset".
private static readonly string[] FundSfnPool =
- ["201", "202", "203", "205", "220", "221", "223", "Multiple"];
+ ["201", "202", "203", "204", "205", "209", "219", "220", "221", "222", "223", "Multiple"];
public static async Task EnsureSeededAsync(DataDbContext db, CancellationToken ct = default)
{
- if (await db.ChartStringSegments.AnyAsync(ct))
+ if (await db.SegmentClassifications.AnyAsync(ct))
{
return;
}
- var segments = new List();
+ var segments = new List();
segments.AddRange(await BuildAsync(db.AccountHierarchies, SegmentType.Account, ct));
segments.AddRange(await BuildAsync(db.FundHierarchies, SegmentType.Fund, ct));
segments.AddRange(await BuildAsync(db.ActivityHierarchies, SegmentType.Activity, ct));
segments.AddRange(await BuildAsync(db.DepartmentHierarchies, SegmentType.FinancialDepartment, ct));
segments.AddRange(BuildErn());
+ segments.AddRange(await BuildPurposeAsync(db, ct));
- db.ChartStringSegments.AddRange(segments);
+ db.SegmentClassifications.AddRange(segments);
await db.SaveChangesAsync(ct);
}
- // ErnCodes.csv columns: 0 DOS_Code, 1 Description, 2 IncludeInAD419FTE, 3 IsNewInUCP.
- // IncludeInAD419FTE seeds the classification directly, except the first few (by DOS
+ // ErnCodes.csv columns: 0 ErnCode, 1 Description, 2 IncludeInAD419FTE, 3 IsNewInUCP.
+ // IncludeInAD419FTE seeds the classification directly, except the first few (by ERN
// code) which stay unset for demo. IsNewInUCP is not consumed yet.
- private static List BuildErn()
+ private static List BuildErn()
{
var rows = SeedCsv.ReadRows("ErnCodes.csv", fields => (
Code: fields[0].Trim(),
@@ -51,7 +52,7 @@ private static List BuildErn()
return rows
.OrderBy(row => row.Code, StringComparer.Ordinal)
- .Select((row, index) => new ChartStringSegment
+ .Select((row, index) => new SegmentClassification
{
SegmentType = SegmentType.Ern,
Code = row.Code,
@@ -61,7 +62,33 @@ private static List BuildErn()
.ToList();
}
- private static async Task> BuildAsync(
+ // Purpose classification defaults follow the 2025 processing rules: the known
+ // exclusion list is excluded, research purposes are included, and codes the 2025
+ // process never ruled on stay unset so they surface for review (fail closed).
+ private static readonly IReadOnlySet ExcludedPurposes =
+ new HashSet { "00", "40", "43", "60", "61", "62", "72", "76", "78", "80" };
+
+ private static readonly IReadOnlySet IncludedPurposes =
+ new HashSet { "44", "45" };
+
+ private static async Task> BuildPurposeAsync(DataDbContext db, CancellationToken ct)
+ {
+ var rows = await db.PurposeHierarchies.ToListAsync(ct);
+ return rows
+ .OrderBy(row => row.Code, StringComparer.Ordinal)
+ .Select(row => new SegmentClassification
+ {
+ SegmentType = SegmentType.Purpose,
+ Code = row.Code,
+ Description = row.Description,
+ IncludeInReport = ExcludedPurposes.Contains(row.Code) ? false
+ : IncludedPurposes.Contains(row.Code) ? true
+ : null,
+ })
+ .ToList();
+ }
+
+ private static async Task> BuildAsync(
DbSet hierarchy,
SegmentType type,
CancellationToken ct)
@@ -82,7 +109,7 @@ private static async Task> BuildAsync(
? FundSfnPool[(StableHash(row.Code) & int.MaxValue) % FundSfnPool.Length]
: null;
- return new ChartStringSegment
+ return new SegmentClassification
{
SegmentType = type,
Code = row.Code,
diff --git a/server.core/Domain/AccountHierarchy.cs b/server.core/Domain/AccountHierarchy.cs
index a54784d..608ced2 100644
--- a/server.core/Domain/AccountHierarchy.cs
+++ b/server.core/Domain/AccountHierarchy.cs
@@ -21,12 +21,12 @@ public IReadOnlyList Levels() =>
[
.. new (string Level, string? Code, string? Name)[]
{
- ("0", ParentLevel0Code, ParentLevel0Name),
- ("1", ParentLevel1Code, ParentLevel1Name),
- ("2", ParentLevel2Code, ParentLevel2Name),
- ("3", ParentLevel3Code, ParentLevel3Name),
- ("4", ParentLevel4Code, ParentLevel4Name),
- ("5", ParentLevel5Code, ParentLevel5Name),
+ ("A", ParentLevel0Code, ParentLevel0Name),
+ ("B", ParentLevel1Code, ParentLevel1Name),
+ ("C", ParentLevel2Code, ParentLevel2Name),
+ ("D", ParentLevel3Code, ParentLevel3Name),
+ ("E", ParentLevel4Code, ParentLevel4Name),
+ ("F", ParentLevel5Code, ParentLevel5Name),
}
.Where(l => l.Code is not null)
.Select(l => new HierarchyLevel(l.Level, l.Code!, l.Name)),
diff --git a/server.core/Domain/ActivityHierarchy.cs b/server.core/Domain/ActivityHierarchy.cs
index 7988a95..6716b68 100644
--- a/server.core/Domain/ActivityHierarchy.cs
+++ b/server.core/Domain/ActivityHierarchy.cs
@@ -21,12 +21,12 @@ public IReadOnlyList Levels() =>
[
.. new (string Level, string? Code, string? Name)[]
{
- ("0", ParentLevel0Code, ParentLevel0Name),
- ("1", ParentLevel1Code, ParentLevel1Name),
- ("2", ParentLevel2Code, ParentLevel2Name),
- ("3", ParentLevel3Code, ParentLevel3Name),
- ("4", ParentLevel4Code, ParentLevel4Name),
- ("5", ParentLevel5Code, ParentLevel5Name),
+ ("A", ParentLevel0Code, ParentLevel0Name),
+ ("B", ParentLevel1Code, ParentLevel1Name),
+ ("C", ParentLevel2Code, ParentLevel2Name),
+ ("D", ParentLevel3Code, ParentLevel3Name),
+ ("E", ParentLevel4Code, ParentLevel4Name),
+ ("F", ParentLevel5Code, ParentLevel5Name),
}
.Where(l => l.Code is not null)
.Select(l => new HierarchyLevel(l.Level, l.Code!, l.Name)),
diff --git a/server.core/Domain/FundHierarchy.cs b/server.core/Domain/FundHierarchy.cs
index cde659f..f6faabf 100644
--- a/server.core/Domain/FundHierarchy.cs
+++ b/server.core/Domain/FundHierarchy.cs
@@ -21,12 +21,12 @@ public IReadOnlyList Levels() =>
[
.. new (string Level, string? Code, string? Name)[]
{
- ("0", ParentLevel0Code, ParentLevel0Name),
- ("1", ParentLevel1Code, ParentLevel1Name),
- ("2", ParentLevel2Code, ParentLevel2Name),
- ("3", ParentLevel3Code, ParentLevel3Name),
- ("4", ParentLevel4Code, ParentLevel4Name),
- ("5", ParentLevel5Code, ParentLevel5Name),
+ ("A", ParentLevel0Code, ParentLevel0Name),
+ ("B", ParentLevel1Code, ParentLevel1Name),
+ ("C", ParentLevel2Code, ParentLevel2Name),
+ ("D", ParentLevel3Code, ParentLevel3Name),
+ ("E", ParentLevel4Code, ParentLevel4Name),
+ ("F", ParentLevel5Code, ParentLevel5Name),
}
.Where(l => l.Code is not null)
.Select(l => new HierarchyLevel(l.Level, l.Code!, l.Name)),
diff --git a/server.core/Domain/PurposeHierarchy.cs b/server.core/Domain/PurposeHierarchy.cs
new file mode 100644
index 0000000..11a4510
--- /dev/null
+++ b/server.core/Domain/PurposeHierarchy.cs
@@ -0,0 +1,34 @@
+namespace Server.Core.Domain;
+
+public class PurposeHierarchy : ISegmentHierarchy
+{
+ public string Code { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string? ParentLevel0Code { get; set; }
+ public string? ParentLevel0Name { get; set; }
+ public string? ParentLevel1Code { get; set; }
+ public string? ParentLevel1Name { get; set; }
+ public string? ParentLevel2Code { get; set; }
+ public string? ParentLevel2Name { get; set; }
+ public string? ParentLevel3Code { get; set; }
+ public string? ParentLevel3Name { get; set; }
+ public string? ParentLevel4Code { get; set; }
+ public string? ParentLevel4Name { get; set; }
+ public string? ParentLevel5Code { get; set; }
+ public string? ParentLevel5Name { get; set; }
+
+ public IReadOnlyList Levels() =>
+ [
+ .. new (string Level, string? Code, string? Name)[]
+ {
+ ("A", ParentLevel0Code, ParentLevel0Name),
+ ("B", ParentLevel1Code, ParentLevel1Name),
+ ("C", ParentLevel2Code, ParentLevel2Name),
+ ("D", ParentLevel3Code, ParentLevel3Name),
+ ("E", ParentLevel4Code, ParentLevel4Name),
+ ("F", ParentLevel5Code, ParentLevel5Name),
+ }
+ .Where(l => l.Code is not null)
+ .Select(l => new HierarchyLevel(l.Level, l.Code!, l.Name)),
+ ];
+}
diff --git a/server.core/Domain/ChartStringSegment.cs b/server.core/Domain/SegmentClassification.cs
similarity index 88%
rename from server.core/Domain/ChartStringSegment.cs
rename to server.core/Domain/SegmentClassification.cs
index b57e0f1..928123a 100644
--- a/server.core/Domain/ChartStringSegment.cs
+++ b/server.core/Domain/SegmentClassification.cs
@@ -7,9 +7,10 @@ public enum SegmentType
Fund,
Activity,
Ern,
+ Purpose,
}
-public class ChartStringSegment
+public class SegmentClassification
{
public SegmentType SegmentType { get; set; }
diff --git a/server/Controllers/ChartStringSegmentsController.cs b/server/Controllers/SegmentClassificationsController.cs
similarity index 79%
rename from server/Controllers/ChartStringSegmentsController.cs
rename to server/Controllers/SegmentClassificationsController.cs
index 8c8272c..113726f 100644
--- a/server/Controllers/ChartStringSegmentsController.cs
+++ b/server/Controllers/SegmentClassificationsController.cs
@@ -2,29 +2,30 @@
using Microsoft.EntityFrameworkCore;
using Server.Core.Data;
using Server.Core.Domain;
-using Server.Models.ChartStringSegments;
+using Server.Models.SegmentClassifications;
namespace Server.Controllers;
-public class ChartStringSegmentsController : ApiControllerBase
+public class SegmentClassificationsController : ApiControllerBase
{
private readonly DataDbContext _db;
- public ChartStringSegmentsController(DataDbContext db)
+ public SegmentClassificationsController(DataDbContext db)
{
_db = db;
}
- // GET api/chartstringsegments
+ // GET api/segmentclassifications
[HttpGet]
- public async Task>> Get(CancellationToken cancellationToken)
+ public async Task>> Get(CancellationToken cancellationToken)
{
- var segments = await _db.ChartStringSegments.ToListAsync(cancellationToken);
+ var segments = await _db.SegmentClassifications.ToListAsync(cancellationToken);
var departments = await _db.DepartmentHierarchies.ToDictionaryAsync(h => h.Code, cancellationToken);
var accounts = await _db.AccountHierarchies.ToDictionaryAsync(h => h.Code, cancellationToken);
var funds = await _db.FundHierarchies.ToDictionaryAsync(h => h.Code, cancellationToken);
var activities = await _db.ActivityHierarchies.ToDictionaryAsync(h => h.Code, cancellationToken);
+ var purposes = await _db.PurposeHierarchies.ToDictionaryAsync(h => h.Code, cancellationToken);
IReadOnlyList HierarchyFor(SegmentType type, string code)
{
@@ -34,6 +35,7 @@ IReadOnlyList HierarchyFor(SegmentType type, string code)
SegmentType.Account => accounts.GetValueOrDefault(code),
SegmentType.Fund => funds.GetValueOrDefault(code),
SegmentType.Activity => activities.GetValueOrDefault(code),
+ SegmentType.Purpose => purposes.GetValueOrDefault(code),
_ => null,
};
@@ -45,7 +47,7 @@ IReadOnlyList HierarchyFor(SegmentType type, string code)
var dtos = segments
.OrderBy(segment => segment.SegmentType)
.ThenBy(segment => segment.Code)
- .Select(segment => new ChartStringSegmentDto(
+ .Select(segment => new SegmentClassificationDto(
segment.SegmentType.ToString(),
segment.Code,
segment.Description,
@@ -57,7 +59,7 @@ IReadOnlyList HierarchyFor(SegmentType type, string code)
return Ok(dtos);
}
- // PATCH api/chartstringsegments
+ // PATCH api/segmentclassifications
[HttpPatch]
public async Task UpdateClassification(
[FromBody] UpdateClassificationRequest request,
@@ -68,7 +70,7 @@ public async Task UpdateClassification(
return BadRequest($"Unknown segment type '{request.SegmentType}'.");
}
- var segment = await _db.ChartStringSegments.FirstOrDefaultAsync(
+ var segment = await _db.SegmentClassifications.FirstOrDefaultAsync(
candidate => candidate.SegmentType == segmentType && candidate.Code == request.Code,
cancellationToken);
diff --git a/server/Models/ChartStringSegments/FundSfns.cs b/server/Models/SegmentClassifications/FundSfns.cs
similarity index 68%
rename from server/Models/ChartStringSegments/FundSfns.cs
rename to server/Models/SegmentClassifications/FundSfns.cs
index 7bdb94a..74b6aff 100644
--- a/server/Models/ChartStringSegments/FundSfns.cs
+++ b/server/Models/SegmentClassifications/FundSfns.cs
@@ -1,11 +1,11 @@
-namespace Server.Models.ChartStringSegments;
+namespace Server.Models.SegmentClassifications;
public static class FundSfns
{
public const string MultipleMarker = "Multiple";
public static readonly IReadOnlySet Codes =
- new HashSet { "201", "202", "203", "205", "220", "221", "223" };
+ SfnCatalog.Entries.Select(e => e.Code).ToHashSet();
public static bool IsValidForInclusion(string? sfn) =>
sfn is not null && (Codes.Contains(sfn) || sfn == MultipleMarker);
diff --git a/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs b/server/Models/SegmentClassifications/SegmentClassificationDtos.cs
similarity index 79%
rename from server/Models/ChartStringSegments/ChartStringSegmentDtos.cs
rename to server/Models/SegmentClassifications/SegmentClassificationDtos.cs
index 433bc49..b49664d 100644
--- a/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs
+++ b/server/Models/SegmentClassifications/SegmentClassificationDtos.cs
@@ -1,6 +1,6 @@
-namespace Server.Models.ChartStringSegments;
+namespace Server.Models.SegmentClassifications;
-public sealed record ChartStringSegmentDto(
+public sealed record SegmentClassificationDto(
string SegmentType,
string Code,
string? Description,
diff --git a/server/Models/SegmentClassifications/SfnCatalog.cs b/server/Models/SegmentClassifications/SfnCatalog.cs
new file mode 100644
index 0000000..fa43add
--- /dev/null
+++ b/server/Models/SegmentClassifications/SfnCatalog.cs
@@ -0,0 +1,26 @@
+namespace Server.Models.SegmentClassifications;
+
+public sealed record SfnEntry(string Code, string Description);
+
+///
+/// The AD419 fund SFN catalog (State Funding Number codes and their line display
+/// descriptors). Code and description are always separate fields; display composes
+/// them. Source: AD419 report line descriptors.
+///
+public static class SfnCatalog
+{
+ public static readonly IReadOnlyList Entries =
+ [
+ new("201", "Hatch Funds"),
+ new("202", "Multi-State Research Funds"),
+ new("203", "McIntire-Stennis Funds"),
+ new("204", "Contracts, Grants, Research Coop Agreements"),
+ new("205", "OtherFunds(AnimalHealthSec1433,Evans-Allen)"),
+ new("209", "National Science Foundation"),
+ new("219", "USDA Contracts, Grants, Coop Agreements"),
+ new("220", "State Appropriations"),
+ new("221", "Self-Generated Funds"),
+ new("222", "Industry Grants and Agreements"),
+ new("223", "Other Non-Federal Funds"),
+ ];
+}
diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs
deleted file mode 100644
index b328296..0000000
--- a/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using FluentAssertions;
-using Microsoft.EntityFrameworkCore;
-using Server.Core.Data;
-using Server.Core.Domain;
-
-namespace Server.Tests.ChartStringSegments;
-
-public class ChartStringSegmentSeedTests
-{
- [Fact]
- public async Task EnsureSeeded_derives_one_segment_per_hierarchy_row()
- {
- using var db = TestDbContextFactory.CreateDataInMemory();
- await HierarchySeed.EnsureSeededAsync(db);
-
- await ChartStringSegmentSeed.EnsureSeededAsync(db);
-
- var expected =
- await db.AccountHierarchies.CountAsync() +
- await db.FundHierarchies.CountAsync() +
- await db.ActivityHierarchies.CountAsync() +
- await db.DepartmentHierarchies.CountAsync() +
- db.ChartStringSegments.Count(segment => segment.SegmentType == SegmentType.Ern);
- (await db.ChartStringSegments.CountAsync()).Should().Be(expected);
- }
-
- [Fact]
- public async Task EnsureSeeded_loads_ern_codes_from_csv()
- {
- using var db = TestDbContextFactory.CreateDataInMemory();
- await HierarchySeed.EnsureSeededAsync(db);
-
- await ChartStringSegmentSeed.EnsureSeededAsync(db);
-
- var ern = db.ChartStringSegments
- .Where(segment => segment.SegmentType == SegmentType.Ern)
- .ToList();
- ern.Count.Should().BeGreaterThan(200);
-
- // REG (Regular Pay) has IncludeInAD419FTE = true and is not in the first 10 by code.
- var reg = ern.Single(segment => segment.Code == "REG");
- reg.IncludeInReport.Should().BeTrue();
- reg.Description.Should().Be("Regular Pay");
-
- // The first 10 by ordinal DOS code are left unset for demo.
- ern.OrderBy(segment => segment.Code, StringComparer.Ordinal)
- .Take(10)
- .All(segment => segment.IncludeInReport == null)
- .Should().BeTrue();
- }
-
- [Fact]
- public async Task EnsureSeeded_maps_segment_types_to_their_hierarchy()
- {
- using var db = TestDbContextFactory.CreateDataInMemory();
- await HierarchySeed.EnsureSeededAsync(db);
-
- await ChartStringSegmentSeed.EnsureSeededAsync(db);
-
- // Account 500000 comes from AccountHierarchy, so it is an Account segment.
- var account = await db.ChartStringSegments.FindAsync(SegmentType.Account, "500000");
- account.Should().NotBeNull();
- // Fund 71549 comes from FundHierarchy.
- (await db.ChartStringSegments.FindAsync(SegmentType.Fund, "71549")).Should().NotBeNull();
- }
-
- [Fact]
- public async Task EnsureSeeded_is_idempotent()
- {
- using var db = TestDbContextFactory.CreateDataInMemory();
- await HierarchySeed.EnsureSeededAsync(db);
-
- await ChartStringSegmentSeed.EnsureSeededAsync(db);
- var count = await db.ChartStringSegments.CountAsync();
- await ChartStringSegmentSeed.EnsureSeededAsync(db);
-
- (await db.ChartStringSegments.CountAsync()).Should().Be(count);
- }
-}
diff --git a/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs b/tests/server.tests/SegmentClassifications/HierarchyMappingTests.cs
similarity index 59%
rename from tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs
rename to tests/server.tests/SegmentClassifications/HierarchyMappingTests.cs
index 722499b..9c80f2b 100644
--- a/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs
+++ b/tests/server.tests/SegmentClassifications/HierarchyMappingTests.cs
@@ -2,7 +2,7 @@
using Microsoft.EntityFrameworkCore;
using Server.Core.Domain;
-namespace Server.Tests.ChartStringSegments;
+namespace Server.Tests.SegmentClassifications;
public class HierarchyMappingTests
{
@@ -39,6 +39,42 @@ public void Department_levels_return_ordered_non_null_pairs()
new HierarchyLevel("B", "DIV1", "Division One"));
}
+ [Fact]
+ public void Fund_levels_use_letter_keys()
+ {
+ var fund = new FundHierarchy
+ {
+ Code = "45530",
+ ParentLevel0Code = "TOP", ParentLevel0Name = "Top",
+ ParentLevel1Code = "MID", ParentLevel1Name = "Mid",
+ };
+
+ fund.Levels().Select(l => l.Level).Should().Equal("A", "B");
+ }
+
+ [Fact]
+ public void Account_and_activity_levels_use_letter_keys()
+ {
+ var account = new AccountHierarchy { Code = "500000", ParentLevel0Code = "4X", ParentLevel5Code = "DEEP" };
+ var activity = new ActivityHierarchy { Code = "000000", ParentLevel0Code = "ROOT" };
+
+ account.Levels().Select(l => l.Level).Should().Equal("A", "F");
+ activity.Levels().Select(l => l.Level).Should().Equal("A");
+ }
+
+ [Fact]
+ public void Purpose_levels_use_letter_keys()
+ {
+ var purpose = new PurposeHierarchy
+ {
+ Code = "44",
+ ParentLevel0Code = "1A", ParentLevel0Name = "Purpose Categories",
+ ParentLevel1Code = "1D", ParentLevel1Name = "Organized Research D",
+ };
+
+ purpose.Levels().Select(l => l.Level).Should().Equal("A", "B");
+ }
+
[Fact]
public void Fund_level_properties_have_max_lengths()
{
@@ -48,7 +84,7 @@ public void Fund_level_properties_have_max_lengths()
entity.FindProperty("ParentLevel0Code")!.GetMaxLength().Should().Be(20);
entity.FindProperty("ParentLevel0Name")!.GetMaxLength().Should().Be(1000);
entity.FindProperty("Description")!.GetMaxLength().Should().Be(1000);
- // Code matches ChartStringSegment.Code (NVARCHAR(50)) so joins on Code align.
+ // Code matches SegmentClassification.Code (NVARCHAR(50)) so joins on Code align.
entity.FindProperty("Code")!.GetMaxLength().Should().Be(50);
}
}
diff --git a/tests/server.tests/ChartStringSegments/HierarchySeedTests.cs b/tests/server.tests/SegmentClassifications/HierarchySeedTests.cs
similarity index 51%
rename from tests/server.tests/ChartStringSegments/HierarchySeedTests.cs
rename to tests/server.tests/SegmentClassifications/HierarchySeedTests.cs
index 90e3bfb..ee674bf 100644
--- a/tests/server.tests/ChartStringSegments/HierarchySeedTests.cs
+++ b/tests/server.tests/SegmentClassifications/HierarchySeedTests.cs
@@ -3,7 +3,7 @@
using Server.Core.Data;
using Server.Core.Domain;
-namespace Server.Tests.ChartStringSegments;
+namespace Server.Tests.SegmentClassifications;
public class HierarchySeedTests
{
@@ -23,7 +23,7 @@ public async Task EnsureSeeded_loads_rows_from_the_embedded_csvs()
}
[Fact]
- public async Task EnsureSeeded_synthesizes_department_levels_a_through_g()
+ public async Task EnsureSeeded_synthesizes_department_levels_a_through_f()
{
using var db = TestDbContextFactory.CreateDataInMemory();
@@ -31,13 +31,49 @@ public async Task EnsureSeeded_synthesizes_department_levels_a_through_g()
var department = await db.DepartmentHierarchies.FindAsync("ANUT006");
department.Should().NotBeNull();
- // Fabricated top levels plus the real D-G chain from the source file.
+ // Fabricated top levels plus the real D-F chain from the source file.
+ // Level G is omitted: it always repeats the row's own code.
department!.Levels().Select(level => level.Level)
- .Should().Equal("A", "B", "C", "D", "E", "F", "G");
+ .Should().Equal("A", "B", "C", "D", "E", "F");
department.ParentLevelACode.Should().Be("UCD");
department.ParentLevelDCode.Should().Be("ACL100D");
}
+ [Fact]
+ public async Task Seeds_purpose_hierarchy_leaves()
+ {
+ using var db = TestDbContextFactory.CreateDataInMemory();
+
+ await HierarchySeed.EnsureSeededAsync(db);
+
+ db.PurposeHierarchies.Should().HaveCount(18);
+ var research = db.PurposeHierarchies.Single(p => p.Code == "44");
+ research.Levels().Should().Equal(
+ new HierarchyLevel("A", "1A", "Purpose Categories"),
+ new HierarchyLevel("B", "1D", "Organized Research D"));
+ }
+
+ [Fact]
+ public async Task Seeding_nulls_levels_that_repeat_the_rows_own_code()
+ {
+ using var db = TestDbContextFactory.CreateDataInMemory();
+
+ await HierarchySeed.EnsureSeededAsync(db);
+
+ // The account CSV repeats the leaf code at the deepest level (e.g. 400900).
+ var account = db.AccountHierarchies.Single(a => a.Code == "400900");
+ account.ParentLevel5Code.Should().BeNull();
+ account.ParentLevel5Name.Should().BeNull();
+
+ // No account row keeps any level equal to its own code.
+ db.AccountHierarchies.AsEnumerable()
+ .Should().OnlyContain(a => a.Levels().All(l => l.Code != a.Code));
+
+ // Department rows no longer carry the self-referencing G level.
+ db.DepartmentHierarchies.AsEnumerable()
+ .Should().OnlyContain(d => d.Levels().All(l => l.Code != d.Code));
+ }
+
[Fact]
public async Task EnsureSeeded_is_idempotent()
{
diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs b/tests/server.tests/SegmentClassifications/SegmentClassificationMappingTests.cs
similarity index 56%
rename from tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs
rename to tests/server.tests/SegmentClassifications/SegmentClassificationMappingTests.cs
index 45d0ee7..c21ce59 100644
--- a/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs
+++ b/tests/server.tests/SegmentClassifications/SegmentClassificationMappingTests.cs
@@ -3,22 +3,22 @@
using Server.Core.Data;
using Server.Core.Domain;
-namespace Server.Tests.ChartStringSegments;
+namespace Server.Tests.SegmentClassifications;
-public class ChartStringSegmentMappingTests
+public class SegmentClassificationMappingTests
{
[Fact]
public void Maps_to_data_schema_with_composite_key()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- var entityType = db.Model.FindEntityType(typeof(ChartStringSegment));
+ var entityType = db.Model.FindEntityType(typeof(SegmentClassification));
entityType.Should().NotBeNull();
entityType!.GetSchema().Should().Be("data");
- entityType.GetTableName().Should().Be("ChartStringSegments");
+ entityType.GetTableName().Should().Be("SegmentClassifications");
entityType.FindPrimaryKey()!.Properties.Select(p => p.Name)
- .Should().Equal(nameof(ChartStringSegment.SegmentType), nameof(ChartStringSegment.Code));
+ .Should().Equal(nameof(SegmentClassification.SegmentType), nameof(SegmentClassification.Code));
}
[Fact]
@@ -26,8 +26,8 @@ public void Stores_segment_type_as_string()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- var property = db.Model.FindEntityType(typeof(ChartStringSegment))!
- .FindProperty(nameof(ChartStringSegment.SegmentType));
+ var property = db.Model.FindEntityType(typeof(SegmentClassification))!
+ .FindProperty(nameof(SegmentClassification.SegmentType));
property!.GetProviderClrType().Should().Be(typeof(string));
}
@@ -37,8 +37,8 @@ public void Sfn_column_allows_ten_characters()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- var property = db.Model.FindEntityType(typeof(ChartStringSegment))!
- .FindProperty(nameof(ChartStringSegment.Sfn));
+ var property = db.Model.FindEntityType(typeof(SegmentClassification))!
+ .FindProperty(nameof(SegmentClassification.Sfn));
property!.GetMaxLength().Should().Be(10);
}
diff --git a/tests/server.tests/SegmentClassifications/SegmentClassificationSeedTests.cs b/tests/server.tests/SegmentClassifications/SegmentClassificationSeedTests.cs
new file mode 100644
index 0000000..7a2ebaa
--- /dev/null
+++ b/tests/server.tests/SegmentClassifications/SegmentClassificationSeedTests.cs
@@ -0,0 +1,101 @@
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Server.Core.Data;
+using Server.Core.Domain;
+
+namespace Server.Tests.SegmentClassifications;
+
+public class SegmentClassificationSeedTests
+{
+ [Fact]
+ public async Task EnsureSeeded_derives_one_segment_per_hierarchy_row()
+ {
+ using var db = TestDbContextFactory.CreateDataInMemory();
+ await HierarchySeed.EnsureSeededAsync(db);
+
+ await SegmentClassificationSeed.EnsureSeededAsync(db);
+
+ var expected =
+ await db.AccountHierarchies.CountAsync() +
+ await db.FundHierarchies.CountAsync() +
+ await db.ActivityHierarchies.CountAsync() +
+ await db.DepartmentHierarchies.CountAsync() +
+ await db.PurposeHierarchies.CountAsync() +
+ db.SegmentClassifications.Count(segment => segment.SegmentType == SegmentType.Ern);
+ (await db.SegmentClassifications.CountAsync()).Should().Be(expected);
+ }
+
+ [Fact]
+ public async Task EnsureSeeded_loads_ern_codes_from_csv()
+ {
+ using var db = TestDbContextFactory.CreateDataInMemory();
+ await HierarchySeed.EnsureSeededAsync(db);
+
+ await SegmentClassificationSeed.EnsureSeededAsync(db);
+
+ var ern = db.SegmentClassifications
+ .Where(segment => segment.SegmentType == SegmentType.Ern)
+ .ToList();
+ ern.Count.Should().BeGreaterThan(200);
+
+ // REG (Regular Pay) has IncludeInAD419FTE = true and is not in the first 10 by code.
+ var reg = ern.Single(segment => segment.Code == "REG");
+ reg.IncludeInReport.Should().BeTrue();
+ reg.Description.Should().Be("Regular Pay");
+
+ // The first 10 by ordinal ERN code are left unset for demo.
+ ern.OrderBy(segment => segment.Code, StringComparer.Ordinal)
+ .Take(10)
+ .All(segment => segment.IncludeInReport == null)
+ .Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task EnsureSeeded_maps_segment_types_to_their_hierarchy()
+ {
+ using var db = TestDbContextFactory.CreateDataInMemory();
+ await HierarchySeed.EnsureSeededAsync(db);
+
+ await SegmentClassificationSeed.EnsureSeededAsync(db);
+
+ // Account 500000 comes from AccountHierarchy, so it is an Account segment.
+ var account = await db.SegmentClassifications.FindAsync(SegmentType.Account, "500000");
+ account.Should().NotBeNull();
+ // Fund 71549 comes from FundHierarchy.
+ (await db.SegmentClassifications.FindAsync(SegmentType.Fund, "71549")).Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task Seeds_purpose_classifications_with_2025_defaults()
+ {
+ using var db = TestDbContextFactory.CreateDataInMemory();
+ await HierarchySeed.EnsureSeededAsync(db);
+
+ await SegmentClassificationSeed.EnsureSeededAsync(db);
+
+ var purposes = db.SegmentClassifications
+ .Where(s => s.SegmentType == SegmentType.Purpose)
+ .ToDictionary(s => s.Code, s => s.IncludeInReport);
+
+ purposes.Should().HaveCount(18);
+ string[] excluded = ["00", "40", "43", "60", "61", "62", "72", "76", "78", "80"];
+ string[] included = ["44", "45"];
+ string[] unset = ["41", "42", "64", "65", "66", "68"];
+ excluded.Should().OnlyContain(code => purposes[code] == false);
+ included.Should().OnlyContain(code => purposes[code] == true);
+ unset.Should().OnlyContain(code => purposes[code] == null);
+ }
+
+ [Fact]
+ public async Task EnsureSeeded_is_idempotent()
+ {
+ using var db = TestDbContextFactory.CreateDataInMemory();
+ await HierarchySeed.EnsureSeededAsync(db);
+
+ await SegmentClassificationSeed.EnsureSeededAsync(db);
+ var count = await db.SegmentClassifications.CountAsync();
+ await SegmentClassificationSeed.EnsureSeededAsync(db);
+
+ (await db.SegmentClassifications.CountAsync()).Should().Be(count);
+ }
+}
diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs b/tests/server.tests/SegmentClassifications/SegmentClassificationsControllerTests.cs
similarity index 63%
rename from tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs
rename to tests/server.tests/SegmentClassifications/SegmentClassificationsControllerTests.cs
index 129561d..bb29649 100644
--- a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs
+++ b/tests/server.tests/SegmentClassifications/SegmentClassificationsControllerTests.cs
@@ -2,26 +2,26 @@
using Microsoft.AspNetCore.Mvc;
using Server.Controllers;
using Server.Core.Domain;
-using Server.Models.ChartStringSegments;
+using Server.Models.SegmentClassifications;
-namespace Server.Tests.ChartStringSegments;
+namespace Server.Tests.SegmentClassifications;
-public class ChartStringSegmentsControllerTests
+public class SegmentClassificationsControllerTests
{
[Fact]
public async Task Get_returns_all_segments()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.AddRange(
- new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", Description = "AES", IncludeInReport = true, Sfn = "220" },
- new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", Description = "S and E", IncludeInReport = null });
+ db.SegmentClassifications.AddRange(
+ new SegmentClassification { SegmentType = SegmentType.Fund, Code = "45530", Description = "AES", IncludeInReport = true, Sfn = "220" },
+ new SegmentClassification { SegmentType = SegmentType.Account, Code = "500000", Description = "S and E", IncludeInReport = null });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.Get(CancellationToken.None);
var ok = result.Result.Should().BeOfType().Subject;
- ok.Value.Should().BeAssignableTo>()
+ ok.Value.Should().BeAssignableTo>()
.Which.Should().HaveCount(2);
}
@@ -29,7 +29,7 @@ public async Task Get_returns_all_segments()
public async Task Get_includes_hierarchy_for_segment_with_matching_code()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", IncludeInReport = true, Sfn = "220" });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Fund, Code = "45530", IncludeInReport = true, Sfn = "220" });
db.FundHierarchies.Add(new FundHierarchy
{
Code = "45530",
@@ -37,28 +37,29 @@ public async Task Get_includes_hierarchy_for_segment_with_matching_code()
ParentLevel1Code = "APPROP", ParentLevel1Name = "Appropriations",
});
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.Get(CancellationToken.None);
var ok = result.Result.Should().BeOfType().Subject;
- var dto = ok.Value.Should().BeAssignableTo>().Subject.Single();
+ var dto = ok.Value.Should().BeAssignableTo>().Subject.Single();
dto.Hierarchy.Should().Equal(
- new HierarchyLevelDto("0", "STATE", "State Funds"),
- new HierarchyLevelDto("1", "APPROP", "Appropriations"));
+ new HierarchyLevelDto("A", "STATE", "State Funds"),
+ new HierarchyLevelDto("B", "APPROP", "Appropriations"));
}
[Theory]
[InlineData(SegmentType.FinancialDepartment, "A")]
- [InlineData(SegmentType.Account, "0")]
- [InlineData(SegmentType.Fund, "0")]
- [InlineData(SegmentType.Activity, "0")]
+ [InlineData(SegmentType.Account, "A")]
+ [InlineData(SegmentType.Fund, "A")]
+ [InlineData(SegmentType.Activity, "A")]
+ [InlineData(SegmentType.Purpose, "A")]
public async Task Get_reads_hierarchy_from_the_matching_segment_types_own_table(
SegmentType segmentType, string expectedLevel)
{
using var db = TestDbContextFactory.CreateDataInMemory();
const string code = "12345";
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = segmentType, Code = code, IncludeInReport = null });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = segmentType, Code = code, IncludeInReport = null });
switch (segmentType)
{
@@ -86,15 +87,21 @@ public async Task Get_reads_hierarchy_from_the_matching_segment_types_own_table(
Code = code, ParentLevel0Code = "X", ParentLevel0Name = "XName",
});
break;
+ case SegmentType.Purpose:
+ db.PurposeHierarchies.Add(new PurposeHierarchy
+ {
+ Code = code, ParentLevel0Code = "X", ParentLevel0Name = "XName",
+ });
+ break;
}
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.Get(CancellationToken.None);
var ok = result.Result.Should().BeOfType().Subject;
- var dto = ok.Value.Should().BeAssignableTo>().Subject.Single();
+ var dto = ok.Value.Should().BeAssignableTo>().Subject.Single();
dto.Hierarchy.Should().Equal(new HierarchyLevelDto(expectedLevel, "X", "XName"));
}
@@ -102,14 +109,14 @@ public async Task Get_reads_hierarchy_from_the_matching_segment_types_own_table(
public async Task Get_returns_empty_hierarchy_when_no_matching_row()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", IncludeInReport = null });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Account, Code = "500000", IncludeInReport = null });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.Get(CancellationToken.None);
var ok = result.Result.Should().BeOfType().Subject;
- var dto = ok.Value.Should().BeAssignableTo>().Subject.Single();
+ var dto = ok.Value.Should().BeAssignableTo>().Subject.Single();
dto.Hierarchy.Should().BeEmpty();
}
@@ -117,15 +124,15 @@ public async Task Get_returns_empty_hierarchy_when_no_matching_row()
public async Task Patch_updates_include_flag()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null, Sfn = "219" });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null, Sfn = "219" });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Fund", "70575", true, "201"), CancellationToken.None);
result.Should().BeOfType();
- var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "70575");
+ var updated = await db.SegmentClassifications.FindAsync(SegmentType.Fund, "70575");
updated!.IncludeInReport.Should().BeTrue();
updated.Sfn.Should().Be("201");
}
@@ -134,7 +141,7 @@ public async Task Patch_updates_include_flag()
public async Task Patch_returns_not_found_for_missing_segment()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Fund", "00000", false, null), CancellationToken.None);
@@ -146,7 +153,7 @@ public async Task Patch_returns_not_found_for_missing_segment()
public async Task Patch_returns_bad_request_for_unknown_segment_type()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Nonsense", "00000", false, null), CancellationToken.None);
@@ -158,15 +165,15 @@ public async Task Patch_returns_bad_request_for_unknown_segment_type()
public async Task Patch_sets_sfn_for_included_fund()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Fund", "70575", true, "220"), CancellationToken.None);
result.Should().BeOfType();
- var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "70575");
+ var updated = await db.SegmentClassifications.FindAsync(SegmentType.Fund, "70575");
updated!.IncludeInReport.Should().BeTrue();
updated.Sfn.Should().Be("220");
}
@@ -175,15 +182,15 @@ public async Task Patch_sets_sfn_for_included_fund()
public async Task Patch_clears_sfn_when_fund_excluded()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", IncludeInReport = true, Sfn = "220" });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Fund, Code = "45530", IncludeInReport = true, Sfn = "220" });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Fund", "45530", false, null), CancellationToken.None);
result.Should().BeOfType();
- var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "45530");
+ var updated = await db.SegmentClassifications.FindAsync(SegmentType.Fund, "45530");
updated!.IncludeInReport.Should().BeFalse();
updated.Sfn.Should().BeNull();
}
@@ -192,9 +199,9 @@ public async Task Patch_clears_sfn_when_fund_excluded()
public async Task Patch_rejects_invalid_sfn_for_included_fund()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Fund", "70575", true, "999"), CancellationToken.None);
@@ -206,9 +213,9 @@ public async Task Patch_rejects_invalid_sfn_for_included_fund()
public async Task Patch_rejects_sfn_on_non_fund_segment()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", IncludeInReport = null });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Account, Code = "500000", IncludeInReport = null });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Account", "500000", true, "201"), CancellationToken.None);
@@ -220,15 +227,15 @@ public async Task Patch_rejects_sfn_on_non_fund_segment()
public async Task Patch_accepts_multiple_marker_for_included_fund()
{
using var db = TestDbContextFactory.CreateDataInMemory();
- db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null });
+ db.SegmentClassifications.Add(new SegmentClassification { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null });
await db.SaveChangesAsync();
- var controller = new ChartStringSegmentsController(db);
+ var controller = new SegmentClassificationsController(db);
var result = await controller.UpdateClassification(
new UpdateClassificationRequest("Fund", "70575", true, "Multiple"), CancellationToken.None);
result.Should().BeOfType();
- var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "70575");
+ var updated = await db.SegmentClassifications.FindAsync(SegmentType.Fund, "70575");
updated!.IncludeInReport.Should().BeTrue();
updated.Sfn.Should().Be("Multiple");
}
diff --git a/tests/server.tests/SegmentClassifications/SfnCatalogTests.cs b/tests/server.tests/SegmentClassifications/SfnCatalogTests.cs
new file mode 100644
index 0000000..0d86977
--- /dev/null
+++ b/tests/server.tests/SegmentClassifications/SfnCatalogTests.cs
@@ -0,0 +1,31 @@
+using FluentAssertions;
+using Server.Models.SegmentClassifications;
+
+namespace Server.Tests.SegmentClassifications;
+
+public class SfnCatalogTests
+{
+ [Fact]
+ public void Catalog_contains_the_eleven_sfn_codes_in_order()
+ {
+ SfnCatalog.Entries.Select(e => e.Code).Should().Equal(
+ "201", "202", "203", "204", "205", "209", "219", "220", "221", "222", "223");
+ }
+
+ [Fact]
+ public void Descriptions_are_trimmed_and_nonempty()
+ {
+ SfnCatalog.Entries.Should().OnlyContain(e =>
+ e.Description == e.Description.Trim() && e.Description.Length > 0);
+ }
+
+ [Fact]
+ public void FundSfns_validation_is_catalog_backed()
+ {
+ FundSfns.IsValidForInclusion("204").Should().BeTrue();
+ FundSfns.IsValidForInclusion("222").Should().BeTrue();
+ FundSfns.IsValidForInclusion("Multiple").Should().BeTrue();
+ FundSfns.IsValidForInclusion("999").Should().BeFalse();
+ FundSfns.IsValidForInclusion(null).Should().BeFalse();
+ }
+}