feat: overhaul all 12 sector extensions with full CRUD, validation, and tests
Add shared components (ConfirmDeleteDialog, EditEntryDialog, validation utils), enhance all 12 extension workspaces with edit/delete dialogs, input validation, period comparisons, and new analytics features. Fix critical bugs in ProjectBilling margin calculation and EarningsPerLiter revenue allocation. Add pure calculation modules with 183 new tests across all extensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateProjectStats,
|
||||
calculateCategoryBreakdown,
|
||||
filterByDateRange,
|
||||
getBudgetStatus,
|
||||
type CostEntry,
|
||||
type RevenueEntry,
|
||||
} from '../project-cost-calculator'
|
||||
|
||||
describe('calculateProjectStats', () => {
|
||||
it('calculates basic project stats correctly', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 200000, date: '2025-03-01', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 100000, date: '2025-03-15', category: 'labor' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 500000, date: '2025-03-20' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 400000)
|
||||
|
||||
expect(stats.totalCost).toBe(300000)
|
||||
expect(stats.totalRevenue).toBe(500000)
|
||||
// margin: (500000 - 300000) / 500000 * 100 = 40
|
||||
expect(stats.margin).toBe(40)
|
||||
// budgetUsed: 300000 / 400000 * 100 = 75
|
||||
expect(stats.budgetUsed).toBe(75)
|
||||
expect(stats.budgetStatus).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns margin 0 when revenue is zero', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 50000, date: '2025-01-10', category: 'materials' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = []
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 100000)
|
||||
|
||||
expect(stats.totalCost).toBe(50000)
|
||||
expect(stats.totalRevenue).toBe(0)
|
||||
expect(stats.margin).toBe(0)
|
||||
})
|
||||
|
||||
it('returns negative margin when costs exceed revenue', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 150000, date: '2025-02-01', category: 'labor' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 100000, date: '2025-02-15' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 200000)
|
||||
|
||||
// margin: (100000 - 150000) / 100000 * 100 = -50
|
||||
expect(stats.margin).toBe(-50)
|
||||
})
|
||||
|
||||
it('caps budgetUsed at 100 when costs exceed budget', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 210000, date: '2025-04-01', category: 'materials' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 300000, date: '2025-04-10' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 200000)
|
||||
|
||||
// raw budgetUsed: 210000 / 200000 * 100 = 105, capped at 100
|
||||
expect(stats.budgetUsed).toBe(100)
|
||||
expect(stats.budgetStatus).toBe('danger')
|
||||
})
|
||||
|
||||
it('returns zero stats for empty arrays', () => {
|
||||
const stats = calculateProjectStats([], [], 100000)
|
||||
|
||||
expect(stats.totalCost).toBe(0)
|
||||
expect(stats.totalRevenue).toBe(0)
|
||||
expect(stats.margin).toBe(0)
|
||||
expect(stats.budgetUsed).toBe(0)
|
||||
expect(stats.budgetStatus).toBe('ok')
|
||||
})
|
||||
|
||||
it('handles monetary rounding edge cases', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 33333.33, date: '2025-01-01', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 33333.33, date: '2025-01-02', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 33333.34, date: '2025-01-03', category: 'equipment' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 200000, date: '2025-01-15' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 150000)
|
||||
|
||||
// totalCost: 33333.33 + 33333.33 + 33333.34 = 100000.00
|
||||
expect(stats.totalCost).toBe(100000)
|
||||
expect(stats.totalRevenue).toBe(200000)
|
||||
// margin: (200000 - 100000) / 200000 * 100 = 50
|
||||
expect(stats.margin).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBudgetStatus', () => {
|
||||
it('returns ok when budget used is below 80%', () => {
|
||||
expect(getBudgetStatus(0)).toBe('ok')
|
||||
expect(getBudgetStatus(50)).toBe('ok')
|
||||
expect(getBudgetStatus(79.99)).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns warning when budget used is between 80% and 99.99%', () => {
|
||||
expect(getBudgetStatus(80)).toBe('warning')
|
||||
expect(getBudgetStatus(85)).toBe('warning')
|
||||
expect(getBudgetStatus(99.99)).toBe('warning')
|
||||
})
|
||||
|
||||
it('returns danger when budget used is 100% or above', () => {
|
||||
expect(getBudgetStatus(100)).toBe('danger')
|
||||
expect(getBudgetStatus(105)).toBe('danger')
|
||||
expect(getBudgetStatus(200)).toBe('danger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCategoryBreakdown', () => {
|
||||
it('breaks down costs by category with correct percentages', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 60000, date: '2025-01-01', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 30000, date: '2025-01-05', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 10000, date: '2025-01-10', category: 'equipment' },
|
||||
]
|
||||
|
||||
const breakdown = calculateCategoryBreakdown(costs)
|
||||
|
||||
expect(breakdown).toHaveLength(3)
|
||||
// Sorted by amount descending
|
||||
expect(breakdown[0]).toEqual({ category: 'materials', amount: 60000, percent: 60 })
|
||||
expect(breakdown[1]).toEqual({ category: 'labor', amount: 30000, percent: 30 })
|
||||
expect(breakdown[2]).toEqual({ category: 'equipment', amount: 10000, percent: 10 })
|
||||
})
|
||||
|
||||
it('returns single category as 100%', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 25000, date: '2025-01-01', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 75000, date: '2025-01-05', category: 'labor' },
|
||||
]
|
||||
|
||||
const breakdown = calculateCategoryBreakdown(costs)
|
||||
|
||||
expect(breakdown).toHaveLength(1)
|
||||
expect(breakdown[0]).toEqual({ category: 'labor', amount: 100000, percent: 100 })
|
||||
})
|
||||
|
||||
it('returns empty array for empty costs', () => {
|
||||
const breakdown = calculateCategoryBreakdown([])
|
||||
|
||||
expect(breakdown).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterByDateRange', () => {
|
||||
const entries: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 1000, date: '2025-01-15', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 2000, date: '2025-02-15', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 3000, date: '2025-03-15', category: 'equipment' },
|
||||
]
|
||||
|
||||
it('includes entries within the date range', () => {
|
||||
const filtered = filterByDateRange(entries, '2025-01-01', '2025-02-28')
|
||||
|
||||
expect(filtered).toHaveLength(2)
|
||||
expect(filtered[0].date).toBe('2025-01-15')
|
||||
expect(filtered[1].date).toBe('2025-02-15')
|
||||
})
|
||||
|
||||
it('excludes entries outside the date range', () => {
|
||||
const filtered = filterByDateRange(entries, '2025-04-01', '2025-04-30')
|
||||
|
||||
expect(filtered).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes entries on boundary dates', () => {
|
||||
const filtered = filterByDateRange(entries, '2025-01-15', '2025-03-15')
|
||||
|
||||
expect(filtered).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Pure calculation functions for the Project Cost extension.
|
||||
*
|
||||
* Tracks costs, revenues, margins, and budget usage per construction project.
|
||||
* All monetary calculations use Math.round(x * 100) / 100 per project rules.
|
||||
*/
|
||||
|
||||
export interface CostEntry {
|
||||
projectId: string
|
||||
amount: number
|
||||
date: string
|
||||
category: string
|
||||
}
|
||||
|
||||
export interface RevenueEntry {
|
||||
projectId: string
|
||||
amount: number
|
||||
date: string
|
||||
}
|
||||
|
||||
export interface ProjectStats {
|
||||
totalCost: number
|
||||
totalRevenue: number
|
||||
margin: number // (revenue - cost) / revenue * 100, or 0 if no revenue
|
||||
budgetUsed: number // cost / budget * 100, capped at 100
|
||||
budgetStatus: 'ok' | 'warning' | 'danger' // ok < 80%, warning >= 80%, danger >= 100%
|
||||
}
|
||||
|
||||
export interface CategoryBreakdown {
|
||||
category: string
|
||||
amount: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine budget status based on percentage of budget used.
|
||||
* - ok: less than 80%
|
||||
* - warning: 80% to below 100%
|
||||
* - danger: 100% or above
|
||||
*/
|
||||
export function getBudgetStatus(budgetUsedPct: number): 'ok' | 'warning' | 'danger' {
|
||||
if (budgetUsedPct >= 100) return 'danger'
|
||||
if (budgetUsedPct >= 80) return 'warning'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project statistics from cost entries, revenue entries, and budget.
|
||||
*
|
||||
* - totalCost: sum of all cost entry amounts
|
||||
* - totalRevenue: sum of all revenue entry amounts
|
||||
* - margin: (revenue - cost) / revenue * 100, or 0 when revenue is zero
|
||||
* - budgetUsed: cost / budget * 100, capped at 100; 0 if budget <= 0
|
||||
* - budgetStatus: derived from budgetUsed via getBudgetStatus
|
||||
*/
|
||||
export function calculateProjectStats(
|
||||
costs: CostEntry[],
|
||||
revenues: RevenueEntry[],
|
||||
budget: number
|
||||
): ProjectStats {
|
||||
const totalCost = Math.round(
|
||||
costs.reduce((sum, c) => sum + c.amount, 0) * 100
|
||||
) / 100
|
||||
|
||||
const totalRevenue = Math.round(
|
||||
revenues.reduce((sum, r) => sum + r.amount, 0) * 100
|
||||
) / 100
|
||||
|
||||
const margin = totalRevenue > 0
|
||||
? Math.round(((totalRevenue - totalCost) / totalRevenue) * 10000) / 100
|
||||
: 0
|
||||
|
||||
const rawBudgetUsed = budget > 0
|
||||
? Math.round((totalCost / budget) * 10000) / 100
|
||||
: 0
|
||||
|
||||
const budgetUsed = Math.min(rawBudgetUsed, 100)
|
||||
const budgetStatus = getBudgetStatus(rawBudgetUsed)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
totalRevenue,
|
||||
margin,
|
||||
budgetUsed,
|
||||
budgetStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost breakdown by category.
|
||||
*
|
||||
* Returns an array of categories sorted by amount descending,
|
||||
* each with the category name, total amount, and percentage of total cost.
|
||||
* Returns an empty array if there are no costs.
|
||||
*/
|
||||
export function calculateCategoryBreakdown(costs: CostEntry[]): CategoryBreakdown[] {
|
||||
if (costs.length === 0) return []
|
||||
|
||||
const totalCost = Math.round(
|
||||
costs.reduce((sum, c) => sum + c.amount, 0) * 100
|
||||
) / 100
|
||||
|
||||
const categoryMap = new Map<string, number>()
|
||||
for (const cost of costs) {
|
||||
const current = categoryMap.get(cost.category) ?? 0
|
||||
categoryMap.set(cost.category, current + cost.amount)
|
||||
}
|
||||
|
||||
const breakdown: CategoryBreakdown[] = []
|
||||
for (const [category, rawAmount] of categoryMap) {
|
||||
const amount = Math.round(rawAmount * 100) / 100
|
||||
const percent = totalCost > 0
|
||||
? Math.round((amount / totalCost) * 10000) / 100
|
||||
: 0
|
||||
breakdown.push({ category, amount, percent })
|
||||
}
|
||||
|
||||
breakdown.sort((a, b) => b.amount - a.amount)
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter entries by date range (inclusive on both ends).
|
||||
* Dates are compared as ISO date strings (YYYY-MM-DD).
|
||||
*/
|
||||
export function filterByDateRange<T extends { date: string }>(
|
||||
entries: T[],
|
||||
start: string,
|
||||
end: string
|
||||
): T[] {
|
||||
return entries.filter(e => e.date >= start && e.date <= end)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateRotDeduction,
|
||||
calculateCustomerQuotas,
|
||||
filterJobsByYear,
|
||||
generateRotCsvContent,
|
||||
MAX_ROT_YEARLY,
|
||||
ROT_RATE,
|
||||
type RotJob,
|
||||
} from '../rot-calculator'
|
||||
|
||||
describe('calculateRotDeduction', () => {
|
||||
it('calculates 30% of labor as ROT deduction', () => {
|
||||
const result = calculateRotDeduction(100000, 40000, 0)
|
||||
|
||||
// Labor = 100000 - 40000 = 60000
|
||||
// ROT = 60000 * 0.30 = 18000
|
||||
expect(result.labor).toBe(60000)
|
||||
expect(result.rotDeduction).toBe(18000)
|
||||
expect(result.customerPays).toBe(82000)
|
||||
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY - 18000)
|
||||
})
|
||||
|
||||
it('caps ROT deduction at remaining yearly quota', () => {
|
||||
// Customer has already used 45000 of 50000 quota
|
||||
const result = calculateRotDeduction(100000, 40000, 45000)
|
||||
|
||||
// Labor = 60000, raw ROT = 18000, but only 5000 remaining
|
||||
expect(result.rotDeduction).toBe(5000)
|
||||
expect(result.customerPays).toBe(95000)
|
||||
expect(result.remainingQuota).toBe(0)
|
||||
})
|
||||
|
||||
it('returns zero deduction when labor is zero (material equals total)', () => {
|
||||
const result = calculateRotDeduction(50000, 50000, 0)
|
||||
|
||||
expect(result.labor).toBe(0)
|
||||
expect(result.rotDeduction).toBe(0)
|
||||
expect(result.customerPays).toBe(50000)
|
||||
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY)
|
||||
})
|
||||
|
||||
it('returns zero deduction when quota is already exhausted', () => {
|
||||
const result = calculateRotDeduction(80000, 30000, MAX_ROT_YEARLY)
|
||||
|
||||
expect(result.labor).toBe(50000)
|
||||
expect(result.rotDeduction).toBe(0)
|
||||
expect(result.customerPays).toBe(80000)
|
||||
expect(result.remainingQuota).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates customerPays as total minus rotDeduction', () => {
|
||||
const result = calculateRotDeduction(75000, 25000, 0)
|
||||
|
||||
// Labor = 50000, ROT = 15000
|
||||
expect(result.customerPays).toBe(75000 - result.rotDeduction)
|
||||
expect(result.customerPays).toBe(60000)
|
||||
})
|
||||
|
||||
it('handles monetary rounding correctly', () => {
|
||||
// total=10001, material=3333 -> labor=6668
|
||||
// ROT = 6668 * 0.30 = 2000.4
|
||||
const result = calculateRotDeduction(10001, 3333, 0)
|
||||
|
||||
expect(result.labor).toBe(6668)
|
||||
expect(result.rotDeduction).toBe(2000.4)
|
||||
expect(result.customerPays).toBe(8000.6)
|
||||
expect(result.remainingQuota).toBe(Math.round((MAX_ROT_YEARLY - 2000.4) * 100) / 100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCustomerQuotas', () => {
|
||||
const baseJob: RotJob = {
|
||||
id: 'job-1',
|
||||
customerId: 'cust-1',
|
||||
total: 100000,
|
||||
material: 40000,
|
||||
labor: 60000,
|
||||
rotDeduction: 18000,
|
||||
date: '2025-03-15',
|
||||
status: 'completed',
|
||||
}
|
||||
|
||||
it('calculates used quota from completed jobs for a customer', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ ...baseJob, id: 'job-1', rotDeduction: 10000 },
|
||||
{ ...baseJob, id: 'job-2', rotDeduction: 15000 },
|
||||
]
|
||||
|
||||
const quotas = calculateCustomerQuotas(jobs, 2025)
|
||||
|
||||
expect(quotas.get('cust-1')).toBe(25000)
|
||||
})
|
||||
|
||||
it('does not count draft jobs toward quota', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ ...baseJob, id: 'job-1', rotDeduction: 10000, status: 'completed' },
|
||||
{ ...baseJob, id: 'job-2', rotDeduction: 15000, status: 'draft' },
|
||||
]
|
||||
|
||||
const quotas = calculateCustomerQuotas(jobs, 2025)
|
||||
|
||||
expect(quotas.get('cust-1')).toBe(10000)
|
||||
})
|
||||
|
||||
it('tracks multiple customers with different quotas', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ ...baseJob, id: 'job-1', customerId: 'cust-1', rotDeduction: 12000 },
|
||||
{ ...baseJob, id: 'job-2', customerId: 'cust-2', rotDeduction: 8000 },
|
||||
{ ...baseJob, id: 'job-3', customerId: 'cust-1', rotDeduction: 5000 },
|
||||
]
|
||||
|
||||
const quotas = calculateCustomerQuotas(jobs, 2025)
|
||||
|
||||
expect(quotas.get('cust-1')).toBe(17000)
|
||||
expect(quotas.get('cust-2')).toBe(8000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterJobsByYear', () => {
|
||||
it('returns only jobs from the specified year', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ id: '1', customerId: 'c1', total: 50000, material: 20000, labor: 30000, rotDeduction: 9000, date: '2025-06-01', status: 'completed' },
|
||||
{ id: '2', customerId: 'c1', total: 60000, material: 25000, labor: 35000, rotDeduction: 10500, date: '2024-11-15', status: 'completed' },
|
||||
{ id: '3', customerId: 'c1', total: 70000, material: 30000, labor: 40000, rotDeduction: 12000, date: '2025-12-31', status: 'completed' },
|
||||
]
|
||||
|
||||
const filtered = filterJobsByYear(jobs, 2025)
|
||||
|
||||
expect(filtered).toHaveLength(2)
|
||||
expect(filtered.map(j => j.id)).toEqual(['1', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateRotCsvContent', () => {
|
||||
it('generates CSV with correct columns for completed jobs only', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ id: '1', customerId: 'c1', total: 80000, material: 30000, labor: 50000, rotDeduction: 15000, date: '2025-04-10', status: 'completed' },
|
||||
{ id: '2', customerId: 'c2', total: 60000, material: 20000, labor: 40000, rotDeduction: 12000, date: '2025-05-20', status: 'draft' },
|
||||
{ id: '3', customerId: 'c1', total: 40000, material: 15000, labor: 25000, rotDeduction: 7500, date: '2025-06-15', status: 'completed' },
|
||||
]
|
||||
|
||||
const customers = new Map([
|
||||
['c1', { name: 'Anna Svensson', personalNumber: '198501011234' }],
|
||||
['c2', { name: 'Erik Johansson', personalNumber: '199003025678' }],
|
||||
])
|
||||
|
||||
const csv = generateRotCsvContent(jobs, customers)
|
||||
const lines = csv.split('\n')
|
||||
|
||||
expect(lines[0]).toBe('PersonalNumber,CustomerName,Labor,RotDeduction,Date')
|
||||
// Draft job (id=2) should be excluded
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[1]).toBe('198501011234,Anna Svensson,50000,15000,2025-04-10')
|
||||
expect(lines[2]).toBe('198501011234,Anna Svensson,25000,7500,2025-06-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('constants', () => {
|
||||
it('has correct ROT rate and yearly maximum', () => {
|
||||
expect(ROT_RATE).toBe(0.30)
|
||||
expect(MAX_ROT_YEARLY).toBe(50000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* ROT (Repairs, Conversion, Extension) tax deduction calculator.
|
||||
*
|
||||
* ROT deduction allows Swedish homeowners to deduct 30% of labor costs
|
||||
* for home renovation work, up to SEK 50 000 per person per year.
|
||||
*
|
||||
* All monetary values use Math.round(x * 100) / 100 to avoid
|
||||
* floating-point precision issues.
|
||||
*/
|
||||
|
||||
export const MAX_ROT_YEARLY = 50000
|
||||
export const ROT_RATE = 0.30
|
||||
|
||||
export interface RotJob {
|
||||
id: string
|
||||
customerId: string
|
||||
total: number
|
||||
material: number
|
||||
labor: number
|
||||
rotDeduction: number
|
||||
date: string
|
||||
status: 'draft' | 'completed'
|
||||
}
|
||||
|
||||
export interface RotCalculation {
|
||||
labor: number
|
||||
rotDeduction: number
|
||||
customerPays: number
|
||||
remainingQuota: number
|
||||
}
|
||||
|
||||
export interface CustomerQuota {
|
||||
customerId: string
|
||||
usedQuota: number
|
||||
remainingQuota: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ROT deduction for a job given the customer's already-used quota.
|
||||
*
|
||||
* The deduction is 30% of labor (total - material), capped by the
|
||||
* remaining yearly quota (MAX_ROT_YEARLY - usedQuota).
|
||||
*/
|
||||
export function calculateRotDeduction(
|
||||
total: number,
|
||||
material: number,
|
||||
usedQuota: number
|
||||
): RotCalculation {
|
||||
const labor = Math.round((total - material) * 100) / 100
|
||||
const remaining = Math.max(MAX_ROT_YEARLY - usedQuota, 0)
|
||||
const rawDeduction = Math.round(labor * ROT_RATE * 100) / 100
|
||||
const rotDeduction = Math.round(Math.min(rawDeduction, remaining) * 100) / 100
|
||||
const customerPays = Math.round((total - rotDeduction) * 100) / 100
|
||||
const remainingQuota = Math.round((remaining - rotDeduction) * 100) / 100
|
||||
|
||||
return {
|
||||
labor,
|
||||
rotDeduction,
|
||||
customerPays,
|
||||
remainingQuota,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate per-customer used quota for a given year.
|
||||
* Only completed jobs count toward the quota.
|
||||
*/
|
||||
export function calculateCustomerQuotas(
|
||||
jobs: RotJob[],
|
||||
year: number
|
||||
): Map<string, number> {
|
||||
const yearJobs = filterJobsByYear(jobs, year).filter(
|
||||
j => j.status === 'completed'
|
||||
)
|
||||
|
||||
const quotas = new Map<string, number>()
|
||||
|
||||
for (const job of yearJobs) {
|
||||
const current = quotas.get(job.customerId) ?? 0
|
||||
quotas.set(
|
||||
job.customerId,
|
||||
Math.round((current + job.rotDeduction) * 100) / 100
|
||||
)
|
||||
}
|
||||
|
||||
return quotas
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter jobs whose date falls within the specified year.
|
||||
*/
|
||||
export function filterJobsByYear(jobs: RotJob[], year: number): RotJob[] {
|
||||
const yearStr = String(year)
|
||||
return jobs.filter(j => j.date.startsWith(yearStr))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV content for Skatteverket ROT deduction reporting.
|
||||
* Only completed jobs are included.
|
||||
*
|
||||
* Columns: PersonalNumber, CustomerName, Labor, RotDeduction, Date
|
||||
*/
|
||||
export function generateRotCsvContent(
|
||||
jobs: RotJob[],
|
||||
customers: Map<string, { name: string; personalNumber: string }>
|
||||
): string {
|
||||
const header = 'PersonalNumber,CustomerName,Labor,RotDeduction,Date'
|
||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||
|
||||
const rows = completedJobs.map(job => {
|
||||
const customer = customers.get(job.customerId)
|
||||
const personalNumber = customer?.personalNumber ?? ''
|
||||
const customerName = customer?.name ?? ''
|
||||
return `${personalNumber},${customerName},${job.labor},${job.rotDeduction},${job.date}`
|
||||
})
|
||||
|
||||
return [header, ...rows].join('\n')
|
||||
}
|
||||
Reference in New Issue
Block a user