dev #241

Merged
flfeders merged 9 commits from dev into main 2026-10-07 17:11:45 +00:00
6 changed files with 269 additions and 87 deletions
Showing only changes of commit fdcb9bfc1d - Show all commits

View File

@@ -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"

View File

@@ -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,
},
},

View File

@@ -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;
}
}

View File

@@ -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

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"
variant="solid"
label="Ausführen"
:loading="isExecuting"
@click="openExecutionModal"
/>
@@ -70,21 +71,33 @@
<div>
<p class="text-sm font-medium">Start: {{ dayjs(exec.createdAt).format('DD.MM.YYYY HH:mm') }}</p>
</div>
<UBadge color="primary" variant="subtle">Läuft</UBadge>
<UBadge :color="getStatusColor(exec.status)" variant="subtle">{{ getStatusLabel(exec.status) }}</UBadge>
</div>
<div class="text-xs text-gray-500 flex justify-between mb-3">
<span>{{exec.summary}}</span>
</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
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"
color="white"
variant="solid"
icon="i-heroicons-check"
label="Fertigstellen"
:loading="finishingId === exec.id"
:disabled="finishingId !== null || cancellingId !== null"
@click="finishExecution(exec.id)"
/>
</div>
@@ -343,6 +356,14 @@ const showExecutionsSlideover = ref(false)
const executionItems = ref([])
const executionsLoading = ref(false)
const finishingId = ref(null)
const cancellingId = ref(null)
let executionsPoll
onMounted(() => {
executionsPoll = setInterval(() => {
if (!executionsLoading.value) fetchExecutions()
}, 3000)
})
onUnmounted(() => clearInterval(executionsPoll))
const setupPage = async () => {
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(() => {
return executionItems.value.filter(i => i.status === 'draft')
return executionItems.value.filter(i => ['generating', 'draft', 'processing', 'cancelling', 'error'].includes(i.status))
})
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 = () => {
@@ -376,16 +397,34 @@ const openExecutionsSlideover = () => {
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) => {
if (!executionId) return
finishingId.value = executionId
try {
await $api(`/api/functions/serial/finish/${executionId}`, { method: 'POST' })
const result = await $api(`/api/functions/serial/finish/${executionId}`, { method: 'POST' })
toast.add({
title: 'Ausführung beendet',
description: 'Der Prozess wurde erfolgreich als fertig markiert.',
title: getStatusLabel(result.execution.status),
description: result.execution.summary,
icon: 'i-heroicons-check-circle',
color: 'green'
color: result.errors > 0 ? 'error' : 'success'
})
await fetchExecutions()
} catch (error) {
@@ -403,11 +442,13 @@ const finishExecution = async (executionId) => {
const getStatusColor = (status) => {
switch (status) {
case 'completed': return 'green'
case 'error': return 'red'
case 'completed': return 'success'
case 'error': return 'error'
case 'generating':
case 'processing':
case 'running':
case 'draft': return 'primary'
default: return 'gray'
default: return 'neutral'
}
}
@@ -415,7 +456,11 @@ const getStatusLabel = (status) => {
switch (status) {
case 'completed': return 'Abgeschlossen'
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
}
}
@@ -645,14 +690,15 @@ const executeSerialInvoices = async () => {
templateIds: selectedExecutionRows.value.map(row => row.id)
}
showExecutionModal.value = false
const res = await $api('/api/functions/serial/start', {
method: 'POST',
body: payload
})
toast.add({
title: 'Ausführung gestartet',
description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`,
title: res.execution.status === 'cancelled' ? 'Durchlauf abgebrochen' : 'Ausführung gestartet',
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',
color: 'green'
})