feat: Duplikate im Import als neue Version behandeln #6

Merged
bjoernpoettker merged 12 commits from feat/duplikat-neue-version into main 2026-11-02 13:31:04 +00:00
6 changed files with 145 additions and 53 deletions
Showing only changes of commit bdc85f5f1f - Show all commits

No files matched your search

@@ -18,6 +18,11 @@ import {
extractSectionToTemp, extractSectionToTemp,
} from './edit-applier'; } from './edit-applier';
import { applyTemplate, buildVariables } from './variable-resolver'; import { applyTemplate, buildVariables } from './variable-resolver';
import { DokumentVersionService } from '../paperless/dokument-version.service';
import {
aktionenAbEntscheidung,
type PaperlessEntscheidung,
} from './paperless-entscheidung.util';
function parseFlexDate(s: string): Date | null { function parseFlexDate(s: string): Date | null {
if (!s) return null; if (!s) return null;
@@ -45,6 +50,12 @@ export interface ActionResult {
skipped?: boolean; skipped?: boolean;
message?: string; message?: string;
duplicateOfDocumentId?: number; duplicateOfDocumentId?: number;
/** Duplikat mit genau einem Stammdokument → neue Version möglich */
versionMoeglich?: boolean;
/** Alle gefundenen Stammdokumente (bei mehrdeutigen Treffern) */
duplikatKandidaten?: number[];
/** Der Benutzer hat das Duplikat verworfen */
verworfen?: boolean;
} }
export interface RunForDocumentResult { export interface RunForDocumentResult {
@@ -56,6 +67,8 @@ interface PaperlessRunResult {
skipped?: boolean; skipped?: boolean;
message?: string; message?: string;
duplicateOfDocumentId?: number; duplicateOfDocumentId?: number;
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
} }
@Injectable() @Injectable()
@@ -65,6 +78,7 @@ export class InboxPostprocessorService {
constructor( constructor(
private readonly pageCache: PageCacheService, private readonly pageCache: PageCacheService,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
private readonly dokumentVersion: DokumentVersionService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly exportService: ExportService, private readonly exportService: ExportService,
@InjectRepository(InboxPostprocessingAction) @InjectRepository(InboxPostprocessingAction)
@@ -82,7 +96,7 @@ export class InboxPostprocessorService {
preferredUsername: string | null, preferredUsername: string | null,
sectionOffset: number = 0, sectionOffset: number = 0,
processOnlyOne: boolean = false, processOnlyOne: boolean = false,
replaceDuplicate: boolean = false, entscheidung?: PaperlessEntscheidung,
): Promise<RunForDocumentResult> { ): Promise<RunForDocumentResult> {
const doc = await this.docRepo.findOne({ where: { Id: documentId } }); const doc = await this.docRepo.findOne({ where: { Id: documentId } });
if (!doc) throw new NotFoundException('Dokument nicht gefunden'); if (!doc) throw new NotFoundException('Dokument nicht gefunden');
@@ -252,16 +266,31 @@ export class InboxPostprocessorService {
: undefined; : undefined;
try { try {
for (const action of tplActions) { for (const action of aktionenAbEntscheidung(
tplActions,
entscheidung,
)) {
if (abortProcessing) break; if (abortProcessing) break;
if (action.ActionType === 'PAPERLESS') { if (
action.ActionType === 'PAPERLESS' &&
entscheidung?.aktion === 'verwerfen'
) {
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: true,
verworfen: true,
message: 'Verworfen',
});
} else if (action.ActionType === 'PAPERLESS') {
try { try {
const res = await this.runPaperless( const res = await this.runPaperless(
action.Content ?? {}, action.Content ?? {},
sectionPdfPath, sectionPdfPath,
variables, variables,
defaultFilenameBase, defaultFilenameBase,
replaceDuplicate, entscheidung,
); );
results.push({ results.push({
sectionIndex: currentSectionIndex, sectionIndex: currentSectionIndex,
@@ -271,6 +300,8 @@ export class InboxPostprocessorService {
skipped: res.skipped, skipped: res.skipped,
message: res.message, message: res.message,
duplicateOfDocumentId: res.duplicateOfDocumentId, duplicateOfDocumentId: res.duplicateOfDocumentId,
versionMoeglich: res.versionMoeglich,
duplikatKandidaten: res.duplikatKandidaten,
}); });
if (res.skipped) { if (res.skipped) {
abortProcessing = true; abortProcessing = true;
@@ -404,7 +435,7 @@ export class InboxPostprocessorService {
pdfPath: string, pdfPath: string,
variables: Record<string, string>, variables: Record<string, string>,
defaultFilenameBase?: string, defaultFilenameBase?: string,
replaceDuplicate: boolean = false, entscheidung?: PaperlessEntscheidung,
): Promise<PaperlessRunResult> { ): Promise<PaperlessRunResult> {
// 1. Interne Belegnummer auflösen (Pflicht) // 1. Interne Belegnummer auflösen (Pflicht)
const intNrTpl = String(content.interneBelegnummer ?? '').trim(); const intNrTpl = String(content.interneBelegnummer ?? '').trim();
@@ -416,7 +447,21 @@ export class InboxPostprocessorService {
if (!interneBelegnummer) if (!interneBelegnummer)
throw new Error('Interne Belegnummer konnte nicht aufgelöst werden'); throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
// 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann) // 2. Vom Benutzer bestätigt: als neue Version an das Stammdokument hängen
if (entscheidung?.aktion === 'version') {
await this.dokumentVersion.haengeVersionAn(
entscheidung.documentId,
pdfPath,
);
this.logger.log(
`Belegnummer ${interneBelegnummer} als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
);
return {
message: `Als neue Version zu Dokument ${entscheidung.documentId} hinzugefügt`,
};
}
// 3. ASN bestimmen (für den Upload)
const asnTpl = String(content.asn ?? '').trim(); const asnTpl = String(content.asn ?? '').trim();
const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null; const asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
let archiveSerialNumber: number | undefined; let archiveSerialNumber: number | undefined;
@@ -429,12 +474,26 @@ export class InboxPostprocessorService {
if (!Number.isNaN(n)) archiveSerialNumber = n; if (!Number.isNaN(n)) archiveSerialNumber = n;
} }
if (replaceDuplicate) { // 4. Duplikat-Check Paperless: CF7 + aus der Belegnummer abgeleitete ASN
this.logger.log( const pruefung =
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`, await this.paperlessService.pruefeBelegnummer(interneBelegnummer);
if (pruefung.status !== 'frei') {
this.logger.warn(
`Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`,
); );
} else { return {
// 3. Duplikat-Check lokal (tasks-Tabelle) skipped: true,
message:
pruefung.status === 'eindeutig'
? `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`
: `Duplikat – Belegnummer ${interneBelegnummer} mehrfach in Paperless`,
duplicateOfDocumentId: pruefung.documentId ?? pruefung.kandidaten[0],
versionMoeglich: pruefung.status === 'eindeutig',
duplikatKandidaten: pruefung.kandidaten,
};
}
// 5. Duplikat-Check lokal (tasks-Tabelle)
const existingTask = await this.taskRepo.findOneBy({ const existingTask = await this.taskRepo.findOneBy({
InterneBelegnummer: interneBelegnummer, InterneBelegnummer: interneBelegnummer,
}); });
@@ -446,47 +505,15 @@ export class InboxPostprocessorService {
skipped: true, skipped: true,
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`, message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined, duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
versionMoeglich: false,
}; };
} }
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(
7,
interneBelegnummer,
);
if (cf7DocId !== null) {
this.logger.warn(
`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`,
);
return {
skipped: true,
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`,
duplicateOfDocumentId: cf7DocId,
};
}
// 5. Duplikat-Check Paperless API (archive_serial_number)
if (archiveSerialNumber !== undefined) {
const asnDocId =
await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
if (asnDocId !== null) {
this.logger.warn(
`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`,
);
return {
skipped: true,
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`,
duplicateOfDocumentId: asnDocId,
};
}
}
// 6. Checksum berechnen und prüfen // 6. Checksum berechnen und prüfen
const buffer = await fs.readFile(pdfPath); const buffer = await fs.readFile(pdfPath);
// Paperless ≥ 3.0 vergleicht SHA-256-Checksummen // Paperless ≥ 3.0 vergleicht SHA-256-Checksummen
const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const checksumExists = const checksumExists = await this.paperlessService.checksumExists(checksum);
await this.paperlessService.checksumExists(checksum);
if (checksumExists) { if (checksumExists) {
this.logger.warn(`Duplikat (Checksum): ${checksum}`); this.logger.warn(`Duplikat (Checksum): ${checksum}`);
return { return {
@@ -494,7 +521,6 @@ export class InboxPostprocessorService {
message: 'Duplikat (Checksum-Übereinstimmung)', message: 'Duplikat (Checksum-Übereinstimmung)',
}; };
} }
}
// 7. Restliche Metadaten auflösen // 7. Restliche Metadaten auflösen
@@ -0,0 +1,38 @@
import { aktionenAbEntscheidung } from './paperless-entscheidung.util';
const MAIL = { ActionType: 'MAIL' };
const PAPERLESS = { ActionType: 'PAPERLESS' };
const EXPORT = { ActionType: 'EXPORT' };
describe('aktionenAbEntscheidung', () => {
it('ohne Entscheidung laufen alle Aktionen', () => {
expect(aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT])).toEqual([
MAIL,
PAPERLESS,
EXPORT,
]);
});
it('nach „Verwerfen“ laufen Aktionen vor PAPERLESS nicht doppelt, die danach schon', () => {
expect(
aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT], {
aktion: 'verwerfen',
}),
).toEqual([PAPERLESS, EXPORT]);
});
it('nach „Version“ geht es ab der PAPERLESS-Aktion weiter', () => {
expect(
aktionenAbEntscheidung([MAIL, PAPERLESS, EXPORT], {
aktion: 'version',
documentId: 1,
}),
).toEqual([PAPERLESS, EXPORT]);
});
it('ohne PAPERLESS-Aktion laufen trotz Entscheidung alle', () => {
expect(
aktionenAbEntscheidung([MAIL, EXPORT], { aktion: 'verwerfen' }),
).toEqual([MAIL, EXPORT]);
});
});
@@ -0,0 +1,18 @@
/** Entscheidung des Benutzers zu einem Duplikat bei der Aktion „An Paperless senden“. */
export type PaperlessEntscheidung =
| { aktion: 'version'; documentId: number }
| { aktion: 'verwerfen' };
/**
* Aktionen eines Abschnitts, die nach einer Duplikat-Entscheidung noch laufen:
* alles ab der ersten PAPERLESS-Aktion – die davor liefen schon beim ersten
* Durchlauf. Ohne Entscheidung oder ohne PAPERLESS-Aktion laufen alle.
*/
export function aktionenAbEntscheidung<T extends { ActionType: string }>(
aktionen: T[],
entscheidung?: PaperlessEntscheidung,
): T[] {
if (!entscheidung) return aktionen;
const start = aktionen.findIndex((a) => a.ActionType === 'PAPERLESS');
return start === -1 ? aktionen : aktionen.slice(start);
}
@@ -17,6 +17,7 @@ import type { Response } from 'express';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { InboxService } from './inbox.service'; import { InboxService } from './inbox.service';
import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service'; import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service';
import type { PaperlessEntscheidung } from '../inbox-postprocessor/paperless-entscheidung.util';
import { BarcodeScannerService } from '../barcode/barcode-scanner.service'; import { BarcodeScannerService } from '../barcode/barcode-scanner.service';
import { UserSettingsService } from '../user-settings/user-settings.service'; import { UserSettingsService } from '../user-settings/user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
@@ -143,7 +144,7 @@ export class InboxController {
body: { body: {
sectionOffset?: number; sectionOffset?: number;
processOnlyOne?: boolean; processOnlyOne?: boolean;
replaceDuplicate?: boolean; entscheidung?: PaperlessEntscheidung;
}, },
) { ) {
const preferredUsername: string | null = const preferredUsername: string | null =
@@ -153,7 +154,7 @@ export class InboxController {
preferredUsername, preferredUsername,
body?.sectionOffset ?? 0, body?.sectionOffset ?? 0,
body?.processOnlyOne ?? false, body?.processOnlyOne ?? false,
body?.replaceDuplicate ?? false, body?.entscheidung,
); );
return { results, totalSections }; return { results, totalSections };
} }
+8 -1
View File
@@ -87,7 +87,7 @@ export const inboxApi = {
postprocess: ( postprocess: (
id: string, id: string,
opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean }, opts?: { sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung },
) => ) =>
api api
.post<{ results: PostprocessActionResult[]; totalSections: number }>( .post<{ results: PostprocessActionResult[]; totalSections: number }>(
@@ -132,6 +132,9 @@ export const inboxApi = {
api.post(`/api/inbox/${encodeURIComponent(id)}/send-email`, body).then(() => {}), api.post(`/api/inbox/${encodeURIComponent(id)}/send-email`, body).then(() => {}),
}; };
/** Entscheidung des Benutzers zu einem Duplikat bei „An Paperless senden“ */
export type PaperlessEntscheidung = { aktion: 'version'; documentId: number } | { aktion: 'verwerfen' };
export interface PostprocessActionResult { export interface PostprocessActionResult {
sectionIndex: number; sectionIndex: number;
actionId: number; actionId: number;
@@ -140,6 +143,10 @@ export interface PostprocessActionResult {
skipped?: boolean; skipped?: boolean;
message?: string; message?: string;
duplicateOfDocumentId?: number; duplicateOfDocumentId?: number;
/** Duplikat mit genau einem Stammdokument → neue Version möglich */
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
verworfen?: boolean;
} }
export const clientsApi = { export const clientsApi = {
@@ -22,7 +22,7 @@ import {
ZoomInOutlined, ZoomInOutlined,
ZoomOutOutlined, ZoomOutOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox'; import { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox';
import { paperlessApi } from '../api/paperless'; import { paperlessApi } from '../api/paperless';
import { userSettingsApi, type SenderOption } from '../api/userSettings'; import { userSettingsApi, type SenderOption } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
@@ -259,7 +259,7 @@ function PostprocessWizardModal({
setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s))); setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s)));
}; };
const processSection = async (idx: number, replaceDuplicate = false) => { const processSection = async (idx: number, entscheidung?: PaperlessEntscheidung) => {
if (!documentId) return; if (!documentId) return;
setBusy(true); setBusy(true);
setStepAt(idx, { status: 'processing', message: undefined }); setStepAt(idx, { status: 'processing', message: undefined });
@@ -267,7 +267,7 @@ function PostprocessWizardModal({
const { results } = await inboxApi.postprocess(documentId, { const { results } = await inboxApi.postprocess(documentId, {
sectionOffset: idx, sectionOffset: idx,
processOnlyOne: true, processOnlyOne: true,
replaceDuplicate, entscheidung,
}); });
const skipped = results.find((r) => r.skipped); const skipped = results.find((r) => r.skipped);
const failed = results.filter((r) => !r.ok); const failed = results.filter((r) => !r.ok);
@@ -304,8 +304,10 @@ function PostprocessWizardModal({
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]); }, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
const handleReplace = () => { const handleReplace = () => {
const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId;
if (documentId === undefined) return;
setAwaitingDecision(false); setAwaitingDecision(false);
void processSection(currentSection, true); void processSection(currentSection, { aktion: 'version', documentId });
}; };
const handleSkip = () => { const handleSkip = () => {