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,
} from './edit-applier';
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 {
if (!s) return null;
@@ -45,6 +50,12 @@ export interface ActionResult {
skipped?: boolean;
message?: string;
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 {
@@ -56,6 +67,8 @@ interface PaperlessRunResult {
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
}
@Injectable()
@@ -65,6 +78,7 @@ export class InboxPostprocessorService {
constructor(
private readonly pageCache: PageCacheService,
private readonly paperlessService: PaperlessService,
private readonly dokumentVersion: DokumentVersionService,
private readonly mailService: MailService,
private readonly exportService: ExportService,
@InjectRepository(InboxPostprocessingAction)
@@ -82,7 +96,7 @@ export class InboxPostprocessorService {
preferredUsername: string | null,
sectionOffset: number = 0,
processOnlyOne: boolean = false,
replaceDuplicate: boolean = false,
entscheidung?: PaperlessEntscheidung,
): Promise<RunForDocumentResult> {
const doc = await this.docRepo.findOne({ where: { Id: documentId } });
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
@@ -252,16 +266,31 @@ export class InboxPostprocessorService {
: undefined;
try {
for (const action of tplActions) {
for (const action of aktionenAbEntscheidung(
tplActions,
entscheidung,
)) {
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 {
const res = await this.runPaperless(
action.Content ?? {},
sectionPdfPath,
variables,
defaultFilenameBase,
replaceDuplicate,
entscheidung,
);
results.push({
sectionIndex: currentSectionIndex,
@@ -271,6 +300,8 @@ export class InboxPostprocessorService {
skipped: res.skipped,
message: res.message,
duplicateOfDocumentId: res.duplicateOfDocumentId,
versionMoeglich: res.versionMoeglich,
duplikatKandidaten: res.duplikatKandidaten,
});
if (res.skipped) {
abortProcessing = true;
@@ -404,7 +435,7 @@ export class InboxPostprocessorService {
pdfPath: string,
variables: Record<string, string>,
defaultFilenameBase?: string,
replaceDuplicate: boolean = false,
entscheidung?: PaperlessEntscheidung,
): Promise<PaperlessRunResult> {
// 1. Interne Belegnummer auflösen (Pflicht)
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
@@ -416,7 +447,21 @@ export class InboxPostprocessorService {
if (!interneBelegnummer)
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 asn = asnTpl ? applyTemplate(asnTpl, variables).trim() : null;
let archiveSerialNumber: number | undefined;
@@ -429,12 +474,26 @@ export class InboxPostprocessorService {
if (!Number.isNaN(n)) archiveSerialNumber = n;
}
if (replaceDuplicate) {
this.logger.log(
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`,
// 4. Duplikat-Check Paperless: CF7 + aus der Belegnummer abgeleitete ASN
const pruefung =
await this.paperlessService.pruefeBelegnummer(interneBelegnummer);
if (pruefung.status !== 'frei') {
this.logger.warn(
`Duplikat (Paperless): Belegnummer ${interneBelegnummer} bereits vorhanden (Dokument(e) ${pruefung.kandidaten.join(', ')})`,
);
} else {
// 3. Duplikat-Check lokal (tasks-Tabelle)
return {
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({
InterneBelegnummer: interneBelegnummer,
});
@@ -446,47 +505,15 @@ export class InboxPostprocessorService {
skipped: true,
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
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
const buffer = await fs.readFile(pdfPath);
// Paperless ≥ 3.0 vergleicht SHA-256-Checksummen
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const checksumExists =
await this.paperlessService.checksumExists(checksum);
const checksumExists = await this.paperlessService.checksumExists(checksum);
if (checksumExists) {
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
return {
@@ -494,7 +521,6 @@ export class InboxPostprocessorService {
message: 'Duplikat (Checksum-Übereinstimmung)',
};
}
}
// 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 { InboxService } from './inbox.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 { UserSettingsService } from '../user-settings/user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator';
@@ -143,7 +144,7 @@ export class InboxController {
body: {
sectionOffset?: number;
processOnlyOne?: boolean;
replaceDuplicate?: boolean;
entscheidung?: PaperlessEntscheidung;
},
) {
const preferredUsername: string | null =
@@ -153,7 +154,7 @@ export class InboxController {
preferredUsername,
body?.sectionOffset ?? 0,
body?.processOnlyOne ?? false,
body?.replaceDuplicate ?? false,
body?.entscheidung,
);
return { results, totalSections };
}
+8 -1
View File
@@ -87,7 +87,7 @@ export const inboxApi = {
postprocess: (
id: string,
opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean },
opts?: { sectionOffset?: number; processOnlyOne?: boolean; entscheidung?: PaperlessEntscheidung },
) =>
api
.post<{ results: PostprocessActionResult[]; totalSections: number }>(
@@ -132,6 +132,9 @@ export const inboxApi = {
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 {
sectionIndex: number;
actionId: number;
@@ -140,6 +143,10 @@ export interface PostprocessActionResult {
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
/** Duplikat mit genau einem Stammdokument → neue Version möglich */
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
verworfen?: boolean;
}
export const clientsApi = {
@@ -22,7 +22,7 @@ import {
ZoomInOutlined,
ZoomOutOutlined,
} 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 { userSettingsApi, type SenderOption } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -259,7 +259,7 @@ function PostprocessWizardModal({
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;
setBusy(true);
setStepAt(idx, { status: 'processing', message: undefined });
@@ -267,7 +267,7 @@ function PostprocessWizardModal({
const { results } = await inboxApi.postprocess(documentId, {
sectionOffset: idx,
processOnlyOne: true,
replaceDuplicate,
entscheidung,
});
const skipped = results.find((r) => r.skipped);
const failed = results.filter((r) => !r.ok);
@@ -304,8 +304,10 @@ function PostprocessWizardModal({
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
const handleReplace = () => {
const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId;
if (documentId === undefined) return;
setAwaitingDecision(false);
void processSection(currentSection, true);
void processSection(currentSection, { aktion: 'version', documentId });
};
const handleSkip = () => {