diff --git a/backend/prisma/migrations/20260814100000_address_malo_ids/migration.sql b/backend/prisma/migrations/20260814100000_address_malo_ids/migration.sql new file mode 100644 index 00000000..a0577db0 --- /dev/null +++ b/backend/prisma/migrations/20260814100000_address_malo_ids/migration.sql @@ -0,0 +1,23 @@ +-- MaLo-IDs (Marktlokation) je Sparte an die Adresse: die MaLo gehört zur +-- (Liefer-)Adresse, nicht zum Vertrag. Strom + Gas getrennt. +ALTER TABLE `Address` ADD COLUMN IF NOT EXISTS `maloIdElectricity` VARCHAR(191) NULL; +ALTER TABLE `Address` ADD COLUMN IF NOT EXISTS `maloIdGas` VARCHAR(191) NULL; + +-- Daten-Migration: bestehende Vertrags-MaLo-IDs an die jeweilige Lieferadresse +-- übertragen – je nach Vertragstyp in Strom- bzw. Gas-Feld. Nur setzen, wenn +-- am Ziel-Adressfeld noch nichts steht (idempotent, kein Überschreiben). +UPDATE `Address` a + JOIN `Contract` c ON c.addressId = a.id + JOIN `EnergyContractDetails` ecd ON ecd.contractId = c.id + SET a.maloIdElectricity = ecd.maloId + WHERE c.type = 'ELECTRICITY' + AND ecd.maloId IS NOT NULL AND ecd.maloId <> '' + AND (a.maloIdElectricity IS NULL OR a.maloIdElectricity = ''); + +UPDATE `Address` a + JOIN `Contract` c ON c.addressId = a.id + JOIN `EnergyContractDetails` ecd ON ecd.contractId = c.id + SET a.maloIdGas = ecd.maloId + WHERE c.type = 'GAS' + AND ecd.maloId IS NOT NULL AND ecd.maloId <> '' + AND (a.maloIdGas IS NULL OR a.maloIdGas = ''); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 762de0d0..31cb08df 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -327,6 +327,10 @@ model Address { city String country String @default("Deutschland") isDefault Boolean @default(false) + // Marktlokations-IDs (MaLo) je Sparte – die MaLo gehört zur (Liefer-)Adresse, + // nicht zum Vertrag. Strom- und Gas-Marktlokation sind getrennt. + maloIdElectricity String? // MaLo-ID Strom + maloIdGas String? // MaLo-ID Gas // Eigentümer (leer = Kunde ist selbst Eigentümer) ownerCompany String? ownerFirstName String? diff --git a/backend/src/services/customer.service.ts b/backend/src/services/customer.service.ts index e4d4859b..df12abe0 100644 --- a/backend/src/services/customer.service.ts +++ b/backend/src/services/customer.service.ts @@ -218,6 +218,27 @@ export async function getCustomerAddresses(customerId: number) { }); } +// Feld-Whitelist für Address-Create/Update (Mass-Assignment-Schutz, konsistent +// zu R156; autoritativ aus den DB-Spalten abgeleitet). BEWUSST NICHT enthalten: +// id/customerId/createdAt/updatedAt. Enthält die Eigentümer-Felder (owner*) – +// die waren beim ersten R156-Umbau versehentlich rausgefallen (Regression) – +// sowie die neuen MaLo-IDs. +const ADDRESS_SCALAR_FIELDS = [ + 'type', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'isDefault', + 'maloIdElectricity', 'maloIdGas', + 'ownerCompany', 'ownerFirstName', 'ownerLastName', 'ownerStreet', 'ownerHouseNumber', + 'ownerPostalCode', 'ownerCity', 'ownerPhone', 'ownerMobile', 'ownerEmail', +] as const; + +function pickAddressScalars(data: unknown): Record { + const src = (data && typeof data === 'object') ? (data as Record) : {}; + const out: Record = {}; + for (const key of ADDRESS_SCALAR_FIELDS) { + if (key in src) out[key] = src[key]; + } + return out; +} + export async function createAddress( customerId: number, data: { @@ -238,17 +259,12 @@ export async function createAddress( }); } - // Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155). + // Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe + // ADDRESS_SCALAR_FIELDS. customerId kommt ausschließlich aus dem Parameter. return prisma.address.create({ data: { customerId, - type: data.type, - street: data.street, - houseNumber: data.houseNumber, - postalCode: data.postalCode, - city: data.city, - country: data.country, - isDefault: data.isDefault, + ...(pickAddressScalars(data) as any), }, }); } @@ -279,18 +295,11 @@ export async function updateAddress( }); } - // Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155). + // Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe + // ADDRESS_SCALAR_FIELDS. return prisma.address.update({ where: { id }, - data: { - type: data.type, - street: data.street, - houseNumber: data.houseNumber, - postalCode: data.postalCode, - city: data.city, - country: data.country, - isDefault: data.isDefault, - }, + data: pickAddressScalars(data) as any, }); } diff --git a/docs/todo.md b/docs/todo.md index 9fb1be09..86ccebeb 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14) + - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt + **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal + (nur Lieferadresse) pflegbar. + - **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte + (ELECTRICITY→Strom, GAS→Gas) die MaLo der gewählten Lieferadresse zeigt (mit + Copy + „dort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der + Adresse. + - **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent) + **+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` → jeweilige + Lieferadresse, Strom→maloIdElectricity / Gas→maloIdGas). Verifiziert. + - **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156- + Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address- + Whitelist gefallen → die Eigentümer-Sektion speicherte seit `cb21a2c` nicht + mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle + DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus). + - [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13) - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent diff --git a/frontend/src/components/contracts/ContractDetailModal.tsx b/frontend/src/components/contracts/ContractDetailModal.tsx index 7c317907..e0a799bf 100644 --- a/frontend/src/components/contracts/ContractDetailModal.tsx +++ b/frontend/src/components/contracts/ContractDetailModal.tsx @@ -134,15 +134,19 @@ export default function ContractDetailModal({ contractId, isOpen, onClose }: Con - {c.energyDetails.maloId && ( -
-
MaLo-ID
-
- {c.energyDetails.maloId} - -
-
- )} + {(() => { + // MaLo-ID kommt jetzt aus der Lieferadresse (je Sparte). + const malo = c.type === 'ELECTRICITY' ? c.address?.maloIdElectricity : c.address?.maloIdGas; + return malo ? ( +
+
MaLo-ID ({c.type === 'ELECTRICITY' ? 'Strom' : 'Gas'})
+
+ {malo} + +
+
+ ) : null; + })()} )} diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index 84ced351..229892aa 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -2812,15 +2812,19 @@ export default function ContractDetail() { )} )} - {c.energyDetails.maloId && ( -
-
MaLo-ID
-
- {c.energyDetails.maloId} - -
-
- )} + {(() => { + // MaLo-ID kommt jetzt aus der Lieferadresse (je Sparte). + const malo = c.type === 'ELECTRICITY' ? c.address?.maloIdElectricity : c.address?.maloIdGas; + return malo ? ( +
+
MaLo-ID ({c.type === 'ELECTRICITY' ? 'Strom' : 'Gas'})
+
+ {malo} + +
+
+ ) : null; + })()} {c.energyDetails.annualConsumption ? (
diff --git a/frontend/src/pages/contracts/ContractForm.tsx b/frontend/src/pages/contracts/ContractForm.tsx index 4ba9ff1f..1f1d6124 100644 --- a/frontend/src/pages/contracts/ContractForm.tsx +++ b/frontend/src/pages/contracts/ContractForm.tsx @@ -349,7 +349,6 @@ export default function ContractForm() { notes: c.notes || '', // Energy details meterId: c.energyDetails?.meterId?.toString() || '', - maloId: c.energyDetails?.maloId || '', annualConsumption: c.energyDetails?.annualConsumption || '', annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '', basePrice: c.energyDetails?.basePrice || '', @@ -672,7 +671,7 @@ export default function ContractForm() { if (['ELECTRICITY', 'GAS'].includes(data.type)) { contractData.energyDetails = { meterId: safeParseInt(data.meterId) ?? null, - maloId: emptyToNull(data.maloId), + // maloId wird nicht mehr am Vertrag gepflegt – sie gehört zur Lieferadresse. annualConsumption: data.annualConsumption ? parseFloat(data.annualConsumption) : null, annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null, basePrice: data.basePrice ? parseFloat(data.basePrice) : null, @@ -1448,10 +1447,46 @@ export default function ContractForm() { /> ); })()} - + {(() => { + // MaLo-ID gehört zur Lieferadresse (nicht zum Vertrag) → Lesefeld, + // Wert je nach Sparte aus der gewählten Lieferadresse. + const selAddr = addresses.find((a) => String(a.id) === watch('addressId')); + const isElectricity = contractType === 'ELECTRICITY'; + const maloValue = isElectricity ? selAddr?.maloIdElectricity : selAddr?.maloIdGas; + const sparte = isElectricity ? 'Strom' : 'Gas'; + return ( +
+ +
+ + {maloValue && } +
+

+ Wird aus der Lieferadresse übernommen –{' '} + {customerId ? ( + + dort pflegen + + ) : 'in der Kundenakte an der Adresse pflegen'}. +

+
+ ); + })()}
+ {/* Marktlokations-IDs (nur Lieferadresse) – gehören zur Adresse, nicht + zum Vertrag; im Vertrag werden sie je Sparte nur noch angezeigt. */} + {formData.type === 'DELIVERY_RESIDENCE' && ( +
+

Marktlokations-IDs (MaLo)

+

+ Je Sparte getrennt. Wird im Strom-/Gasvertrag automatisch aus dieser Lieferadresse angezeigt. +

+
+ setFormData({ ...formData, maloIdElectricity: e.target.value })} + placeholder="z.B. 11XXXXXXXXXXX" + /> + setFormData({ ...formData, maloIdGas: e.target.value })} + placeholder="z.B. 11XXXXXXXXXXX" + /> +
+
+ )} + {/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */} {formData.type === 'DELIVERY_RESIDENCE' && (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ac48b9ea..fd5840dd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -260,6 +260,9 @@ export interface Address { city: string; country: string; isDefault: boolean; + // Marktlokations-IDs (MaLo) je Sparte – gehören zur (Liefer-)Adresse + maloIdElectricity?: string; + maloIdGas?: string; // Eigentümer (leer = Kunde ist selbst Eigentümer) ownerCompany?: string; ownerFirstName?: string;