Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ model Contact {
agreementEndDate DateTime? @map("agreement_end_date") @db.Date
terminationDate DateTime? @map("termination_date") @db.Date
mcfdContract String? @map("mcfd_contract")
sourceAgreement String? @map("source_agreement")
orderNumber String? @map("order_number")
orderType String? @map("order_type")
orderStatus String? @map("order_status")
Expand Down Expand Up @@ -185,6 +186,9 @@ model WklFileRecord {
recordIndex Int @map("record_index")
weeklyFileDate DateTime? @map("weekly_file_date") @db.Date
recordData Json @map("record_data") @db.JsonB
transactionType String? @map("transaction_type")
craStatus String? @map("cra_status")
transactionSource String? @map("transaction_source")
matchStatus String @map("match_status")
contactId Int? @map("contact_id")
batchDetailId Int? @map("batch_detail_id")
Expand All @@ -200,6 +204,9 @@ model WklFileRecord {
@@index([transferFileId])
@@index([matchStatus])
@@index([contactId])
@@index([transactionType])
@@index([craStatus])
@@index([transactionSource])
@@map("wkl_file_records")
@@schema("csa")
}
Expand Down
1 change: 1 addition & 0 deletions backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ function generateContact(csaStatus: string) {
agreementEndDate: agreementEnd ?? null,
terminationDate: terminationDate ?? null,
mcfdContract: faker.string.alphanumeric(10).toUpperCase(),
sourceAgreement: faker.helpers.arrayElement(SOURCES),

orderNumber: faker.string.alphanumeric(8).toUpperCase(),
orderType: faker.helpers.arrayElement(ORDER_TYPES),
Expand Down
5 changes: 4 additions & 1 deletion backend/src/api/contacts/dto/contact.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export class ContactDto {
@ApiPropertyOptional({ description: 'Whether placement was interrupted' })
interruptedPlacement?: string

@ApiPropertyOptional({ description: 'Source of placement' })
@ApiPropertyOptional({ description: 'Source of the primary placement (ICM or MIS)' })
sourcePlacement?: string

@ApiPropertyOptional({ description: 'Service provider name' })
Expand Down Expand Up @@ -171,6 +171,9 @@ export class ContactDto {
@ApiPropertyOptional({ description: 'MCFD contract identifier' })
mcfdContract?: string

@ApiPropertyOptional({ description: 'Source of the primary agreement or contract (ICM or MIS)' })
sourceAgreement?: string

@ApiPropertyOptional({ description: 'Order number' })
orderNumber?: string

Expand Down
33 changes: 33 additions & 0 deletions backend/src/api/weekly-files/weekly-file-record-filters.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CRA_DATA_HANDLING_CONSTANT } from 'src/cra/cra.constant'
import { describe, expect, it } from 'vitest'
import {
buildTransactionSourceWhere,
resolveCsaMatchFoundStatuses,
} from './weekly-file-record-filters'

const { WKL_MATCH_STATUS } = CRA_DATA_HANDLING_CONSTANT

describe('weekly-file-record-filters', () => {
it('maps CSA Match Found filter values to match_status groups', () => {
expect(resolveCsaMatchFoundStatuses(['Yes'])).toEqual([WKL_MATCH_STATUS.MATCHED])
expect(resolveCsaMatchFoundStatuses(['No'])).toEqual([
WKL_MATCH_STATUS.UNMATCHED,
WKL_MATCH_STATUS.ASSOCIATED,
])
expect(resolveCsaMatchFoundStatuses(['Yes', 'No'])).toEqual([
WKL_MATCH_STATUS.MATCHED,
WKL_MATCH_STATUS.UNMATCHED,
WKL_MATCH_STATUS.ASSOCIATED,
])
})

it('maps transaction source search terms to stored column predicates', () => {
expect(buildTransactionSourceWhere('elec')).toEqual({
OR: [{ transactionSource: 'E' }],
})
expect(buildTransactionSourceWhere('oth')).toEqual({
OR: [{ OR: [{ transactionSource: '' }, { transactionSource: null }] }],
})
expect(buildTransactionSourceWhere('ab')).toBeNull()
})
})
104 changes: 104 additions & 0 deletions backend/src/api/weekly-files/weekly-file-record-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Prisma } from '@prisma/client'
import { CRA_DATA_HANDLING_CONSTANT } from 'src/cra/cra.constant'
import type { PrismaService } from 'src/common/database/prisma.service'
import { filterAllowedCraStatuses, filterAllowedTransactionTypes } from './weekly-file.mapper'
import type { WeeklyFileRecordFilters } from './weekly-files.service'

const { WKL_MATCH_STATUS } = CRA_DATA_HANDLING_CONSTANT

export function resolveCsaMatchFoundStatuses(values: string[]): string[] {
const matchStatuses: string[] = []
for (const val of values) {
if (val === 'Yes') matchStatuses.push(WKL_MATCH_STATUS.MATCHED)
if (val === 'No') {
matchStatuses.push(WKL_MATCH_STATUS.UNMATCHED, WKL_MATCH_STATUS.ASSOCIATED)
}
}
return [...new Set(matchStatuses)]
}

/** Maps a search term to stored transaction_source values (display: electronic / other). */
export function buildTransactionSourceWhere(
term: string | undefined,
): Prisma.WklFileRecordWhereInput | null {
const normalized = term?.trim().toLowerCase() ?? ''
if (normalized.length < 3) return null

const orConditions: Prisma.WklFileRecordWhereInput[] = []
if ('electronic'.includes(normalized)) {
orConditions.push({ transactionSource: 'E' })
}
if ('other'.includes(normalized)) {
orConditions.push({ OR: [{ transactionSource: '' }, { transactionSource: null }] })
}
if (!orConditions.length) {
orConditions.push({ transactionSource: { contains: normalized, mode: 'insensitive' } })
}
return { OR: orConditions }
}

async function findBatchDetailIdsByBatchNumberSubstring(
prisma: PrismaService,
term: string,
): Promise<number[]> {
const pattern = `%${term}%`
const rows = await prisma.$queryRaw<{ id: number }[]>(
Prisma.sql`
SELECT cbd.id
FROM csa.contact_batch_details cbd
INNER JOIN csa.batches b ON b.id = cbd.batch_id
WHERE LOWER(CAST(b.batch_number AS TEXT)) LIKE ${pattern}
`,
)
return rows.map((row) => row.id)
}

export async function buildWklRecordWhereInput(
prisma: PrismaService,
transferFileId: number,
filters?: WeeklyFileRecordFilters,
): Promise<Prisma.WklFileRecordWhereInput> {
const andConditions: Prisma.WklFileRecordWhereInput[] = []

if (filters?.csaMatchFound?.length) {
const matchStatuses = resolveCsaMatchFoundStatuses(filters.csaMatchFound)
if (matchStatuses.length) {
andConditions.push({ matchStatus: { in: matchStatuses } })
}
}

const transactionTypes = filterAllowedTransactionTypes(filters?.transactionType ?? [])
if (transactionTypes.length) {
andConditions.push({ transactionType: { in: transactionTypes } })
}

const craStatuses = filterAllowedCraStatuses(filters?.craStatus ?? [])
if (craStatuses.length) {
andConditions.push({ craStatus: { in: craStatuses } })
}

if (filters?.matchedBy?.trim()) {
const term = filters.matchedBy.trim()
if (term.length >= 3) {
andConditions.push({ matchedBy: { contains: term, mode: 'insensitive' } })
}
}

const transactionSourceWhere = buildTransactionSourceWhere(filters?.transactionSource)
if (transactionSourceWhere) {
andConditions.push(transactionSourceWhere)
}

if (filters?.batchNumber?.trim()) {
const term = filters.batchNumber.trim().toLowerCase()
if (term.length >= 1) {
const batchDetailIds = await findBatchDetailIdsByBatchNumberSubstring(prisma, term)
andConditions.push({ batchDetailId: { in: batchDetailIds } })
}
}

return {
transferFileId,
...(andConditions.length ? { AND: andConditions } : {}),
}
}
22 changes: 22 additions & 0 deletions backend/src/api/weekly-files/weekly-file.mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { CRA_DATA_HANDLING_CONSTANT } from 'src/cra/cra.constant'
import { describe, expect, it } from 'vitest'
import {
aggregateWeeklyFileCounts,
filterAllowedCraStatuses,
filterAllowedTransactionTypes,
toCraStatusDisplayLabel,
toCsaMatchFound,
toWeeklyFileRecordDto,
} from './weekly-file.mapper'
Expand Down Expand Up @@ -130,4 +133,23 @@ describe('weekly-file.mapper', () => {
expect(dto.gender).toBe('Unknown')
expect(dto.birthCountry).toBe('Outside Canada')
})

it('derives CRA status display labels from stored file values', () => {
expect(toCraStatusDisplayLabel('in-progress')).toBe('IN PROGRESS')
expect(toCraStatusDisplayLabel('completed')).toBe('COMPLETED')
})

it('whitelists stored transaction type filter values', () => {
expect(filterAllowedTransactionTypes(['A', 'c'])).toEqual(['A', 'C'])
expect(filterAllowedTransactionTypes(['Application', 'INVALID'])).toEqual([])
})

it('whitelists stored CRA status filter values', () => {
expect(filterAllowedCraStatuses(['in-progress'])).toEqual(['in-progress'])
expect(filterAllowedCraStatuses(['completed', 'IN-PROGRESS'])).toEqual([
'completed',
'in-progress',
])
expect(filterAllowedCraStatuses(['INVALID', 'completed'])).toEqual(['completed'])
})
})
25 changes: 23 additions & 2 deletions backend/src/api/weekly-files/weekly-file.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type { DetailRecord04 } from 'src/cra/inbound/inbound-weekly.interface'
import type { WeeklyFileRecordDto, WeeklyFileSummaryDto } from './dto/weekly-file.dto'

const { WKL_MATCH_STATUS, WEEKLY_FILE } = CRA_DATA_HANDLING_CONSTANT
const { RECEIVE_MODE } = WEEKLY_FILE
const { RECEIVE_MODE, STATUS: WKL_STATUS } = WEEKLY_FILE

const WKL_STATUS_STORED_VALUES = Object.values(WKL_STATUS)

const TRANSACTION_TYPE_LABELS: Record<string, string> = {
A: 'Application',
Expand All @@ -28,6 +30,25 @@ const BIRTH_COUNTRY_LABELS: Record<string, string> = {
EX: 'Outside Canada',
}

/** Display label for a stored CRA status (e.g. "in-progress" → "IN PROGRESS"). */
export function toCraStatusDisplayLabel(stored: string): string {
return stored.trim().toUpperCase().replace(/-/g, ' ')
}

const TRANSACTION_TYPE_CODES = Object.keys(TRANSACTION_TYPE_LABELS)

/** Whitelist filter params to values stored in transaction_type (A, C, U). */
export function filterAllowedTransactionTypes(values: string[]): string[] {
const allowed = new Set(TRANSACTION_TYPE_CODES)
return [...new Set(values.map((v) => v.trim().toUpperCase()).filter((v) => allowed.has(v)))]
}

/** Whitelist filter params to values stored in cra_status. */
export function filterAllowedCraStatuses(values: string[]): string[] {
const allowed = new Set<string>(WKL_STATUS_STORED_VALUES)
return [...new Set(values.map((v) => v.trim().toLowerCase()).filter((v) => allowed.has(v)))]
}

export interface WeeklyFileCounts {
totalCount: number
eCount: number
Expand Down Expand Up @@ -167,7 +188,7 @@ function formatWklDateString(value: string | undefined): string | null {

function formatCraStatus(status: string | undefined): string {
if (!status?.trim()) return ''
return status.trim().toUpperCase().replace(/-/g, ' ')
return toCraStatusDisplayLabel(status)
}

function formatTransactionType(value: string | undefined): string {
Expand Down
73 changes: 72 additions & 1 deletion backend/src/api/weekly-files/weekly-files.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,85 @@ export class WeeklyFilesController {
@Get(':id/records')
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({
name: 'csaMatchFound',
required: false,
type: String,
description: 'Comma-separated filter values for CSA Match Found: "Yes" and/or "No"',
})
@ApiQuery({
name: 'transactionType',
required: false,
type: String,
description: 'Comma-separated stored transaction type codes: "A", "C", "U"',
})
@ApiQuery({
name: 'craStatus',
required: false,
type: String,
description:
'Comma-separated stored CRA status values: "completed", "abandoned", "in-progress", "updated"',
})
@ApiQuery({
name: 'matchedBy',
required: false,
type: String,
description: 'Text filter for Matched By (minimum 3 characters)',
})
@ApiQuery({
name: 'batchNumber',
required: false,
type: String,
description: 'Text filter for Batch Req ID / batch number (minimum 3 characters)',
})
@ApiQuery({
name: 'transactionSource',
required: false,
type: String,
description: 'Text filter for Transaction Source (minimum 3 characters)',
})
@ApiResponse({ status: 200, description: 'Paginated detail records for a weekly file' })
@ApiResponse({ status: 404, description: 'Weekly file not found' })
findRecords(
@Param('id', ParseIntPipe) id: number,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('csaMatchFound') csaMatchFound?: string,
@Query('transactionType') transactionType?: string,
@Query('craStatus') craStatus?: string,
@Query('matchedBy') matchedBy?: string,
@Query('batchNumber') batchNumber?: string,
@Query('transactionSource') transactionSource?: string,
): Promise<PaginatedResponse<WeeklyFileRecordDto>> {
return this.weeklyFilesService.findRecords(id, this.parsePage(page), this.parseLimit(limit))
const filters = {
csaMatchFound: csaMatchFound
? csaMatchFound
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: undefined,
transactionType: transactionType
? transactionType
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: undefined,
craStatus: craStatus
? craStatus
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: undefined,
matchedBy: matchedBy?.trim() || undefined,
batchNumber: batchNumber?.trim() || undefined,
transactionSource: transactionSource?.trim() || undefined,
}
return this.weeklyFilesService.findRecords(
id,
this.parsePage(page),
this.parseLimit(limit),
filters,
)
}

@Post(':id/records/:recordId/associate')
Expand Down
Loading
Loading