diff --git a/backend/db/schema/serialexecutions.ts b/backend/db/schema/serialexecutions.ts index c63cfb22..08bed4c9 100644 --- a/backend/db/schema/serialexecutions.ts +++ b/backend/db/schema/serialexecutions.ts @@ -14,7 +14,7 @@ export const serialExecutions = pgTable("serial_executions", { tenant: bigint("tenant", { mode: "number" }) .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 createdAt: timestamp("created_at").defaultNow(), summary: text("summary"), // z.B. "25 Rechnungen erstellt" diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 06521ead..76acc705 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -802,7 +802,7 @@ export const accountingTools: McpTool[] = [ inputSchema: { type: "object", properties: { - status: { type: "string", enum: ["draft", "completed", "error"] }, + status: { type: "string", enum: ["generating", "draft", "processing", "cancelling", "cancelled", "completed", "error"] }, ...paginationProperties, }, }, diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index 7a977157..18dae834 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -14,6 +14,60 @@ import { documentTemplateHandlebars } from "../utils/handlebars"; 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 ( server: FastifyInstance, executionDate: string | Date, @@ -66,7 +120,7 @@ export const executeManualGeneration = async ( .values({ tenant: tenantId, executionDate: executionDayjs.toDate(), - status: "draft", + status: "generating", createdBy: executedBy, summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet` }) @@ -74,50 +128,45 @@ export const executeManualGeneration = async ( console.log(executionRecord); - // 4. Loop durch die Templates - for (const template of templates) { - try { - const resultId = await processSingleTemplate( - server, - template, - tenant, - executionDayjs, - folderId, - fileTypeId, - executedBy, - executionRecord.id - ); - results.push({ id: template.id, status: "success", newDocumentId: resultId }); - } catch (e: any) { - console.error(`Fehler bei Template ${template.id}:`, e); - results.push({ id: template.id, status: "error", error: e.message }); + try { + // 4. Loop durch die Templates + for (const template of templates) { + if (await isCancellationRequested(server, executionRecord.id, tenantId)) break; + try { + const resultId = await processSingleTemplate( + server, + template, + tenant, + executionDayjs, + folderId, + fileTypeId, + executedBy, + executionRecord.id + ); + results.push({ id: template.id, status: "success", newDocumentId: resultId }); + } 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) => { + 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 { - 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.) const [tenant] = await server.db .select() @@ -125,19 +174,13 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI .where(eq(schema.tenants.id, tenantId)) .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 const documents = await server.db .select() .from(schema.createddocuments) .where(and( eq(schema.createddocuments.serialexecution, executionId), + eq(schema.createddocuments.state, "Entwurf"), eq(schema.createddocuments.tenant, tenantId), eq(schema.createddocuments.archived, false), )); @@ -160,6 +203,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI // 5. Loop durch Dokumente for (const doc of documents) { + if (await isCancellationRequested(server, executionId, tenantId)) break; try { 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 - await server.db - .update(schema.serialExecutions) - .set({ - 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 }; - + const execution = await settleExecution(server, executionId, tenantId, finalStatus, + `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`); + return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount }; } catch (error) { - console.error("Critical Error in finishManualGeneration:", error); - // 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), - )); + await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren."); throw error; } } diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 49e38bb9..1944ee20 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -20,7 +20,7 @@ import timezone from "dayjs/plugin/timezone.js"; import {generateTimesEvaluation} from "../modules/time/evaluation.service"; import {citys, files} from "../../db/schema"; 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 { s3 } from "../utils/s3"; 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) }) + 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) => { const {execution_id} = req.params as { execution_id: string } //@ts-ignore diff --git a/backend/tests/serialExecutionCancellation.test.ts b/backend/tests/serialExecutionCancellation.test.ts new file mode 100644 index 00000000..793fa1d4 --- /dev/null +++ b/backend/tests/serialExecutionCancellation.test.ts @@ -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 }) +}) diff --git a/frontend/pages/createDocument/serialInvoice.vue b/frontend/pages/createDocument/serialInvoice.vue index 5154fc1b..07da4bd7 100644 --- a/frontend/pages/createDocument/serialInvoice.vue +++ b/frontend/pages/createDocument/serialInvoice.vue @@ -28,6 +28,7 @@ color="white" variant="solid" label="Ausführen" + :loading="isExecuting" @click="openExecutionModal" /> @@ -70,21 +71,33 @@
Start: {{ dayjs(exec.createdAt).format('DD.MM.YYYY HH:mm') }}