Browse Source

feat(k1-import): Phase 7 US5 - import history, reprocess, audit trail, KDocument detail with aggregation summary

pull/6701/head
Robert Patch 2 months ago
parent
commit
063722b829
  1. 17
      apps/api/src/app/cell-mapping/cell-mapping.controller.ts
  2. 43
      apps/api/src/app/cell-mapping/cell-mapping.service.ts
  3. 31
      apps/api/src/app/k1-import/k1-import.controller.ts
  4. 124
      apps/api/src/app/k1-import/k1-import.service.ts
  5. 119
      apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.component.ts
  6. 102
      apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.html
  7. 79
      apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.scss
  8. 9
      apps/client/src/app/pages/k-documents/k-documents-page.routes.ts
  9. 56
      apps/client/src/app/pages/k1-import/k1-import-page.component.ts
  10. 65
      apps/client/src/app/pages/k1-import/k1-import-page.html
  11. 24
      prisma/schema.prisma
  12. 10
      specs/004-k1-scan-import/tasks.md

17
apps/api/src/app/cell-mapping/cell-mapping.controller.ts

@ -112,4 +112,21 @@ export class CellMappingController {
data.rules data.rules
); );
} }
/**
* GET /api/v1/cell-mapping/aggregation-rules/compute
* Compute aggregation values for a specific KDocument (FR-036).
*/
@HasPermission(permissions.readKDocument)
@Get('aggregation-rules/compute')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async computeAggregations(
@Query('kDocumentId') kDocumentId: string,
@Query('partnershipId') partnershipId?: string
) {
return this.cellMappingService.computeAggregations(
kDocumentId,
partnershipId
);
}
} }

43
apps/api/src/app/cell-mapping/cell-mapping.service.ts

@ -265,4 +265,47 @@ export class CellMappingService implements OnModuleInit {
return this.getAggregationRules(partnershipId); return this.getAggregationRules(partnershipId);
} }
/**
* Compute aggregation values for a specific KDocument (FR-036).
*/
public async computeAggregations(
kDocumentId: string,
partnershipId?: string
) {
const kDocument = await this.prismaService.kDocument.findUnique({
where: { id: kDocumentId }
});
if (!kDocument) {
throw new HttpException('KDocument not found', StatusCodes.NOT_FOUND);
}
const pId = partnershipId || kDocument.partnershipId;
const rules = await this.getAggregationRules(pId);
const data = kDocument.data as Record<string, any>;
return rules.map((rule: any) => {
const sourceCells = (rule.sourceCells || []) as string[];
const breakdown = sourceCells.map((boxNumber: string) => ({
boxNumber,
value: typeof data[boxNumber] === 'number' ? data[boxNumber] : 0
}));
let value = 0;
if (rule.operation === 'SUM') {
value = breakdown.reduce(
(sum: number, item: any) => sum + item.value,
0
);
}
return {
name: rule.name,
operation: rule.operation,
value,
breakdown
};
});
}
} }

31
apps/api/src/app/k1-import/k1-import.controller.ts

@ -12,6 +12,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
@ -53,6 +54,24 @@ export class K1ImportController {
}); });
} }
/**
* GET /api/v1/k1-import/history
* Get import history for a partnership.
*/
@HasPermission(permissions.readKDocument)
@Get('history')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getImportHistory(
@Query('partnershipId') partnershipId: string,
@Query('taxYear') taxYear?: string
) {
return this.k1ImportService.getHistory(
this.request.user.id,
partnershipId,
taxYear ? parseInt(taxYear, 10) : undefined
);
}
/** /**
* GET /api/v1/k1-import/:id * GET /api/v1/k1-import/:id
* Get the current state of an import session. * Get the current state of an import session.
@ -90,6 +109,18 @@ export class K1ImportController {
return this.k1ImportService.cancel(id, this.request.user.id); return this.k1ImportService.cancel(id, this.request.user.id);
} }
/**
* POST /api/v1/k1-import/:id/reprocess
* Re-process a previously uploaded K-1 PDF with current cell mapping.
*/
@HasPermission(permissions.updateKDocument)
@Post(':id/reprocess')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async reprocessImportSession(@Param('id') id: string) {
return this.k1ImportService.reprocess(id, this.request.user.id);
}
/** /**
* POST /api/v1/k1-import/:id/confirm * POST /api/v1/k1-import/:id/confirm
* Confirm verified data and trigger auto-creation of model objects. * Confirm verified data and trigger auto-creation of model objects.

124
apps/api/src/app/k1-import/k1-import.service.ts

@ -389,6 +389,124 @@ export class K1ImportService {
return updated; return updated;
} }
/**
* Get import history for a partnership, optionally filtered by tax year.
* FR-022: History of all K-1 import attempts per partnership.
*/
public async getHistory(
userId: string,
partnershipId: string,
taxYear?: number
) {
const where: any = { userId, partnershipId };
if (taxYear) {
where.taxYear = taxYear;
}
const sessions = await this.prismaService.k1ImportSession.findMany({
where,
orderBy: { createdAt: 'desc' },
select: {
id: true,
partnershipId: true,
status: true,
taxYear: true,
fileName: true,
extractionMethod: true,
kDocumentId: true,
createdAt: true
}
});
return sessions;
}
/**
* Re-process a previously uploaded K-1 PDF with the current cell mapping.
* FR-023: Creates a new import session using the stored document from the original session.
*/
public async reprocess(sessionId: string, userId: string) {
const originalSession = await this.getSession(sessionId, userId);
if (!originalSession.documentId) {
throw new HttpException(
'Original session has no stored document to re-process',
StatusCodes.BAD_REQUEST
);
}
// Read the stored file from uploads directory
const document = await this.prismaService.document.findUnique({
where: { id: originalSession.documentId }
});
if (!document) {
throw new HttpException(
'Stored document not found',
StatusCodes.NOT_FOUND
);
}
// Create a new import session in PROCESSING status
const newSession = await this.prismaService.k1ImportSession.create({
data: {
partnershipId: originalSession.partnershipId,
userId,
status: K1ImportStatus.PROCESSING,
taxYear: originalSession.taxYear,
fileName: originalSession.fileName,
fileSize: originalSession.fileSize,
extractionMethod: 'pending',
documentId: originalSession.documentId
}
});
// Read file from disk and run extraction asynchronously
const fs = await import('fs/promises');
const filePath = (document as any).url || (document as any).filePath;
if (!filePath) {
throw new HttpException(
'Cannot determine file path for stored document',
StatusCodes.INTERNAL_SERVER_ERROR
);
}
const fileBuffer = await fs.readFile(filePath);
const file = {
buffer: fileBuffer,
originalname: originalSession.fileName,
mimetype: 'application/pdf',
size: originalSession.fileSize
};
this.runExtraction(
newSession.id,
file,
originalSession.partnershipId
).catch((err) => {
this.logger.error(
`Reprocess extraction failed for session ${newSession.id}: ${err.message}`,
err.stack
);
});
this.logger.log(
`Session ${sessionId}: Re-processing started as new session ${newSession.id}`
);
return {
id: newSession.id,
partnershipId: newSession.partnershipId,
status: newSession.status,
taxYear: newSession.taxYear,
fileName: newSession.fileName,
fileSize: newSession.fileSize,
extractionMethod: newSession.extractionMethod,
createdAt: newSession.createdAt
};
}
/** /**
* Confirm verified data and auto-create model objects. * Confirm verified data and auto-create model objects.
* VERIFIED CONFIRMED transition. * VERIFIED CONFIRMED transition.
@ -470,11 +588,17 @@ export class K1ImportService {
// FR-012: Create or update KDocument // FR-012: Create or update KDocument
let kDocument; let kDocument;
if (existingKDocument && data.existingKDocumentAction === 'UPDATE') { if (existingKDocument && data.existingKDocumentAction === 'UPDATE') {
// FR-025: Preserve previous values for audit trail
const previousData = existingKDocument.data;
const previousFilingStatus = existingKDocument.filingStatus;
kDocument = await this.prismaService.kDocument.update({ kDocument = await this.prismaService.kDocument.update({
where: { id: existingKDocument.id }, where: { id: existingKDocument.id },
data: { data: {
filingStatus: data.filingStatus, filingStatus: data.filingStatus,
data: kDocumentData as any, data: kDocumentData as any,
previousData: previousData as any,
previousFilingStatus,
documentFileId: session.documentId documentFileId: session.documentId
} }
}); });

119
apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.component.ts

@ -0,0 +1,119 @@
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service';
import { K1ImportDataService } from '@ghostfolio/client/services/k1-import-data.service';
import { K1AggregationResult } from '@ghostfolio/common/interfaces/k1-import.interface';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
CommonModule,
MatButtonModule,
MatCardModule,
MatChipsModule,
MatIconModule,
MatTableModule,
RouterModule
],
selector: 'gf-k-document-detail',
styleUrls: ['./k-document-detail.scss'],
templateUrl: './k-document-detail.html'
})
export class KDocumentDetailComponent implements OnInit {
public aggregations: K1AggregationResult[] = [];
public boxColumns = ['boxNumber', 'value'];
public boxData: Array<{ boxNumber: string; value: number | null }> = [];
public error: string | null = null;
public kDocument: any = null;
public kDocumentId: string;
public constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly destroyRef: DestroyRef,
private readonly familyOfficeDataService: FamilyOfficeDataService,
private readonly k1ImportDataService: K1ImportDataService,
private readonly router: Router
) {}
public ngOnInit(): void {
this.kDocumentId = this.activatedRoute.snapshot.paramMap.get('id') || '';
if (this.kDocumentId) {
this.loadKDocument();
this.loadAggregations();
}
}
public goBack(): void {
this.router.navigate(['/k-documents']);
}
private loadKDocument(): void {
this.familyOfficeDataService
.fetchKDocuments()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (docs) => {
this.kDocument = docs.find((d) => d.id === this.kDocumentId) || null;
if (this.kDocument?.data) {
const data = this.kDocument.data as Record<string, any>;
this.boxData = Object.entries(data)
.map(([boxNumber, value]) => ({
boxNumber,
value: typeof value === 'number' ? value : null
}))
.sort((a, b) => this.compareBoxNumbers(a.boxNumber, b.boxNumber));
}
this.changeDetectorRef.markForCheck();
},
error: () => {
this.error = 'Failed to load K-Document.';
this.changeDetectorRef.markForCheck();
}
});
}
private loadAggregations(): void {
this.k1ImportDataService
.computeAggregations({ kDocumentId: this.kDocumentId })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (aggregations) => {
this.aggregations = aggregations;
this.changeDetectorRef.markForCheck();
},
error: () => {
// Aggregations may not be configured yet
this.aggregations = [];
this.changeDetectorRef.markForCheck();
}
});
}
private compareBoxNumbers(a: string, b: string): number {
const numA = parseInt(a.replace(/[^0-9]/g, ''), 10) || 0;
const numB = parseInt(b.replace(/[^0-9]/g, ''), 10) || 0;
if (numA !== numB) {
return numA - numB;
}
return a.localeCompare(b);
}
}

102
apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.html

@ -0,0 +1,102 @@
<div class="container">
<div class="d-flex align-items-center mb-3">
<button mat-icon-button (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<h1 class="h3 mb-0 ms-2">K-Document Detail</h1>
</div>
@if (error) {
<div class="alert alert-danger">{{ error }}</div>
}
@if (kDocument) {
<!-- Summary Card -->
<mat-card class="mb-4">
<mat-card-header>
<mat-card-title>{{ kDocument.partnershipName || kDocument.partnershipId }}</mat-card-title>
<mat-card-subtitle>
{{ kDocument.type }} — Tax Year {{ kDocument.taxYear }}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="detail-row">
<span class="label">Filing Status:</span>
<mat-chip-set>
<mat-chip [class.chip-draft]="kDocument.filingStatus === 'DRAFT'"
[class.chip-estimated]="kDocument.filingStatus === 'ESTIMATED'"
[class.chip-final]="kDocument.filingStatus === 'FINAL'">
{{ kDocument.filingStatus }}
</mat-chip>
</mat-chip-set>
</div>
<div class="detail-row">
<span class="label">Created:</span>
<span>{{ kDocument.createdAt | date:'medium' }}</span>
</div>
<div class="detail-row">
<span class="label">Updated:</span>
<span>{{ kDocument.updatedAt | date:'medium' }}</span>
</div>
</mat-card-content>
</mat-card>
<!-- Aggregation Summary (FR-036) -->
@if (aggregations.length > 0) {
<h2 class="h5 mb-3">Aggregation Summary</h2>
<div class="aggregation-cards mb-4">
@for (agg of aggregations; track agg.name) {
<mat-card class="aggregation-card">
<mat-card-header>
<mat-card-title>{{ agg.name }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="aggregation-value">
{{ agg.value | currency:'USD':'symbol':'1.2-2' }}
</div>
@if (agg.breakdown && agg.breakdown.length > 0) {
<div class="breakdown">
@for (item of agg.breakdown; track item.boxNumber) {
<div class="breakdown-row">
<span class="box-label">Box {{ item.boxNumber }}:</span>
<span class="box-value">{{ item.value | currency:'USD':'symbol':'1.2-2' }}</span>
</div>
}
</div>
}
</mat-card-content>
</mat-card>
}
</div>
}
<!-- Raw Box Values -->
<h2 class="h5 mb-3">Box Values</h2>
@if (boxData.length > 0) {
<table mat-table [dataSource]="boxData" class="w-100 box-table">
<ng-container matColumnDef="boxNumber">
<th mat-header-cell *matHeaderCellDef>Box #</th>
<td mat-cell *matCellDef="let row">{{ row.boxNumber }}</td>
</ng-container>
<ng-container matColumnDef="value">
<th mat-header-cell *matHeaderCellDef>Value</th>
<td mat-cell *matCellDef="let row">
@if (row.value !== null) {
{{ row.value | currency:'USD':'symbol':'1.2-2' }}
} @else {
<span class="text-muted"></span>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="boxColumns"></tr>
<tr mat-row *matRowDef="let row; columns: boxColumns;"></tr>
</table>
} @else {
<p class="text-muted">No box values available.</p>
}
} @else if (!error) {
<p>Loading...</p>
}
</div>

79
apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.scss

@ -0,0 +1,79 @@
:host {
display: block;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
.detail-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
.label {
font-weight: 500;
min-width: 120px;
color: rgba(0, 0, 0, 0.6);
}
}
// Filing status chips
.chip-draft {
--mdc-chip-elevated-container-color: #e0e0e0;
}
.chip-estimated {
--mdc-chip-elevated-container-color: #fff3e0;
}
.chip-final {
--mdc-chip-elevated-container-color: #e8f5e9;
}
// Aggregation cards
.aggregation-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.aggregation-card {
.aggregation-value {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.breakdown {
border-top: 1px solid rgba(0, 0, 0, 0.08);
padding-top: 0.5rem;
font-size: 0.875rem;
}
.breakdown-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.box-label {
color: rgba(0, 0, 0, 0.6);
font-family: monospace;
}
.box-value {
font-weight: 500;
}
}
// Box table
.box-table {
max-width: 500px;
}

9
apps/client/src/app/pages/k-documents/k-documents-page.routes.ts

@ -10,5 +10,14 @@ export const routes: Routes = [
component: KDocumentsPageComponent, component: KDocumentsPageComponent,
path: '', path: '',
title: 'K-1 / K-3 Documents' title: 'K-1 / K-3 Documents'
},
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./k-document-detail/k-document-detail.component').then(
(c) => c.KDocumentDetailComponent
),
path: ':id',
title: 'K-Document Detail'
} }
]; ];

56
apps/client/src/app/pages/k1-import/k1-import-page.component.ts

@ -16,7 +16,9 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { Router } from '@angular/router'; import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router, RouterModule } from '@angular/router';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
cloudUploadOutline, cloudUploadOutline,
@ -33,7 +35,10 @@ import {
MatFormFieldModule, MatFormFieldModule,
MatIconModule, MatIconModule,
MatProgressBarModule, MatProgressBarModule,
MatSelectModule MatSelectModule,
MatTableModule,
MatTooltipModule,
RouterModule
], ],
selector: 'gf-k1-import-page', selector: 'gf-k1-import-page',
styleUrls: ['./k1-import-page.scss'], styleUrls: ['./k1-import-page.scss'],
@ -42,6 +47,8 @@ import {
export class K1ImportPageComponent implements OnInit { export class K1ImportPageComponent implements OnInit {
public error: string | null = null; public error: string | null = null;
public extractionStatus: string | null = null; public extractionStatus: string | null = null;
public historyColumns = ['createdAt', 'fileName', 'taxYear', 'status', 'kDocument', 'actions'];
public importHistory: any[] = [];
public isUploading = false; public isUploading = false;
public partnerships: Array<{ id: string; name: string }> = []; public partnerships: Array<{ id: string; name: string }> = [];
public selectedFile: File | null = null; public selectedFile: File | null = null;
@ -72,6 +79,51 @@ export class K1ImportPageComponent implements OnInit {
this.fetchPartnerships(); this.fetchPartnerships();
} }
public onPartnershipChange(): void {
if (this.selectedPartnershipId) {
this.loadImportHistory();
}
}
public loadImportHistory(): void {
if (!this.selectedPartnershipId) {
return;
}
this.k1ImportDataService
.fetchImportHistory({ partnershipId: this.selectedPartnershipId })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (history) => {
this.importHistory = history;
this.changeDetectorRef.markForCheck();
},
error: () => {
this.importHistory = [];
this.changeDetectorRef.markForCheck();
}
});
}
public reprocessSession(sessionId: string): void {
this.k1ImportDataService
.reprocessImportSession(sessionId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.sessionId = result.id;
this.extractionStatus = 'Processing...';
this.changeDetectorRef.markForCheck();
this.startPolling(result.id);
},
error: (err) => {
this.error =
err?.error?.message || err?.message || 'Re-processing failed.';
this.changeDetectorRef.markForCheck();
}
});
}
public onFileSelected(event: Event): void { public onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { if (input.files && input.files.length > 0) {

65
apps/client/src/app/pages/k1-import/k1-import-page.html

@ -15,7 +15,7 @@
<div class="mb-3"> <div class="mb-3">
<mat-form-field class="w-100"> <mat-form-field class="w-100">
<mat-label>Partnership</mat-label> <mat-label>Partnership</mat-label>
<mat-select [(ngModel)]="selectedPartnershipId"> <mat-select [(ngModel)]="selectedPartnershipId" (selectionChange)="onPartnershipChange()">
@for (p of partnerships; track p.id) { @for (p of partnerships; track p.id) {
<mat-option [value]="p.id">{{ p.name }}</mat-option> <mat-option [value]="p.id">{{ p.name }}</mat-option>
} }
@ -96,4 +96,67 @@
} }
</div> </div>
</div> </div>
<!-- Import History -->
@if (importHistory.length > 0) {
<div class="row mt-4">
<div class="col">
<h3 class="h5 mb-3">Import History</h3>
<table mat-table [dataSource]="importHistory" class="w-100">
<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let row">{{ row.createdAt | date:'short' }}</td>
</ng-container>
<ng-container matColumnDef="fileName">
<th mat-header-cell *matHeaderCellDef>File</th>
<td mat-cell *matCellDef="let row">{{ row.fileName }}</td>
</ng-container>
<ng-container matColumnDef="taxYear">
<th mat-header-cell *matHeaderCellDef>Tax Year</th>
<td mat-cell *matCellDef="let row">{{ row.taxYear }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="badge"
[class.badge-success]="row.status === 'CONFIRMED'"
[class.badge-warning]="row.status === 'EXTRACTED' || row.status === 'VERIFIED'"
[class.badge-danger]="row.status === 'FAILED' || row.status === 'CANCELLED'"
[class.badge-info]="row.status === 'PROCESSING'">
{{ row.status }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="kDocument">
<th mat-header-cell *matHeaderCellDef>K-Document</th>
<td mat-cell *matCellDef="let row">
@if (row.kDocumentId) {
<a [routerLink]="['/k-documents']">View</a>
} @else {
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let row">
<button mat-icon-button
(click)="reprocessSession(row.id)"
matTooltip="Re-process with current cell mapping">
<mat-icon>refresh</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="historyColumns"></tr>
<tr mat-row *matRowDef="let row; columns: historyColumns;"></tr>
</table>
</div>
</div>
}
</div> </div>

24
prisma/schema.prisma

@ -541,17 +541,19 @@ model Distribution {
} }
model KDocument { model KDocument {
id String @id @default(uuid()) id String @id @default(uuid())
partnershipId String partnershipId String
partnership Partnership @relation(fields: [partnershipId], onDelete: Cascade, references: [id]) partnership Partnership @relation(fields: [partnershipId], onDelete: Cascade, references: [id])
type KDocumentType type KDocumentType
taxYear Int taxYear Int
filingStatus KDocumentStatus @default(DRAFT) filingStatus KDocumentStatus @default(DRAFT)
data Json data Json
documentFileId String? previousData Json?
documentFile Document? @relation(fields: [documentFileId], references: [id]) previousFilingStatus KDocumentStatus?
createdAt DateTime @default(now()) documentFileId String?
updatedAt DateTime @updatedAt documentFile Document? @relation(fields: [documentFileId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
importSession K1ImportSession? importSession K1ImportSession?

10
specs/004-k1-scan-import/tasks.md

@ -142,11 +142,11 @@
### Implementation for User Story 5 ### Implementation for User Story 5
- [ ] T042 [US5] Implement import history query (filter by partnershipId and optional taxYear, order by createdAt desc) in apps/api/src/app/k1-import/k1-import.service.ts and GET /api/v1/k1-import/history endpoint in k1-import.controller.ts - [X] T042 [US5] Implement import history query (filter by partnershipId and optional taxYear, order by createdAt desc) in apps/api/src/app/k1-import/k1-import.service.ts and GET /api/v1/k1-import/history endpoint in k1-import.controller.ts
- [ ] T043 [US5] Implement reprocess endpoint (re-extract stored PDF with current cell mapping, create new session, original session unchanged) in apps/api/src/app/k1-import/k1-import.service.ts and POST /api/v1/k1-import/:id/reprocess in k1-import.controller.ts - [X] T043 [US5] Implement reprocess endpoint (re-extract stored PDF with current cell mapping, create new session, original session unchanged) in apps/api/src/app/k1-import/k1-import.service.ts and POST /api/v1/k1-import/:id/reprocess in k1-import.controller.ts
- [ ] T044 [US5] Add import history list view (date, filename, status, tax year, link to KDocument) to K1 import page in apps/client/src/app/pages/k1-import/k1-import-page.component.ts - [X] T044 [US5] Add import history list view (date, filename, status, tax year, link to KDocument) to K1 import page in apps/client/src/app/pages/k1-import/k1-import-page.component.ts
- [ ] T045 [US5] Implement KDocument status transitions (DRAFT → ESTIMATED → FINAL) with previous value preservation for audit trail (FR-024/FR-025) in apps/api/src/app/k1-import/k1-import.service.ts - [X] T045 [US5] Implement KDocument status transitions (DRAFT → ESTIMATED → FINAL) with previous value preservation for audit trail (FR-024/FR-025) in apps/api/src/app/k1-import/k1-import.service.ts
- [ ] T046 [US5] Extend KDocument detail view with aggregation summary section (display named aggregation totals alongside raw box values per FR-036) in apps/client/src/app/pages/k-document/k-document-detail/ - [X] T046 [US5] Extend KDocument detail view with aggregation summary section (display named aggregation totals alongside raw box values per FR-036) in apps/client/src/app/pages/k-document/k-document-detail/
**Checkpoint**: All user stories should now be independently functional **Checkpoint**: All user stories should now be independently functional

Loading…
Cancel
Save