feat(accounting): edit finalized document cost centres

This commit is contained in:
2026-09-09 14:37:22 +02:00
parent 2ec162c0e7
commit ebbd1e0cd0
5 changed files with 301 additions and 2 deletions

View File

@@ -0,0 +1,146 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { costcentres, createddocuments } from "../../db/schema"
import { insertHistoryItem } from "../utils/history"
export type RowCostCentreAssignment = {
rowId: string
costcentre: string | null
}
export type OutgoingDocumentCostCentreUpdate = {
costcentre?: string | null
rowCostCentres?: RowCostCentreAssignment[]
}
const serviceError = (message: string, statusCode = 400) =>
Object.assign(new Error(message), { statusCode })
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function applyRowCostCentreAssignments(
rows: unknown,
assignments: RowCostCentreAssignment[] = [],
) {
if (!Array.isArray(rows)) return []
if (!assignments.length) return rows
const assignmentMap = new Map(assignments.map((assignment) => [String(assignment.rowId), assignment.costcentre]))
const existingIds = new Set(rows
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => (row as Record<string, unknown>).id)
.filter((id) => id !== null && id !== undefined && id !== "")
.map(String))
for (const rowId of assignmentMap.keys()) {
if (!existingIds.has(rowId)) throw serviceError(`Position ${rowId} wurde im Ausgangsbeleg nicht gefunden`)
}
return rows.map((row) => {
if (!row || typeof row !== "object" || Array.isArray(row)) return row
const rowRecord = row as Record<string, unknown>
if (rowRecord.id === null || rowRecord.id === undefined || rowRecord.id === "") return row
const rowId = String(rowRecord.id)
if (!assignmentMap.has(rowId)) return row
const { costcentre: _legacyCostCentre, ...rest } = rowRecord
return { ...rest, costCentre: assignmentMap.get(rowId) ?? null }
})
}
export async function updateOutgoingDocumentCostCentres(
server: FastifyInstance,
tenantId: number,
userId: string,
documentId: number,
input: OutgoingDocumentCostCentreUpdate,
) {
const hasDocumentCostCentre = Object.prototype.hasOwnProperty.call(input, "costcentre")
if (hasDocumentCostCentre && input.costcentre !== null && !UUID_PATTERN.test(String(input.costcentre))) {
throw serviceError("costcentre muss eine gültige UUID oder null sein")
}
if (input.rowCostCentres !== undefined && !Array.isArray(input.rowCostCentres)) {
throw serviceError("rowCostCentres muss ein Array sein")
}
const rowAssignments = (input.rowCostCentres || []).map((assignment) => {
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
const costcentre = assignment?.costcentre
if (!rowId) throw serviceError("Jede Positionszuordnung benötigt eine rowId")
if (costcentre !== null && !UUID_PATTERN.test(String(costcentre))) {
throw serviceError(`Ungültige Kostenstelle für Position ${rowId}`)
}
return { rowId, costcentre }
})
if (!hasDocumentCostCentre && !rowAssignments.length) {
throw serviceError("Mindestens eine Beleg- oder Positionskostenstelle ist erforderlich")
}
const [existing] = await server.db
.select()
.from(createddocuments)
.where(and(
eq(createddocuments.id, documentId),
eq(createddocuments.tenant, tenantId),
eq(createddocuments.archived, false),
))
.limit(1)
if (!existing) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
if (existing.state !== "Gebucht") {
throw serviceError("Kostenstellen können über diesen Weg nur bei fertiggestellten Ausgangsbelegen geändert werden")
}
const referencedCostCentreIds = [...new Set([
...(hasDocumentCostCentre && input.costcentre ? [input.costcentre] : []),
...rowAssignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
])]
if (referencedCostCentreIds.length) {
const validCostCentres = await server.db
.select({ id: costcentres.id })
.from(costcentres)
.where(and(
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
inArray(costcentres.id, referencedCostCentreIds),
))
if (validCostCentres.length !== referencedCostCentreIds.length) {
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
}
}
const updatedRows = applyRowCostCentreAssignments(existing.rows, rowAssignments)
const update: Record<string, unknown> = {
rows: updatedRows,
updatedAt: new Date(),
updatedBy: userId,
}
if (hasDocumentCostCentre) update.costcentre = input.costcentre ?? null
const [updated] = await server.db
.update(createddocuments)
.set(update)
.where(and(
eq(createddocuments.id, documentId),
eq(createddocuments.tenant, tenantId),
eq(createddocuments.archived, false),
eq(createddocuments.state, "Gebucht"),
))
.returning()
if (!updated) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: "createddocuments",
entityId: documentId,
action: "updated",
oldVal: existing,
newVal: updated,
text: "Kostenstellen des fertiggestellten Ausgangsbelegs geändert",
})
return updated
}