mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
467 lines
23 KiB
467 lines
23 KiB
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
|
|
import { HttpException, Injectable, OnModuleInit } from '@nestjs/common';
|
|
import { StatusCodes } from 'http-status-codes';
|
|
|
|
/** Allowed cell types */
|
|
type CellType = 'number' | 'string' | 'percentage' | 'boolean';
|
|
|
|
/** Default IRS K-1 (Form 1065) cell mappings */
|
|
const IRS_DEFAULT_MAPPINGS: Array<{
|
|
boxNumber: string;
|
|
label: string;
|
|
description: string;
|
|
cellType: CellType;
|
|
sortOrder: number;
|
|
}> = [
|
|
// ── Header / Metadata ──────────────────────────────────────────────────
|
|
{ boxNumber: 'K1_DOCUMENT_ID', label: 'K-1 Document ID', description: 'Large-font ID at top right of K-1 form', cellType: 'string', sortOrder: 0 },
|
|
{ boxNumber: 'TAX_YEAR', label: 'Tax Year', description: 'Calendar year or tax year beginning/ending', cellType: 'string', sortOrder: 1 },
|
|
{ boxNumber: 'FINAL_K1', label: 'Final K-1', description: 'Check if this is a final K-1', cellType: 'boolean', sortOrder: 2 },
|
|
{ boxNumber: 'AMENDED_K1', label: 'Amended K-1', description: 'Check if this is an amended K-1', cellType: 'boolean', sortOrder: 3 },
|
|
|
|
// ── Part I — Information About the Partnership ─────────────────────────
|
|
{ boxNumber: 'A', label: "Partnership's EIN", description: 'Part I, Line A — Employer identification number', cellType: 'string', sortOrder: 10 },
|
|
{ boxNumber: 'B', label: "Partnership's name, address, city, state, ZIP", description: 'Part I, Line B', cellType: 'string', sortOrder: 11 },
|
|
{ boxNumber: 'C', label: 'IRS center where partnership filed return', description: 'Part I, Line C', cellType: 'string', sortOrder: 12 },
|
|
{ boxNumber: 'D', label: 'Publicly traded partnership (PTP)', description: 'Part I, Line D — Check if PTP', cellType: 'boolean', sortOrder: 13 },
|
|
|
|
// ── Part II — Information About the Partner ────────────────────────────
|
|
{ boxNumber: 'E', label: "Partner's identifying number", description: 'Part II, Line E — SSN or TIN', cellType: 'string', sortOrder: 20 },
|
|
{ boxNumber: 'F', label: "Partner's name, address, city, state, ZIP", description: 'Part II, Line F', cellType: 'string', sortOrder: 21 },
|
|
{ boxNumber: 'G_GENERAL', label: 'General partner or LLC member-manager', description: 'Part II, Line G — General partner checkbox', cellType: 'boolean', sortOrder: 22 },
|
|
{ boxNumber: 'G_LIMITED', label: 'Limited partner or other LLC member', description: 'Part II, Line G — Limited partner checkbox', cellType: 'boolean', sortOrder: 23 },
|
|
{ boxNumber: 'H1_DOMESTIC', label: 'Domestic partner', description: 'Part II, Line H1 — Domestic', cellType: 'boolean', sortOrder: 24 },
|
|
{ boxNumber: 'H1_FOREIGN', label: 'Foreign partner', description: 'Part II, Line H1 — Foreign', cellType: 'boolean', sortOrder: 25 },
|
|
{ boxNumber: 'H2', label: 'Disregarded entity (DE)', description: 'Part II, Line H2 — DE checkbox', cellType: 'boolean', sortOrder: 26 },
|
|
{ boxNumber: 'H2_TIN', label: 'Disregarded entity TIN', description: 'Part II, Line H2 — DE taxpayer ID', cellType: 'string', sortOrder: 27 },
|
|
{ boxNumber: 'I1', label: 'Type of entity', description: 'Part II, Line I1 — Entity type of partner', cellType: 'string', sortOrder: 28 },
|
|
{ boxNumber: 'I2', label: 'Retirement plan (IRA/SEP/Keogh)', description: 'Part II, Line I2 — Retirement plan checkbox', cellType: 'boolean', sortOrder: 29 },
|
|
|
|
// ── Section J — Partner's Share of Profit, Loss, and Capital ───────────
|
|
{ boxNumber: 'J_PROFIT_BEGIN', label: 'Profit — Beginning %', description: 'Section J — Profit share beginning of year', cellType: 'percentage', sortOrder: 30 },
|
|
{ boxNumber: 'J_PROFIT_END', label: 'Profit — Ending %', description: 'Section J — Profit share end of year', cellType: 'percentage', sortOrder: 31 },
|
|
{ boxNumber: 'J_LOSS_BEGIN', label: 'Loss — Beginning %', description: 'Section J — Loss share beginning of year', cellType: 'percentage', sortOrder: 32 },
|
|
{ boxNumber: 'J_LOSS_END', label: 'Loss — Ending %', description: 'Section J — Loss share end of year', cellType: 'percentage', sortOrder: 33 },
|
|
{ boxNumber: 'J_CAPITAL_BEGIN', label: 'Capital — Beginning %', description: 'Section J — Capital share beginning of year', cellType: 'percentage', sortOrder: 34 },
|
|
{ boxNumber: 'J_CAPITAL_END', label: 'Capital — Ending %', description: 'Section J — Capital share end of year', cellType: 'percentage', sortOrder: 35 },
|
|
{ boxNumber: 'J_SALE', label: 'Decrease due to sale', description: 'Section J — Check if decrease is due to sale', cellType: 'boolean', sortOrder: 36 },
|
|
{ boxNumber: 'J_EXCHANGE', label: 'Exchange of partnership interest', description: 'Section J — Check if exchange', cellType: 'boolean', sortOrder: 37 },
|
|
|
|
// ── Section K — Partner's Share of Liabilities ─────────────────────────
|
|
{ boxNumber: 'K_NONRECOURSE_BEGIN', label: 'Nonrecourse — Beginning', description: 'Section K — Nonrecourse liabilities beginning', cellType: 'number', sortOrder: 40 },
|
|
{ boxNumber: 'K_NONRECOURSE_END', label: 'Nonrecourse — Ending', description: 'Section K — Nonrecourse liabilities ending', cellType: 'number', sortOrder: 41 },
|
|
{ boxNumber: 'K_QUAL_NONRECOURSE_BEGIN', label: 'Qualified nonrecourse — Beginning', description: 'Section K — Qualified nonrecourse financing beginning', cellType: 'number', sortOrder: 42 },
|
|
{ boxNumber: 'K_QUAL_NONRECOURSE_END', label: 'Qualified nonrecourse — Ending', description: 'Section K — Qualified nonrecourse financing ending', cellType: 'number', sortOrder: 43 },
|
|
{ boxNumber: 'K_RECOURSE_BEGIN', label: 'Recourse — Beginning', description: 'Section K — Recourse liabilities beginning', cellType: 'number', sortOrder: 44 },
|
|
{ boxNumber: 'K_RECOURSE_END', label: 'Recourse — Ending', description: 'Section K — Recourse liabilities ending', cellType: 'number', sortOrder: 45 },
|
|
{ boxNumber: 'K2', label: 'Includes lower-tier partnership liabilities', description: 'Section K2 — Checkbox', cellType: 'boolean', sortOrder: 46 },
|
|
{ boxNumber: 'K3', label: 'Liability subject to guarantees', description: 'Section K3 — Checkbox', cellType: 'boolean', sortOrder: 47 },
|
|
|
|
// ── Section L — Partner's Capital Account Analysis ─────────────────────
|
|
{ boxNumber: 'L_BEG_CAPITAL', label: 'Beginning capital account', description: 'Section L — Beginning capital', cellType: 'number', sortOrder: 50 },
|
|
{ boxNumber: 'L_CONTRIBUTED', label: 'Capital contributed during year', description: 'Section L — Capital contributed', cellType: 'number', sortOrder: 51 },
|
|
{ boxNumber: 'L_CURR_YR_INCOME', label: 'Current year net income (loss)', description: 'Section L — Current year income/loss', cellType: 'number', sortOrder: 52 },
|
|
{ boxNumber: 'L_OTHER', label: 'Other increase (decrease)', description: 'Section L — Other adjustments', cellType: 'number', sortOrder: 53 },
|
|
{ boxNumber: 'L_WITHDRAWALS', label: 'Withdrawals and distributions', description: 'Section L — Withdrawals/distributions', cellType: 'number', sortOrder: 54 },
|
|
{ boxNumber: 'L_END_CAPITAL', label: 'Ending capital account', description: 'Section L — Ending capital', cellType: 'number', sortOrder: 55 },
|
|
|
|
// ── Section M — Contributed Property ───────────────────────────────────
|
|
{ boxNumber: 'M_YES', label: 'Contributed property with built-in gain/loss — Yes', description: 'Section M — Yes checkbox', cellType: 'boolean', sortOrder: 60 },
|
|
{ boxNumber: 'M_NO', label: 'Contributed property with built-in gain/loss — No', description: 'Section M — No checkbox', cellType: 'boolean', sortOrder: 61 },
|
|
|
|
// ── Section N — Net Unrecognized Section 704(c) ────────────────────────
|
|
{ boxNumber: 'N_BEGINNING', label: 'Net 704(c) gain/loss — Beginning', description: 'Section N — Beginning balance', cellType: 'number', sortOrder: 62 },
|
|
{ boxNumber: 'N_ENDING', label: 'Net 704(c) gain/loss — Ending', description: 'Section N — Ending balance', cellType: 'number', sortOrder: 63 },
|
|
|
|
// ── Part III — Partner's Share of Current Year Income, Deductions, etc. ─
|
|
{ boxNumber: '1', label: 'Ordinary business income (loss)', description: 'IRS Schedule K-1 Box 1', cellType: 'number', sortOrder: 100 },
|
|
{ boxNumber: '2', label: 'Net rental real estate income (loss)', description: 'IRS Schedule K-1 Box 2', cellType: 'number', sortOrder: 101 },
|
|
{ boxNumber: '3', label: 'Other net rental income (loss)', description: 'IRS Schedule K-1 Box 3', cellType: 'number', sortOrder: 102 },
|
|
{ boxNumber: '4', label: 'Guaranteed payments for services', description: 'IRS Schedule K-1 Box 4', cellType: 'number', sortOrder: 103 },
|
|
{ boxNumber: '4a', label: 'Guaranteed payments for capital', description: 'IRS Schedule K-1 Box 4a', cellType: 'number', sortOrder: 104 },
|
|
{ boxNumber: '4b', label: 'Total guaranteed payments', description: 'IRS Schedule K-1 Box 4b', cellType: 'number', sortOrder: 105 },
|
|
{ boxNumber: '5', label: 'Interest income', description: 'IRS Schedule K-1 Box 5', cellType: 'number', sortOrder: 106 },
|
|
{ boxNumber: '6a', label: 'Ordinary dividends', description: 'IRS Schedule K-1 Box 6a', cellType: 'number', sortOrder: 107 },
|
|
{ boxNumber: '6b', label: 'Qualified dividends', description: 'IRS Schedule K-1 Box 6b', cellType: 'number', sortOrder: 108 },
|
|
{ boxNumber: '6c', label: 'Dividend equivalents', description: 'IRS Schedule K-1 Box 6c', cellType: 'number', sortOrder: 109 },
|
|
{ boxNumber: '7', label: 'Royalties', description: 'IRS Schedule K-1 Box 7', cellType: 'number', sortOrder: 110 },
|
|
{ boxNumber: '8', label: 'Net short-term capital gain (loss)', description: 'IRS Schedule K-1 Box 8', cellType: 'number', sortOrder: 111 },
|
|
{ boxNumber: '9a', label: 'Net long-term capital gain (loss)', description: 'IRS Schedule K-1 Box 9a', cellType: 'number', sortOrder: 112 },
|
|
{ boxNumber: '9b', label: 'Collectibles (28%) gain (loss)', description: 'IRS Schedule K-1 Box 9b', cellType: 'number', sortOrder: 113 },
|
|
{ boxNumber: '9c', label: 'Unrecaptured section 1250 gain', description: 'IRS Schedule K-1 Box 9c', cellType: 'number', sortOrder: 114 },
|
|
{ boxNumber: '10', label: 'Net section 1231 gain (loss)', description: 'IRS Schedule K-1 Box 10', cellType: 'number', sortOrder: 115 },
|
|
{ boxNumber: '11', label: 'Other income (loss)', description: 'IRS Schedule K-1 Box 11', cellType: 'number', sortOrder: 116 },
|
|
{ boxNumber: '12', label: 'Section 179 deduction', description: 'IRS Schedule K-1 Box 12', cellType: 'number', sortOrder: 117 },
|
|
{ boxNumber: '13', label: 'Other deductions', description: 'IRS Schedule K-1 Box 13', cellType: 'number', sortOrder: 118 },
|
|
{ boxNumber: '14', label: 'Self-employment earnings (loss)', description: 'IRS Schedule K-1 Box 14', cellType: 'number', sortOrder: 119 },
|
|
{ boxNumber: '15', label: 'Credits', description: 'IRS Schedule K-1 Box 15', cellType: 'number', sortOrder: 120 },
|
|
{ boxNumber: '16', label: 'Foreign transactions', description: 'IRS Schedule K-1 Box 16', cellType: 'number', sortOrder: 121 },
|
|
{ boxNumber: '16_K3', label: 'Schedule K-3 is attached', description: 'IRS Schedule K-1 Box 16 K-3 checkbox', cellType: 'boolean', sortOrder: 122 },
|
|
{ boxNumber: '17', label: 'Alternative minimum tax (AMT) items', description: 'IRS Schedule K-1 Box 17', cellType: 'number', sortOrder: 123 },
|
|
{ boxNumber: '18', label: 'Tax-exempt income and nondeductible expenses', description: 'IRS Schedule K-1 Box 18', cellType: 'number', sortOrder: 124 },
|
|
{ boxNumber: '19', label: 'Distributions', description: 'IRS Schedule K-1 Box 19', cellType: 'number', sortOrder: 125 },
|
|
{ boxNumber: '19a', label: 'Distributions — Cash and marketable securities', description: 'IRS Schedule K-1 Box 19a', cellType: 'number', sortOrder: 126 },
|
|
{ boxNumber: '19b', label: 'Distributions — Other property', description: 'IRS Schedule K-1 Box 19b', cellType: 'number', sortOrder: 127 },
|
|
{ boxNumber: '20A', label: 'Other information — Code A', description: 'IRS Schedule K-1 Box 20, Code A', cellType: 'number', sortOrder: 128 },
|
|
{ boxNumber: '20B', label: 'Other information — Code B', description: 'IRS Schedule K-1 Box 20, Code B', cellType: 'number', sortOrder: 129 },
|
|
{ boxNumber: '20V', label: 'Other information — Code V', description: 'IRS Schedule K-1 Box 20, Code V', cellType: 'number', sortOrder: 130 },
|
|
{ boxNumber: '20_WILDCARD', label: 'Other information — Other codes', description: 'IRS Schedule K-1 Box 20, all other codes', cellType: 'number', sortOrder: 131 },
|
|
{ boxNumber: '21', label: 'Foreign taxes paid or accrued', description: 'IRS Schedule K-1 Box 21', cellType: 'number', sortOrder: 132 },
|
|
{ boxNumber: '22', label: 'More than one activity for at-risk purposes', description: 'IRS Schedule K-1 Box 22 — Checkbox', cellType: 'boolean', sortOrder: 133 },
|
|
{ boxNumber: '23', label: 'More than one activity for passive activity purposes', description: 'IRS Schedule K-1 Box 23 — Checkbox', cellType: 'boolean', sortOrder: 134 }
|
|
];
|
|
|
|
/** Default aggregation rules */
|
|
const DEFAULT_AGGREGATION_RULES: Array<{
|
|
name: string;
|
|
operation: string;
|
|
sourceCells: string[];
|
|
sortOrder: number;
|
|
}> = [
|
|
{
|
|
name: 'Total Ordinary Income',
|
|
operation: 'SUM',
|
|
sourceCells: ['1'],
|
|
sortOrder: 1
|
|
},
|
|
{
|
|
name: 'Total Capital Gains',
|
|
operation: 'SUM',
|
|
sourceCells: ['8', '9a', '9b', '9c', '10'],
|
|
sortOrder: 2
|
|
},
|
|
{
|
|
name: 'Total Deductions',
|
|
operation: 'SUM',
|
|
sourceCells: ['12', '13'],
|
|
sortOrder: 3
|
|
}
|
|
];
|
|
|
|
@Injectable()
|
|
export class CellMappingService implements OnModuleInit {
|
|
public constructor(private readonly prismaService: PrismaService) {}
|
|
|
|
public async onModuleInit() {
|
|
await this.seedDefaultMappings();
|
|
await this.seedDefaultAggregationRules();
|
|
}
|
|
|
|
/**
|
|
* Seed default IRS cell mappings (partnershipId = null) if they don't exist.
|
|
* Also adds any new default mappings that may have been introduced in updates.
|
|
*/
|
|
public async seedDefaultMappings() {
|
|
const existing = await this.prismaService.cellMapping.findMany({
|
|
where: { partnershipId: null }
|
|
});
|
|
const existingBoxNumbers = new Set(existing.map((m) => m.boxNumber));
|
|
|
|
const newMappings = IRS_DEFAULT_MAPPINGS.filter(
|
|
(m) => !existingBoxNumbers.has(m.boxNumber)
|
|
);
|
|
|
|
if (newMappings.length > 0) {
|
|
await this.prismaService.cellMapping.createMany({
|
|
data: newMappings.map((mapping) => ({
|
|
...mapping,
|
|
partnershipId: null,
|
|
isCustom: false,
|
|
isIgnored: false,
|
|
cellType: mapping.cellType
|
|
}))
|
|
});
|
|
}
|
|
|
|
// Backfill cellType on existing defaults that were seeded before the cellType column existed
|
|
for (const defaultMapping of IRS_DEFAULT_MAPPINGS) {
|
|
const existingRow = existing.find((e) => e.boxNumber === defaultMapping.boxNumber);
|
|
if (existingRow && (existingRow as any).cellType === 'number' && defaultMapping.cellType !== 'number') {
|
|
await this.prismaService.cellMapping.update({
|
|
where: { id: existingRow.id },
|
|
data: { cellType: defaultMapping.cellType }
|
|
});
|
|
}
|
|
}
|
|
|
|
// Clean up stale parent-level box "20" that was replaced by 20A/20B/20V/20_WILDCARD
|
|
const validBoxNumbers = new Set(IRS_DEFAULT_MAPPINGS.map((m) => m.boxNumber));
|
|
const staleDefaults = existing.filter(
|
|
(m) => !m.isCustom && !validBoxNumbers.has(m.boxNumber)
|
|
);
|
|
if (staleDefaults.length > 0) {
|
|
await this.prismaService.cellMapping.deleteMany({
|
|
where: {
|
|
id: { in: staleDefaults.map((m) => m.id) }
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Seed default aggregation rules (partnershipId = null) if they don't exist
|
|
*/
|
|
public async seedDefaultAggregationRules() {
|
|
const existingCount = await this.prismaService.cellAggregationRule.count({
|
|
where: { partnershipId: null }
|
|
});
|
|
|
|
if (existingCount > 0) {
|
|
return;
|
|
}
|
|
|
|
await this.prismaService.cellAggregationRule.createMany({
|
|
data: DEFAULT_AGGREGATION_RULES.map((rule) => ({
|
|
...rule,
|
|
partnershipId: null
|
|
}))
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get cell mappings for a partnership (with global defaults for unmapped boxes)
|
|
*/
|
|
public async getMappings(partnershipId?: string) {
|
|
if (!partnershipId) {
|
|
return this.prismaService.cellMapping.findMany({
|
|
where: { partnershipId: null },
|
|
orderBy: { sortOrder: 'asc' }
|
|
});
|
|
}
|
|
|
|
// Get partnership-specific mappings
|
|
const partnershipMappings = await this.prismaService.cellMapping.findMany({
|
|
where: { partnershipId },
|
|
orderBy: { sortOrder: 'asc' }
|
|
});
|
|
|
|
// Get global defaults for any boxes not overridden
|
|
const globalMappings = await this.prismaService.cellMapping.findMany({
|
|
where: { partnershipId: null },
|
|
orderBy: { sortOrder: 'asc' }
|
|
});
|
|
|
|
const partnershipBoxNumbers = new Set(
|
|
partnershipMappings.map((m) => m.boxNumber)
|
|
);
|
|
|
|
const mergedMappings = [
|
|
...partnershipMappings,
|
|
...globalMappings.filter((g) => !partnershipBoxNumbers.has(g.boxNumber))
|
|
];
|
|
|
|
return mergedMappings.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
}
|
|
|
|
/**
|
|
* Get aggregation rules for a partnership (with global defaults)
|
|
*/
|
|
public async getAggregationRules(partnershipId?: string) {
|
|
if (!partnershipId) {
|
|
return this.prismaService.cellAggregationRule.findMany({
|
|
where: { partnershipId: null },
|
|
orderBy: { sortOrder: 'asc' }
|
|
});
|
|
}
|
|
|
|
const partnershipRules =
|
|
await this.prismaService.cellAggregationRule.findMany({
|
|
where: { partnershipId },
|
|
orderBy: { sortOrder: 'asc' }
|
|
});
|
|
|
|
if (partnershipRules.length > 0) {
|
|
return partnershipRules;
|
|
}
|
|
|
|
// Fall back to global defaults
|
|
return this.prismaService.cellAggregationRule.findMany({
|
|
where: { partnershipId: null },
|
|
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;
|
|
cellType?: string;
|
|
isCustom: boolean;
|
|
}>
|
|
) {
|
|
const results = [];
|
|
|
|
for (let i = 0; i < mappings.length; i++) {
|
|
const mapping = mappings[i];
|
|
const updateData: Record<string, any> = {
|
|
label: mapping.label,
|
|
description: mapping.description || null,
|
|
isCustom: mapping.isCustom,
|
|
sortOrder: i + 1
|
|
};
|
|
if (mapping.cellType) {
|
|
updateData.cellType = mapping.cellType;
|
|
}
|
|
|
|
const result = await this.prismaService.cellMapping.upsert({
|
|
where: {
|
|
partnershipId_boxNumber: {
|
|
partnershipId,
|
|
boxNumber: mapping.boxNumber
|
|
}
|
|
},
|
|
update: updateData,
|
|
create: {
|
|
partnershipId,
|
|
boxNumber: mapping.boxNumber,
|
|
label: mapping.label,
|
|
description: mapping.description || null,
|
|
cellType: mapping.cellType || 'number',
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Toggle the isIgnored flag on a cell mapping.
|
|
* If a partnership-specific override exists, toggles it.
|
|
* If only the global default exists, creates a partnership-specific override with isIgnored toggled.
|
|
*/
|
|
public async toggleIgnored(
|
|
partnershipId: string,
|
|
boxNumber: string
|
|
) {
|
|
// Check for partnership-specific mapping first
|
|
const existing = await this.prismaService.cellMapping.findUnique({
|
|
where: { partnershipId_boxNumber: { partnershipId, boxNumber } }
|
|
});
|
|
|
|
if (existing) {
|
|
return this.prismaService.cellMapping.update({
|
|
where: { id: existing.id },
|
|
data: { isIgnored: !existing.isIgnored }
|
|
});
|
|
}
|
|
|
|
// No partnership override — check for global default and create an override
|
|
const globalMapping = await this.prismaService.cellMapping.findFirst({
|
|
where: { partnershipId: null, boxNumber }
|
|
});
|
|
|
|
if (globalMapping) {
|
|
return this.prismaService.cellMapping.create({
|
|
data: {
|
|
partnershipId,
|
|
boxNumber: globalMapping.boxNumber,
|
|
label: globalMapping.label,
|
|
description: globalMapping.description,
|
|
cellType: globalMapping.cellType,
|
|
isCustom: false,
|
|
isIgnored: true,
|
|
sortOrder: globalMapping.sortOrder
|
|
}
|
|
});
|
|
}
|
|
|
|
throw new HttpException(
|
|
`No cell mapping found for box ${boxNumber}`,
|
|
StatusCodes.NOT_FOUND
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|