feat(accounting): allow cancelling serial invoice executions

This commit is contained in:
2026-10-02 18:48:06 +02:00
parent 7e6c2fbac5
commit fdcb9bfc1d
6 changed files with 269 additions and 87 deletions

View File

@@ -14,7 +14,7 @@ export const serialExecutions = pgTable("serial_executions", {
tenant: bigint("tenant", { mode: "number" }) tenant: bigint("tenant", { mode: "number" })
.notNull() .notNull()
.references(() => tenants.id), executionDate: timestamp("execution_date").notNull(), .references(() => tenants.id), executionDate: timestamp("execution_date").notNull(),
status: text("status").default("draft"), // 'draft', 'completed' status: text("status").default("draft"), // generating, draft, processing, cancelling, cancelled, completed, error
createdBy: text("created_by"), // oder UUID, je nach Auth-System createdBy: text("created_by"), // oder UUID, je nach Auth-System
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
summary: text("summary"), // z.B. "25 Rechnungen erstellt" summary: text("summary"), // z.B. "25 Rechnungen erstellt"

View File

@@ -802,7 +802,7 @@ export const accountingTools: McpTool[] = [
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
status: { type: "string", enum: ["draft", "completed", "error"] }, status: { type: "string", enum: ["generating", "draft", "processing", "cancelling", "cancelled", "completed", "error"] },
...paginationProperties, ...paginationProperties,
}, },
}, },

View File

@@ -14,6 +14,60 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
// Cancellation and finalization compete through conditional status updates.
export const cancelManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
return server.db.transaction(async (tx) => {
const [execution] = await tx.select().from(schema.serialExecutions).where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
)).for("update");
if (!execution) throw Object.assign(new Error("Durchlauf nicht gefunden."), { statusCode: 404 });
if (execution.status === "completed") {
throw Object.assign(new Error("Abgeschlossene Durchläufe können nicht abgebrochen werden."), { statusCode: 409 });
}
if (["cancelled", "cancelling"].includes(execution.status)) return { execution };
const status = ["generating", "processing"].includes(execution.status) ? "cancelling" : "cancelled";
if (status === "cancelled") await archiveExecutionDrafts(tx, executionId, tenantId);
const [updated] = await tx.update(schema.serialExecutions).set({
status,
summary: status === "cancelling" ? "Abbruch angefordert." : "Abgebrochen. Ungebuchte Entwürfe wurden archiviert.",
}).where(and(eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId))).returning();
return { execution: updated };
});
};
async function archiveExecutionDrafts(db: any, executionId: string, tenantId: number) {
await db.update(schema.createddocuments).set({ archived: true }).where(and(
eq(schema.createddocuments.serialexecution, executionId),
eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.state, "Entwurf"),
eq(schema.createddocuments.archived, false),
));
}
async function isCancellationRequested(server: FastifyInstance, executionId: string, tenantId: number) {
const [record] = await server.db.select().from(schema.serialExecutions).where(and(
eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId),
));
return record?.status === "cancelling";
}
async function settleExecution(server: FastifyInstance, executionId: string, tenantId: number, status: string, summary?: string) {
return server.db.transaction(async (tx) => {
const [record] = await tx.select().from(schema.serialExecutions).where(and(
eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId),
)).for("update");
if (record.status === "cancelling") {
await archiveExecutionDrafts(tx, executionId, tenantId);
status = "cancelled";
summary = "Abgebrochen. Ungebuchte Entwürfe wurden archiviert; gebuchte Rechnungen bleiben erhalten.";
}
const [updated] = await tx.update(schema.serialExecutions).set({ status, ...(summary ? { summary } : {}) })
.where(and(eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId))).returning();
return updated;
});
}
export const executeManualGeneration = async ( export const executeManualGeneration = async (
server: FastifyInstance, server: FastifyInstance,
executionDate: string | Date, executionDate: string | Date,
@@ -66,7 +120,7 @@ export const executeManualGeneration = async (
.values({ .values({
tenant: tenantId, tenant: tenantId,
executionDate: executionDayjs.toDate(), executionDate: executionDayjs.toDate(),
status: "draft", status: "generating",
createdBy: executedBy, createdBy: executedBy,
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet` summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
}) })
@@ -74,50 +128,45 @@ export const executeManualGeneration = async (
console.log(executionRecord); console.log(executionRecord);
// 4. Loop durch die Templates try {
for (const template of templates) { // 4. Loop durch die Templates
try { for (const template of templates) {
const resultId = await processSingleTemplate( if (await isCancellationRequested(server, executionRecord.id, tenantId)) break;
server, try {
template, const resultId = await processSingleTemplate(
tenant, server,
executionDayjs, template,
folderId, tenant,
fileTypeId, executionDayjs,
executedBy, folderId,
executionRecord.id fileTypeId,
); executedBy,
results.push({ id: template.id, status: "success", newDocumentId: resultId }); executionRecord.id
} catch (e: any) { );
console.error(`Fehler bei Template ${template.id}:`, e); results.push({ id: template.id, status: "success", newDocumentId: resultId });
results.push({ id: template.id, status: "error", error: e.message }); } catch (e: any) {
console.error(`Fehler bei Template ${template.id}:`, e);
results.push({ id: template.id, status: "error", error: e.message });
}
} }
}
return { execution: executionRecord, results }; const execution = await settleExecution(server, executionRecord.id, tenantId, "draft");
return { execution, results };
} catch (error) {
await settleExecution(server, executionRecord.id, tenantId, "error", "Fehler beim Vorbereiten der Rechnungen.");
throw error;
}
} }
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => { export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
const [executionRecord] = await server.db.update(schema.serialExecutions)
.set({ status: "processing" }).where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
inArray(schema.serialExecutions.status, ["draft", "error"]),
)).returning();
if (!executionRecord) throw Object.assign(new Error("Durchlauf kann nicht fertiggestellt werden."), { statusCode: 409 });
try { try {
console.log(`Beende Ausführung ${executionId}...`);
// 1. Execution und Tenant laden
const [executionRecord] = await server.db
.select()
.from(schema.serialExecutions)// @ts-ignore
.where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
))
.limit(1);
if (!executionRecord) throw new Error("Execution nicht gefunden");
console.log(executionRecord);
console.log(tenantId)
// Tenant laden (für Settings etc.) // Tenant laden (für Settings etc.)
const [tenant] = await server.db const [tenant] = await server.db
.select() .select()
@@ -125,19 +174,13 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
.where(eq(schema.tenants.id, tenantId)) .where(eq(schema.tenants.id, tenantId))
.limit(1); .limit(1);
// 2. Status auf "processing" setzen (optional, damit UI feedback hat)
/*await server.db
.update(schema.serialExecutions)
.set({ status: "processing" })// @ts-ignore
.where(eq(schema.serialExecutions.id, executionId));*/
// 3. Alle erstellten Dokumente dieser Execution laden // 3. Alle erstellten Dokumente dieser Execution laden
const documents = await server.db const documents = await server.db
.select() .select()
.from(schema.createddocuments) .from(schema.createddocuments)
.where(and( .where(and(
eq(schema.createddocuments.serialexecution, executionId), eq(schema.createddocuments.serialexecution, executionId),
eq(schema.createddocuments.state, "Entwurf"),
eq(schema.createddocuments.tenant, tenantId), eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.archived, false), eq(schema.createddocuments.archived, false),
)); ));
@@ -160,6 +203,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
// 5. Loop durch Dokumente // 5. Loop durch Dokumente
for (const doc of documents) { for (const doc of documents) {
if (await isCancellationRequested(server, executionId, tenantId)) break;
try { try {
const [letterhead] = await Promise.all([ const [letterhead] = await Promise.all([
@@ -230,31 +274,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge
await server.db const execution = await settleExecution(server, executionId, tenantId, finalStatus,
.update(schema.serialExecutions) `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`);
.set({ return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
status: finalStatus,
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
})// @ts-ignore
.where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
));
return { success: true, processed: successCount, errors: errorCount };
} catch (error) { } catch (error) {
console.error("Critical Error in finishManualGeneration:", error); await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
// Execution auf Error setzen
// @ts-ignore
await server.db
.update(schema.serialExecutions)
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
//@ts-ignore
.where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
));
throw error; throw error;
} }
} }

View File

@@ -20,7 +20,7 @@ import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service"; import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, files} from "../../db/schema"; import {citys, files} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm"; import {and, eq, isNull, not} from "drizzle-orm";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service"; import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
@@ -301,6 +301,13 @@ export default async function functionRoutes(server: FastifyInstance) {
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id) return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
}) })
server.post('/functions/serial/cancel/:execution_id', {
schema: { params: { type: 'object', required: ['execution_id'], properties: { execution_id: { type: 'string', format: 'uuid' } } } },
}, async (req) => {
const { execution_id } = req.params as { execution_id: string }
return cancelManualGeneration(server, execution_id, req.user.tenant_id)
})
server.post('/functions/serial/finish/:execution_id', async (req, reply) => { server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
const {execution_id} = req.params as { execution_id: string } const {execution_id} = req.params as { execution_id: string }
//@ts-ignore //@ts-ignore

View File

@@ -0,0 +1,105 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { PgDialect } from 'drizzle-orm/pg-core'
import * as schema from '../db/schema'
import { cancelManualGeneration, finishManualGeneration } from '../src/modules/serialexecution.service'
const id = '00000000-0000-4000-8000-000000000001'
const dialect = new PgDialect()
function fixture(status: string | null, options: { cancelDuringFinish?: boolean } = {}) {
const execution = status ? { id, tenant: 7, status } : null
const calls: any[] = []
const db: any = {
transaction: async (fn: any) => fn(db),
select: () => query('select'),
update: (table: any) => query('update', table),
}
function query(kind: string, table?: any) {
let values: any
let condition: any
const builder: any = {
from: (value: any) => { table = value; return builder },
set: (value: any) => { values = value; return builder },
where: (value: any) => { condition = value; return builder },
limit: () => builder,
for: (lock: string) => { assert.equal(lock, 'update'); return builder },
returning: () => builder,
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
const sql = condition ? dialect.sqlToQuery(condition) : null
calls.push({ kind, table, values, sql })
if (table === schema.serialExecutions) {
if (!execution || !sql.params.includes(7)) return []
if (values?.status === 'processing' && !['draft', 'error'].includes(execution.status)) return []
if (values) Object.assign(execution, values)
return [{ ...execution }]
}
if (table === schema.createddocuments && kind === 'select' && options.cancelDuringFinish) {
execution!.status = 'cancelling'
return [{ id: 123, state: 'Entwurf' }]
}
if (table === schema.tenants) return [{ id: 7 }]
return []
}).then(resolve, reject),
}
return builder
}
return { server: { db } as any, execution, calls }
}
test('cancels drafts and archives only unarchived draft invoices of the same tenant and run', async () => {
const f = fixture('draft')
const result = await cancelManualGeneration(f.server, id, 7)
assert.equal(result.execution.status, 'cancelled')
const archive = f.calls.find(c => c.table === schema.createddocuments)
assert.deepEqual(archive.values, { archived: true })
assert.deepEqual(archive.sql.params, [id, 7, 'Entwurf', false])
for (const column of ['serialexecution', 'tenant', 'state', 'archived']) {
assert.ok(archive.sql.sql.includes(`"${column}"`))
}
})
for (const status of ['generating', 'processing']) {
test(`requests cooperative cancellation while ${status}`, async () => {
const f = fixture(status)
const result = await cancelManualGeneration(f.server, id, 7)
assert.equal(result.execution.status, 'cancelling')
assert.equal(f.calls.some(c => c.table === schema.createddocuments), false)
})
}
for (const status of ['cancelled', 'cancelling']) {
test(`repeated cancellation of ${status} is idempotent`, async () => {
const f = fixture(status)
await cancelManualGeneration(f.server, id, 7)
assert.equal(f.calls.some(c => c.kind === 'update'), false)
})
}
test('rejects completed runs and missing or foreign-tenant executions without writes', async () => {
for (const [status, tenant, code] of [['completed', 7, 409], [null, 7, 404], ['draft', 8, 404]] as const) {
const f = fixture(status)
await assert.rejects(cancelManualGeneration(f.server, id, tenant), { statusCode: code })
assert.equal(f.calls.some(c => c.kind === 'update'), false)
}
})
test('cannot finalize cancelled, cancelling, completed or already processing runs', async () => {
for (const status of ['cancelled', 'cancelling', 'completed', 'processing', 'generating']) {
const f = fixture(status)
await assert.rejects(finishManualGeneration(f.server, id, 7), { statusCode: 409 })
assert.equal(f.execution!.status, status)
assert.equal(f.calls.length, 1)
}
})
test('cancellation during finalization skips remaining invoices and settles as cancelled', async () => {
const f = fixture('draft', { cancelDuringFinish: true })
const result = await finishManualGeneration(f.server, id, 7)
assert.equal(result.execution.status, 'cancelled')
assert.equal(result.processed, 0)
assert.equal(result.success, false)
const writes = f.calls.filter(c => c.kind === 'update' && c.table === schema.createddocuments)
assert.equal(writes.length, 1)
assert.deepEqual(writes[0].values, { archived: true })
})

View File

@@ -28,6 +28,7 @@
color="white" color="white"
variant="solid" variant="solid"
label="Ausführen" label="Ausführen"
:loading="isExecuting"
@click="openExecutionModal" @click="openExecutionModal"
/> />
@@ -70,21 +71,33 @@
<div> <div>
<p class="text-sm font-medium">Start: {{ dayjs(exec.createdAt).format('DD.MM.YYYY HH:mm') }}</p> <p class="text-sm font-medium">Start: {{ dayjs(exec.createdAt).format('DD.MM.YYYY HH:mm') }}</p>
</div> </div>
<UBadge color="primary" variant="subtle">Läuft</UBadge> <UBadge :color="getStatusColor(exec.status)" variant="subtle">{{ getStatusLabel(exec.status) }}</UBadge>
</div> </div>
<div class="text-xs text-gray-500 flex justify-between mb-3"> <div class="text-xs text-gray-500 flex justify-between mb-3">
<span>{{exec.summary}}</span> <span>{{exec.summary}}</span>
</div> </div>
<div class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800"> <div class="flex justify-end gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
<UButton <UButton
size="xs"
color="error"
variant="soft"
icon="i-heroicons-stop"
label="Durchlauf abbrechen"
:loading="cancellingId === exec.id"
:disabled="exec.status === 'cancelling' || cancellingId !== null"
@click="cancelExecution(exec.id)"
/>
<UButton
v-if="['draft', 'error'].includes(exec.status)"
size="xs" size="xs"
color="white" color="white"
variant="solid" variant="solid"
icon="i-heroicons-check" icon="i-heroicons-check"
label="Fertigstellen" label="Fertigstellen"
:loading="finishingId === exec.id" :loading="finishingId === exec.id"
:disabled="finishingId !== null || cancellingId !== null"
@click="finishExecution(exec.id)" @click="finishExecution(exec.id)"
/> />
</div> </div>
@@ -343,6 +356,14 @@ const showExecutionsSlideover = ref(false)
const executionItems = ref([]) const executionItems = ref([])
const executionsLoading = ref(false) const executionsLoading = ref(false)
const finishingId = ref(null) const finishingId = ref(null)
const cancellingId = ref(null)
let executionsPoll
onMounted(() => {
executionsPoll = setInterval(() => {
if (!executionsLoading.value) fetchExecutions()
}, 3000)
})
onUnmounted(() => clearInterval(executionsPoll))
const setupPage = async () => { const setupPage = async () => {
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber), plant(id,name)","documentDate",undefined,true) items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber), plant(id,name)","documentDate",undefined,true)
@@ -364,11 +385,11 @@ const fetchExecutions = async () => {
} }
const runningExecutions = computed(() => { const runningExecutions = computed(() => {
return executionItems.value.filter(i => i.status === 'draft') return executionItems.value.filter(i => ['generating', 'draft', 'processing', 'cancelling', 'error'].includes(i.status))
}) })
const completedExecutions = computed(() => { const completedExecutions = computed(() => {
return executionItems.value.filter(i => i.status !== 'running' && i.status !== 'pending' && i.status !== 'draft') return executionItems.value.filter(i => ['completed', 'cancelled'].includes(i.status))
}) })
const openExecutionsSlideover = () => { const openExecutionsSlideover = () => {
@@ -376,16 +397,34 @@ const openExecutionsSlideover = () => {
fetchExecutions() fetchExecutions()
} }
const cancelExecution = async (executionId) => {
if (cancellingId.value || !window.confirm('Durchlauf abbrechen? Ungebuchte Rechnungsentwürfe werden archiviert. Bereits gebuchte Rechnungen bleiben erhalten.')) return
cancellingId.value = executionId
try {
const result = await $api(`/api/functions/serial/cancel/${executionId}`, { method: 'POST' })
toast.add({
title: result.execution.status === 'cancelling' ? 'Abbruch angefordert' : 'Durchlauf abgebrochen',
description: result.execution.status === 'cancelling' ? 'Die gerade bearbeitete Rechnung wird noch abgeschlossen.' : 'Ungebuchte Rechnungsentwürfe wurden archiviert.',
color: 'success'
})
await fetchExecutions()
} catch (error) {
toast.add({ title: 'Abbruch fehlgeschlagen', description: error.data?.message || 'Der Durchlauf konnte nicht abgebrochen werden.', color: 'error' })
} finally {
cancellingId.value = null
}
}
const finishExecution = async (executionId) => { const finishExecution = async (executionId) => {
if (!executionId) return if (!executionId) return
finishingId.value = executionId finishingId.value = executionId
try { try {
await $api(`/api/functions/serial/finish/${executionId}`, { method: 'POST' }) const result = await $api(`/api/functions/serial/finish/${executionId}`, { method: 'POST' })
toast.add({ toast.add({
title: 'Ausführung beendet', title: getStatusLabel(result.execution.status),
description: 'Der Prozess wurde erfolgreich als fertig markiert.', description: result.execution.summary,
icon: 'i-heroicons-check-circle', icon: 'i-heroicons-check-circle',
color: 'green' color: result.errors > 0 ? 'error' : 'success'
}) })
await fetchExecutions() await fetchExecutions()
} catch (error) { } catch (error) {
@@ -403,11 +442,13 @@ const finishExecution = async (executionId) => {
const getStatusColor = (status) => { const getStatusColor = (status) => {
switch (status) { switch (status) {
case 'completed': return 'green' case 'completed': return 'success'
case 'error': return 'red' case 'error': return 'error'
case 'generating':
case 'processing':
case 'running': case 'running':
case 'draft': return 'primary' case 'draft': return 'primary'
default: return 'gray' default: return 'neutral'
} }
} }
@@ -415,7 +456,11 @@ const getStatusLabel = (status) => {
switch (status) { switch (status) {
case 'completed': return 'Abgeschlossen' case 'completed': return 'Abgeschlossen'
case 'error': return 'Fehlerhaft' case 'error': return 'Fehlerhaft'
case 'draft': return 'Gestartet' case 'draft': return 'Vorbereitet'
case 'generating': return 'Wird vorbereitet'
case 'processing': return 'Wird fertiggestellt'
case 'cancelling': return 'Wird abgebrochen'
case 'cancelled': return 'Abgebrochen'
default: return status default: return status
} }
} }
@@ -645,14 +690,15 @@ const executeSerialInvoices = async () => {
templateIds: selectedExecutionRows.value.map(row => row.id) templateIds: selectedExecutionRows.value.map(row => row.id)
} }
showExecutionModal.value = false
const res = await $api('/api/functions/serial/start', { const res = await $api('/api/functions/serial/start', {
method: 'POST', method: 'POST',
body: payload body: payload
}) })
toast.add({ toast.add({
title: 'Ausführung gestartet', title: res.execution.status === 'cancelled' ? 'Durchlauf abgebrochen' : 'Ausführung gestartet',
description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`, description: res.execution.status === 'cancelled' ? res.execution.summary : `${res.results.filter(result => result.status === 'success').length} Rechnungen wurden für den Lauf vorbereitet.`,
icon: 'i-heroicons-check-circle', icon: 'i-heroicons-check-circle',
color: 'green' color: 'green'
}) })