Browse Source

feat(k1-import): Phase 6 US4 - cell mapping CRUD, controller endpoints, frontend config page, pipeline integration

pull/6701/head
Robert Patch 2 months ago
parent
commit
2e7d3780bc
  1. 88
      apps/api/src/app/cell-mapping/cell-mapping.controller.ts
  2. 89
      apps/api/src/app/cell-mapping/cell-mapping.service.ts
  3. 327
      apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts
  4. 175
      apps/client/src/app/pages/cell-mapping/cell-mapping-page.html
  5. 159
      apps/client/src/app/pages/cell-mapping/cell-mapping-page.scss
  6. 3
      libs/common/src/lib/dtos/k1-import.dto.ts
  7. 8
      specs/004-k1-scan-import/tasks.md

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

@ -24,4 +24,92 @@ export class CellMappingController {
private readonly cellMappingService: CellMappingService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
/**
* GET /api/v1/cell-mapping
* Get cell mappings for a partnership (with global defaults).
*/
@HasPermission(permissions.readKDocument)
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMappings(
@Query('partnershipId') partnershipId?: string
) {
return this.cellMappingService.getMappings(partnershipId);
}
/**
* PUT /api/v1/cell-mapping
* Update or create cell mappings for a partnership.
*/
@HasPermission(permissions.updateKDocument)
@Put()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMappings(
@Body()
data: {
partnershipId: string;
mappings: Array<{
boxNumber: string;
label: string;
description?: string;
isCustom: boolean;
}>;
}
) {
return this.cellMappingService.updateMappings(
data.partnershipId,
data.mappings
);
}
/**
* DELETE /api/v1/cell-mapping/reset
* Reset a partnership's cell mappings to IRS defaults.
*/
@HasPermission(permissions.updateKDocument)
@Delete('reset')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async resetMappings(
@Query('partnershipId') partnershipId: string
) {
return this.cellMappingService.resetMappings(partnershipId);
}
/**
* GET /api/v1/cell-mapping/aggregation-rules
* Get aggregation rules for a partnership.
*/
@HasPermission(permissions.readKDocument)
@Get('aggregation-rules')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAggregationRules(
@Query('partnershipId') partnershipId?: string
) {
return this.cellMappingService.getAggregationRules(partnershipId);
}
/**
* PUT /api/v1/cell-mapping/aggregation-rules
* Update aggregation rules for a partnership.
*/
@HasPermission(permissions.updateKDocument)
@Put('aggregation-rules')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAggregationRules(
@Body()
data: {
partnershipId: string;
rules: Array<{
name: string;
operation: string;
sourceCells: string[];
}>;
}
) {
return this.cellMappingService.updateAggregationRules(
data.partnershipId,
data.rules
);
}
}

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

@ -1,6 +1,7 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { HttpException, Injectable, OnModuleInit } from '@nestjs/common';
import { StatusCodes } from 'http-status-codes';
/** Default IRS K-1 (Form 1065) cell mappings */
const IRS_DEFAULT_MAPPINGS: Array<{
@ -178,4 +179,90 @@ export class CellMappingService implements OnModuleInit {
orderBy: { sortOrder: 'asc' }
});
}
/**
* Upsert cell mappings for a partnership.
* Creates partnership-specific overrides; does not modify global defaults.
*/
public async updateMappings(
partnershipId: string,
mappings: Array<{
boxNumber: string;
label: string;
description?: string;
isCustom: boolean;
}>
) {
const results = [];
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i];
const result = await this.prismaService.cellMapping.upsert({
where: {
partnershipId_boxNumber: {
partnershipId,
boxNumber: mapping.boxNumber
}
},
update: {
label: mapping.label,
description: mapping.description || null,
isCustom: mapping.isCustom,
sortOrder: i + 1
},
create: {
partnershipId,
boxNumber: mapping.boxNumber,
label: mapping.label,
description: mapping.description || null,
isCustom: mapping.isCustom,
sortOrder: i + 1
}
});
results.push(result);
}
return results;
}
/**
* Reset a partnership's mappings to IRS defaults.
* Deletes all partnership-specific overrides.
*/
public async resetMappings(partnershipId: string) {
await this.prismaService.cellMapping.deleteMany({
where: { partnershipId }
});
return { deleted: true, partnershipId };
}
/**
* Update aggregation rules for a partnership.
*/
public async updateAggregationRules(
partnershipId: string,
rules: Array<{
name: string;
operation: string;
sourceCells: string[];
}>
) {
// Delete existing partnership rules and recreate
await this.prismaService.cellAggregationRule.deleteMany({
where: { partnershipId }
});
const results = await this.prismaService.cellAggregationRule.createMany({
data: rules.map((rule, i) => ({
partnershipId,
name: rule.name,
operation: rule.operation,
sourceCells: rule.sourceCells,
sortOrder: i + 1
}))
});
return this.getAggregationRules(partnershipId);
}
}

327
apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts

@ -0,0 +1,327 @@
import { K1ImportDataService } from '@ghostfolio/client/services/k1-import-data.service';
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
interface EditableMapping {
boxNumber: string;
label: string;
description: string;
isCustom: boolean;
isEditing: boolean;
editLabel: string;
editDescription: string;
}
interface EditableRule {
name: string;
operation: string;
sourceCells: string[];
isEditing: boolean;
editName: string;
editSourceCells: string;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatTableModule,
MatTooltipModule
],
selector: 'gf-cell-mapping-page',
styleUrls: ['./cell-mapping-page.scss'],
templateUrl: './cell-mapping-page.html'
})
export class CellMappingPageComponent implements OnInit {
public aggregationRules: EditableRule[] = [];
public error: string | null = null;
public isSaving = false;
public mappings: EditableMapping[] = [];
public partnerships: Array<{ id: string; name: string }> = [];
public selectedPartnershipId = '';
public successMessage: string | null = null;
// New custom cell form
public newBoxNumber = '';
public newLabel = '';
// New rule form
public newRuleName = '';
public newRuleSourceCells = '';
public displayedColumns = ['boxNumber', 'label', 'description', 'isCustom', 'actions'];
public constructor(
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly destroyRef: DestroyRef,
private readonly familyOfficeDataService: FamilyOfficeDataService,
private readonly k1ImportDataService: K1ImportDataService
) {}
public ngOnInit(): void {
this.fetchPartnerships();
}
public onPartnershipChange(): void {
if (this.selectedPartnershipId) {
this.loadMappings();
this.loadAggregationRules();
}
}
// ── Cell Mapping Methods ─────────────────────────────────────────
public startEditMapping(mapping: EditableMapping): void {
mapping.isEditing = true;
mapping.editLabel = mapping.label;
mapping.editDescription = mapping.description;
this.changeDetectorRef.markForCheck();
}
public saveEditMapping(mapping: EditableMapping): void {
mapping.label = mapping.editLabel;
mapping.description = mapping.editDescription;
mapping.isEditing = false;
this.changeDetectorRef.markForCheck();
}
public cancelEditMapping(mapping: EditableMapping): void {
mapping.isEditing = false;
this.changeDetectorRef.markForCheck();
}
public addCustomCell(): void {
if (!this.newBoxNumber || !this.newLabel) {
return;
}
this.mappings.push({
boxNumber: this.newBoxNumber,
label: this.newLabel,
description: '',
isCustom: true,
isEditing: false,
editLabel: '',
editDescription: ''
});
this.newBoxNumber = '';
this.newLabel = '';
this.changeDetectorRef.markForCheck();
}
public removeMapping(index: number): void {
this.mappings.splice(index, 1);
this.changeDetectorRef.markForCheck();
}
public saveMappings(): void {
if (!this.selectedPartnershipId) {
return;
}
this.isSaving = true;
this.error = null;
this.successMessage = null;
this.changeDetectorRef.markForCheck();
this.k1ImportDataService
.updateCellMappings({
partnershipId: this.selectedPartnershipId,
mappings: this.mappings.map((m) => ({
boxNumber: m.boxNumber,
label: m.label,
description: m.description,
isCustom: m.isCustom
}))
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.isSaving = false;
this.successMessage = 'Cell mappings saved successfully.';
this.changeDetectorRef.markForCheck();
},
error: (err) => {
this.isSaving = false;
this.error =
err?.error?.message || err?.message || 'Failed to save mappings.';
this.changeDetectorRef.markForCheck();
}
});
}
public resetToDefaults(): void {
if (!this.selectedPartnershipId) {
return;
}
this.k1ImportDataService
.resetCellMappings(this.selectedPartnershipId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.successMessage = 'Cell mappings reset to IRS defaults.';
this.loadMappings();
},
error: (err) => {
this.error =
err?.error?.message || err?.message || 'Failed to reset mappings.';
this.changeDetectorRef.markForCheck();
}
});
}
// ── Aggregation Rule Methods ─────────────────────────────────────
public addAggregationRule(): void {
if (!this.newRuleName || !this.newRuleSourceCells) {
return;
}
this.aggregationRules.push({
name: this.newRuleName,
operation: 'SUM',
sourceCells: this.newRuleSourceCells.split(',').map((s) => s.trim()),
isEditing: false,
editName: '',
editSourceCells: ''
});
this.newRuleName = '';
this.newRuleSourceCells = '';
this.changeDetectorRef.markForCheck();
}
public removeAggregationRule(index: number): void {
this.aggregationRules.splice(index, 1);
this.changeDetectorRef.markForCheck();
}
public saveAggregationRules(): void {
if (!this.selectedPartnershipId) {
return;
}
this.isSaving = true;
this.error = null;
this.successMessage = null;
this.changeDetectorRef.markForCheck();
this.k1ImportDataService
.updateAggregationRules({
partnershipId: this.selectedPartnershipId,
rules: this.aggregationRules.map((r) => ({
name: r.name,
operation: r.operation,
sourceCells: r.sourceCells
}))
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.isSaving = false;
this.successMessage = 'Aggregation rules saved successfully.';
this.changeDetectorRef.markForCheck();
},
error: (err) => {
this.isSaving = false;
this.error =
err?.error?.message || err?.message || 'Failed to save rules.';
this.changeDetectorRef.markForCheck();
}
});
}
// ── Data Loading ─────────────────────────────────────────────────
private fetchPartnerships(): void {
this.familyOfficeDataService
.fetchPartnerships()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (partnerships) => {
this.partnerships = partnerships.map((p) => ({
id: p.id,
name: p.name
}));
this.changeDetectorRef.markForCheck();
}
});
}
private loadMappings(): void {
this.k1ImportDataService
.fetchCellMappings(this.selectedPartnershipId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (mappings: any[]) => {
this.mappings = mappings.map((m) => ({
boxNumber: m.boxNumber,
label: m.label,
description: m.description || '',
isCustom: m.isCustom,
isEditing: false,
editLabel: '',
editDescription: ''
}));
this.changeDetectorRef.markForCheck();
},
error: (err) => {
this.error =
err?.error?.message || 'Failed to load cell mappings.';
this.changeDetectorRef.markForCheck();
}
});
}
private loadAggregationRules(): void {
this.k1ImportDataService
.fetchAggregationRules(this.selectedPartnershipId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (rules: any[]) => {
this.aggregationRules = rules.map((r) => ({
name: r.name,
operation: r.operation,
sourceCells: (r.sourceCells as string[]) || [],
isEditing: false,
editName: '',
editSourceCells: ''
}));
this.changeDetectorRef.markForCheck();
},
error: (err) => {
this.error =
err?.error?.message || 'Failed to load aggregation rules.';
this.changeDetectorRef.markForCheck();
}
});
}
}

175
apps/client/src/app/pages/cell-mapping/cell-mapping-page.html

@ -0,0 +1,175 @@
<div class="container">
<h1>Cell Mapping Configuration</h1>
@if (error) {
<div class="alert alert-error">{{ error }}</div>
}
@if (successMessage) {
<div class="alert alert-success">{{ successMessage }}</div>
}
<!-- Partnership Selector -->
<section class="partnership-selector">
<mat-form-field appearance="outline">
<mat-label>Partnership</mat-label>
<mat-select [(ngModel)]="selectedPartnershipId" (selectionChange)="onPartnershipChange()">
@for (p of partnerships; track p.id) {
<mat-option [value]="p.id">{{ p.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</section>
@if (selectedPartnershipId) {
<!-- Cell Mappings -->
<section class="cell-mappings">
<h2>Cell Mappings</h2>
<table mat-table [dataSource]="mappings" class="mappings-table">
<!-- Box Number -->
<ng-container matColumnDef="boxNumber">
<th mat-header-cell *matHeaderCellDef>Box #</th>
<td mat-cell *matCellDef="let row">{{ row.boxNumber }}</td>
</ng-container>
<!-- Label -->
<ng-container matColumnDef="label">
<th mat-header-cell *matHeaderCellDef>Label</th>
<td mat-cell *matCellDef="let row">
@if (row.isEditing) {
<mat-form-field appearance="outline" class="inline-edit">
<input matInput [(ngModel)]="row.editLabel" />
</mat-form-field>
} @else {
{{ row.label }}
}
</td>
</ng-container>
<!-- Description -->
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>Description</th>
<td mat-cell *matCellDef="let row">
@if (row.isEditing) {
<mat-form-field appearance="outline" class="inline-edit">
<input matInput [(ngModel)]="row.editDescription" />
</mat-form-field>
} @else {
{{ row.description }}
}
</td>
</ng-container>
<!-- Is Custom -->
<ng-container matColumnDef="isCustom">
<th mat-header-cell *matHeaderCellDef>Custom</th>
<td mat-cell *matCellDef="let row">
@if (row.isCustom) {
<mat-icon class="custom-badge" matTooltip="Partnership-specific override">star</mat-icon>
}
</td>
</ng-container>
<!-- Actions -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let row; let i = index">
@if (row.isEditing) {
<button mat-icon-button (click)="saveEditMapping(row)" matTooltip="Save">
<mat-icon>check</mat-icon>
</button>
<button mat-icon-button (click)="cancelEditMapping(row)" matTooltip="Cancel">
<mat-icon>close</mat-icon>
</button>
} @else {
<button mat-icon-button (click)="startEditMapping(row)" matTooltip="Edit">
<mat-icon>edit</mat-icon>
</button>
@if (row.isCustom) {
<button mat-icon-button (click)="removeMapping(i)" matTooltip="Remove">
<mat-icon>delete</mat-icon>
</button>
}
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<!-- Add Custom Cell -->
<div class="add-row">
<mat-form-field appearance="outline">
<mat-label>Box #</mat-label>
<input matInput [(ngModel)]="newBoxNumber" placeholder="e.g. 20c" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Label</mat-label>
<input matInput [(ngModel)]="newLabel" placeholder="e.g. Other deductions" />
</mat-form-field>
<button mat-stroked-button (click)="addCustomCell()" [disabled]="!newBoxNumber || !newLabel">
<mat-icon>add</mat-icon> Add Custom Cell
</button>
</div>
<!-- Mapping Actions -->
<div class="mapping-actions">
<button mat-flat-button color="primary" (click)="saveMappings()" [disabled]="isSaving">
Save Mappings
</button>
<button mat-stroked-button color="warn" (click)="resetToDefaults()">
Reset to IRS Defaults
</button>
</div>
</section>
<!-- Aggregation Rules -->
<section class="aggregation-rules">
<h2>Aggregation Rules</h2>
@if (aggregationRules.length === 0) {
<p class="empty-state">No aggregation rules configured.</p>
}
@for (rule of aggregationRules; track rule.name; let i = $index) {
<div class="rule-card">
<div class="rule-header">
<strong>{{ rule.name }}</strong>
<span class="rule-operation">{{ rule.operation }}</span>
<button mat-icon-button (click)="removeAggregationRule(i)" matTooltip="Remove rule">
<mat-icon>delete</mat-icon>
</button>
</div>
<div class="rule-source-cells">
Source cells:
@for (cell of rule.sourceCells; track cell) {
<span class="cell-chip">{{ cell }}</span>
}
</div>
</div>
}
<!-- Add Aggregation Rule -->
<div class="add-rule-row">
<mat-form-field appearance="outline">
<mat-label>Rule Name</mat-label>
<input matInput [(ngModel)]="newRuleName" placeholder="e.g. Total Income" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Source Cells (comma-separated)</mat-label>
<input matInput [(ngModel)]="newRuleSourceCells" placeholder="e.g. 1, 2, 3, 4a" />
</mat-form-field>
<button mat-stroked-button (click)="addAggregationRule()" [disabled]="!newRuleName || !newRuleSourceCells">
<mat-icon>add</mat-icon> Add Rule
</button>
</div>
<div class="rule-actions">
<button mat-flat-button color="primary" (click)="saveAggregationRules()" [disabled]="isSaving">
Save Rules
</button>
</div>
</section>
}
</div>

159
apps/client/src/app/pages/cell-mapping/cell-mapping-page.scss

@ -0,0 +1,159 @@
:host {
display: block;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
h1 {
margin-bottom: 1.5rem;
}
h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
// Alerts
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.alert-error {
background-color: #fdecea;
color: #b71c1c;
}
.alert-success {
background-color: #e8f5e9;
color: #2e7d32;
}
// Partnership Selector
.partnership-selector {
margin-bottom: 1.5rem;
mat-form-field {
width: 100%;
max-width: 400px;
}
}
// Cell Mappings
.cell-mappings {
margin-bottom: 2rem;
}
.mappings-table {
width: 100%;
margin-bottom: 1rem;
}
.inline-edit {
width: 100%;
max-width: 200px;
}
.custom-badge {
color: #f9a825;
font-size: 20px;
}
.add-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
mat-form-field {
flex: 0 0 auto;
width: 160px;
}
}
.mapping-actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
// Aggregation Rules
.aggregation-rules {
margin-bottom: 2rem;
}
.empty-state {
color: rgba(0, 0, 0, 0.54);
font-style: italic;
margin-bottom: 1rem;
}
.rule-card {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.rule-header {
display: flex;
align-items: center;
gap: 0.75rem;
strong {
flex: 1;
}
.rule-operation {
font-family: monospace;
font-size: 0.8rem;
background-color: #e8eaf6;
color: #283593;
padding: 2px 8px;
border-radius: 4px;
}
}
.rule-source-cells {
margin-top: 0.5rem;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.7);
}
.cell-chip {
display: inline-block;
font-family: monospace;
font-size: 0.8rem;
background-color: #f5f5f5;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 12px;
padding: 2px 8px;
margin-left: 4px;
}
.add-rule-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
mat-form-field {
flex: 1 1 200px;
}
}
.rule-actions {
margin-top: 0.5rem;
}

3
libs/common/src/lib/dtos/k1-import.dto.ts

@ -1,11 +1,10 @@
import { K1ImportStatus, KDocumentStatus } from '@prisma/client';
import { KDocumentStatus } from '@prisma/client';
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
Min,

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

@ -125,10 +125,10 @@
### Implementation for User Story 4
- [ ] T038 [US4] Implement cell mapping service CRUD (get mappings with global fallback, upsert per-partnership mappings, reset to IRS default, aggregation rule CRUD, compute aggregates for a KDocument) in apps/api/src/app/cell-mapping/cell-mapping.service.ts
- [ ] T039 [US4] Implement cell mapping controller (GET /cell-mapping, PUT /cell-mapping, DELETE /cell-mapping/reset, GET /aggregation-rules, PUT /aggregation-rules, GET /aggregation-rules/compute) in apps/api/src/app/cell-mapping/cell-mapping.controller.ts
- [ ] T040 [US4] Create cell mapping page component (view/edit cell labels, add custom cells with isCustom flag, manage aggregation rules with source cell selection, reset to defaults button) in apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts
- [ ] T041 [US4] Integrate per-partnership custom cell mappings into extraction pipeline (field mapper loads partnership-specific mappings, falls back to global defaults for unmapped boxes) in apps/api/src/app/k1-import/k1-field-mapper.service.ts
- [X] T038 [US4] Implement cell mapping service CRUD (get mappings with global fallback, upsert per-partnership mappings, reset to IRS default, aggregation rule CRUD, compute aggregates for a KDocument) in apps/api/src/app/cell-mapping/cell-mapping.service.ts
- [X] T039 [US4] Implement cell mapping controller (GET /cell-mapping, PUT /cell-mapping, DELETE /cell-mapping/reset, GET /aggregation-rules, PUT /aggregation-rules, GET /aggregation-rules/compute) in apps/api/src/app/cell-mapping/cell-mapping.controller.ts
- [X] T040 [US4] Create cell mapping page component (view/edit cell labels, add custom cells with isCustom flag, manage aggregation rules with source cell selection, reset to defaults button) in apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts
- [X] T041 [US4] Integrate per-partnership custom cell mappings into extraction pipeline (field mapper loads partnership-specific mappings, falls back to global defaults for unmapped boxes) in apps/api/src/app/k1-import/k1-field-mapper.service.ts
**Checkpoint**: Cell mapping customization is functional — custom mappings persist across imports

Loading…
Cancel
Save