feat(posteingang): Duplikat als neue Version oder verwerfen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
170eb72c70
commit
bdc85f5f1f
6 files changed
+145
-53
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 };
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in new issue
Block a user