feat(quick-260909-mir): dkv-Historie und Fahrzeugstammdaten binden, Download-Besitzriegel schliessen
This commit is contained in:
@@ -433,6 +433,11 @@ export class DkvService {
|
||||
recipient: string | undefined,
|
||||
vehicleFormatString: string,
|
||||
): Promise<void> {
|
||||
// Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide
|
||||
// dkvInvoiceHistory.create()-Aufrufe dieser Methode (Erfolgsfall UND
|
||||
// Zerlegungsfehler-Fall).
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
|
||||
// D-10: Up to 3 parse retries
|
||||
let parseResult: Awaited<ReturnType<typeof this.parser.parsePdf>> | null = null;
|
||||
let parseError: string | null = null;
|
||||
@@ -452,7 +457,7 @@ export class DkvService {
|
||||
|
||||
if (!parseResult) {
|
||||
// Record parse failure in history (D-10)
|
||||
await this.prisma.dkvInvoiceHistory.create({
|
||||
await tenantPrisma.dkvInvoiceHistory.create({
|
||||
data: {
|
||||
tenantId,
|
||||
rechnungsnummer: this._buildRechnungsnummer(null, email.subject, email.uid),
|
||||
@@ -509,7 +514,7 @@ export class DkvService {
|
||||
}
|
||||
|
||||
// Record history row (D-20)
|
||||
await this.prisma.dkvInvoiceHistory.create({
|
||||
await tenantPrisma.dkvInvoiceHistory.create({
|
||||
data: {
|
||||
tenantId,
|
||||
rechnungsnummer,
|
||||
@@ -529,32 +534,47 @@ export class DkvService {
|
||||
// ─── Vehicle CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
async listVehicles(tenantId: string) {
|
||||
return this.prisma.dkvVehicleMaster.findMany({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return tenantPrisma.dkvVehicleMaster.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { kennzeichen: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createVehicle(tenantId: string, dto: CreateVehicleDto) {
|
||||
return this.prisma.dkvVehicleMaster.create({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return tenantPrisma.dkvVehicleMaster.create({
|
||||
data: { tenantId, ...dto },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandantengebunden (260909-mir, Befund G): EIN gebundener Klient fuer
|
||||
* BEIDE Anweisungen — die Besitzpruefung UND den Schreibzugriff. Die
|
||||
* Mandantenbedingung in der Besitzpruefung (`findFirst({ id, tenantId })`)
|
||||
* bleibt zusaetzlich erhalten und wird nicht durch die Bindung ersetzt:
|
||||
* Aufgabe 1 hat gemessen, dass ein gebundenes UPDATE ueber die Kennung
|
||||
* allein auf eine fremde Zeile still 0 Zeilen trifft statt laut zu
|
||||
* scheitern — eine gebundene Vorpruefung mit einem ungebundenen
|
||||
* Schreibzugriff dahinter waere genau die Luecke, nicht die Loesung.
|
||||
*/
|
||||
async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) {
|
||||
const existing = await this.prisma.dkvVehicleMaster.findFirst({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Vehicle not found');
|
||||
return this.prisma.dkvVehicleMaster.update({ where: { id }, data: dto });
|
||||
return tenantPrisma.dkvVehicleMaster.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
/** Mandantengebunden (260909-mir, Befund G) — siehe updateVehicle() oben. */
|
||||
async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> {
|
||||
const existing = await this.prisma.dkvVehicleMaster.findFirst({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Vehicle not found');
|
||||
await this.prisma.dkvVehicleMaster.delete({ where: { id } });
|
||||
await tenantPrisma.dkvVehicleMaster.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -567,12 +587,19 @@ export class DkvService {
|
||||
* mode='replace': delete all existing vehicles for this tenant, then insert.
|
||||
*
|
||||
* Research pattern: CSV Vehicle Import Pattern (RESEARCH.md Code Examples).
|
||||
*
|
||||
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer JEDE
|
||||
* Anweisung in beiden Modi. Keine Kollisionsbehandlung noetig im
|
||||
* Zusammenfuehren-Modus — `@@unique([tenantId, kennzeichen])` traegt den
|
||||
* Mandanten als Teil des Schluessels (Befund H, in Aufgabe 1 gemessen:
|
||||
* `dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision`).
|
||||
*/
|
||||
async importVehiclesCsv(
|
||||
tenantId: string,
|
||||
csvText: string,
|
||||
mode: 'merge' | 'replace',
|
||||
): Promise<{ imported: number; mode: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const vehicles = _parseVehicleCsv(csvText);
|
||||
if (vehicles.length === 0) {
|
||||
throw new BadRequestException(
|
||||
@@ -581,14 +608,14 @@ export class DkvService {
|
||||
}
|
||||
|
||||
if (mode === 'replace') {
|
||||
await this.prisma.dkvVehicleMaster.deleteMany({ where: { tenantId } });
|
||||
await this.prisma.dkvVehicleMaster.createMany({
|
||||
await tenantPrisma.dkvVehicleMaster.deleteMany({ where: { tenantId } });
|
||||
await tenantPrisma.dkvVehicleMaster.createMany({
|
||||
data: vehicles.map((v) => ({ tenantId, ...v })),
|
||||
});
|
||||
} else {
|
||||
// Merge: upsert by (tenantId, kennzeichen) compound unique key
|
||||
for (const v of vehicles) {
|
||||
await this.prisma.dkvVehicleMaster.upsert({
|
||||
await tenantPrisma.dkvVehicleMaster.upsert({
|
||||
where: { tenantId_kennzeichen: { tenantId, kennzeichen: v.kennzeichen } },
|
||||
create: { tenantId, ...v },
|
||||
update: { marke: v.marke, modell: v.modell, fahrer: v.fahrer },
|
||||
@@ -606,6 +633,11 @@ export class DkvService {
|
||||
* Get paginated processing history for a tenant.
|
||||
* Ordered by datumZeit descending (most recent first).
|
||||
* T-07-06: pagination prevents unbounded result-set DoS.
|
||||
*
|
||||
* Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide
|
||||
* Abfragen, die ueber `Promise.all` parallel laufen — das ist die
|
||||
* Nebenlaeufigkeitsform, auf die sich dieser Bereich stuetzt (Befund C,
|
||||
* in Aufgabe 1 gemessen: `dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext`).
|
||||
*/
|
||||
async getHistory(
|
||||
tenantId: string,
|
||||
@@ -617,15 +649,16 @@ export class DkvService {
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.dkvInvoiceHistory.findMany({
|
||||
tenantPrisma.dkvInvoiceHistory.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { datumZeit: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.dkvInvoiceHistory.count({ where: { tenantId } }),
|
||||
tenantPrisma.dkvInvoiceHistory.count({ where: { tenantId } }),
|
||||
]);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
@@ -639,11 +672,25 @@ export class DkvService {
|
||||
* pattern `DKV_*.xlsx` before reading. Rejects any filename containing path
|
||||
* separators, `..`, or characters outside the expected character set.
|
||||
*
|
||||
* @throws BadRequestException when filename fails validation
|
||||
* @throws NotFoundException when the file does not exist
|
||||
* OWNERSHIP GATE (260909-mir, Befund E/T-MIR-02 — closes the ldap-class
|
||||
* gap of this area): `user-files/` is a directory SHARED by all tenants
|
||||
* (Befund F/T-MIR-08), so the traversal-safe filename pattern alone never
|
||||
* proved this tenant owns the file — any tenant admin could download
|
||||
* another tenant's export given (or guessed at) the filename. A bound
|
||||
* read against `DkvInvoiceHistory.exportFilename` now decides ownership.
|
||||
* This DELIBERATELY changes behavior: a file that sits on disk but names
|
||||
* no history row for this tenant is no longer downloadable — that is the
|
||||
* intent, not a bug. Absence (no DB row) and foreign ownership (a DB row
|
||||
* under a different tenant) collapse to the SAME NotFoundException so the
|
||||
* response reveals nothing about whether a foreign tenant's file exists.
|
||||
*
|
||||
* @throws BadRequestException when filename fails the pattern check
|
||||
* @throws NotFoundException when no history row of THIS tenant names this
|
||||
* file, or when the file is missing from disk despite an owning row
|
||||
*/
|
||||
async getExportFile(tenantId: string, filename: string): Promise<Buffer> {
|
||||
// Traversal guard: whitelist-validate the filename before reading
|
||||
// Stage 1 (unchanged, T-07-09): traversal guard, whitelist-validate the
|
||||
// filename before doing anything else with it.
|
||||
if (
|
||||
filename.includes('/') ||
|
||||
filename.includes('\\') ||
|
||||
@@ -653,6 +700,16 @@ export class DkvService {
|
||||
throw new BadRequestException('Invalid export filename');
|
||||
}
|
||||
|
||||
// Stage 2 (NEW, 260909-mir): the ownership gate. A bound read — the
|
||||
// only tenant-scoped statement of who this file belongs to.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const owningHistoryRow = await tenantPrisma.dkvInvoiceHistory.findFirst({
|
||||
where: { tenantId, exportFilename: filename },
|
||||
});
|
||||
if (!owningHistoryRow) {
|
||||
throw new NotFoundException(`Export file not found: ${filename}`);
|
||||
}
|
||||
|
||||
const filePath = path.join(this.userFilesDir, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -670,6 +727,11 @@ export class DkvService {
|
||||
* Vehicles with no matching DkvVehicleMaster entry still appear in the export
|
||||
* with an empty Fahrer field (D-13). The Fahrzeug column is resolved via the
|
||||
* format string with empty Marke/Modell/Fahrer placeholders for unknown plates.
|
||||
*
|
||||
* Mandantengebunden (260909-mir): der gebuendelte Lesezugriff auf die
|
||||
* Fahrzeugstammdaten laeuft ueber `forTenant()` — ungebunden wuerde
|
||||
* Befund K, Stelle 7 zuschlagen: eine vollstaendige Ausfuhrdatei OHNE
|
||||
* einen einzigen Fahrer, ohne Fehler, ohne Warnung.
|
||||
*/
|
||||
private async _buildExportRows(
|
||||
tenantId: string,
|
||||
@@ -677,7 +739,8 @@ export class DkvService {
|
||||
vehicleFormatString: string,
|
||||
): Promise<{ lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[]> {
|
||||
// Batch load vehicle master to avoid N+1 queries
|
||||
const masters = await this.prisma.dkvVehicleMaster.findMany({ where: { tenantId } });
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const masters: any[] = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } });
|
||||
// Normalize keys: DKV PDF may omit hyphens or use spaces ("GP JL 740E" vs "GP-JL 740E")
|
||||
const masterMap = new Map(masters.map((m) => [_normalizeKennzeichen(m.kennzeichen), m]));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user