MaLo-ID an die Lieferadresse (Strom/Gas) + Address-owner-Regression gefixt
MaLo-ID (Marktlokation) gehoert zur (Liefer-)Adresse, nicht zum Vertrag.
Address bekommt maloIdElectricity + maloIdGas (getrennte Marktlokationen je
Sparte), pflegbar im AddressModal (nur Lieferadresse). Im Vertrag ist die
MaLo-ID jetzt ein Lesefeld, das je nach Vertragstyp die MaLo der gewaehlten
Lieferadresse zeigt; ContractDetail/-Modal ebenso.
Schema + Migration 20260814100000: 2 Spalten (idempotent) + Daten-Migration
(bestehende EnergyContractDetails.maloId -> jeweilige Lieferadresse,
ELECTRICITY->maloIdElectricity / GAS->maloIdGas). Migrationslogik verifiziert.
Dabei einen selbst verursachten Regressions-Bug gefixt: beim R156-Umbau waren
die 10 owner*-Adressfelder aus der Address-Whitelist gefallen -> Eigentuemer-
Sektion speicherte seit cb21a2c nicht mehr. Address-Whitelist jetzt via
Pick-Helper, programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo
drin, id/customerId/Timestamps raus). BankCard/Document gegengeprueft: ok
(nur documentPath bewusst upload-only ausgeschlossen).
Verifiziert: tsc+build gruen; owner + maloId speichern wieder, Injection
(id/customerId) blockiert; Daten-Migration Strom->Strom / Gas->Gas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = '');
|
||||||
@@ -327,6 +327,10 @@ model Address {
|
|||||||
city String
|
city String
|
||||||
country String @default("Deutschland")
|
country String @default("Deutschland")
|
||||||
isDefault Boolean @default(false)
|
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)
|
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
||||||
ownerCompany String?
|
ownerCompany String?
|
||||||
ownerFirstName String?
|
ownerFirstName String?
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of ADDRESS_SCALAR_FIELDS) {
|
||||||
|
if (key in src) out[key] = src[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAddress(
|
export async function createAddress(
|
||||||
customerId: number,
|
customerId: number,
|
||||||
data: {
|
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({
|
return prisma.address.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
type: data.type,
|
...(pickAddressScalars(data) as any),
|
||||||
street: data.street,
|
|
||||||
houseNumber: data.houseNumber,
|
|
||||||
postalCode: data.postalCode,
|
|
||||||
city: data.city,
|
|
||||||
country: data.country,
|
|
||||||
isDefault: data.isDefault,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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({
|
return prisma.address.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: pickAddressScalars(data) as any,
|
||||||
type: data.type,
|
|
||||||
street: data.street,
|
|
||||||
houseNumber: data.houseNumber,
|
|
||||||
postalCode: data.postalCode,
|
|
||||||
city: data.city,
|
|
||||||
country: data.country,
|
|
||||||
isDefault: data.isDefault,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)
|
- [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13)
|
||||||
- Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen
|
- Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen
|
||||||
`...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent
|
`...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent
|
||||||
|
|||||||
@@ -134,15 +134,19 @@ export default function ContractDetailModal({ contractId, isOpen, onClose }: Con
|
|||||||
<CopyButton value={c.energyDetails.meter.meterNumber} />
|
<CopyButton value={c.energyDetails.meter.meterNumber} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{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 ? (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">MaLo-ID</dt>
|
<dt className="text-sm text-gray-500">MaLo-ID ({c.type === 'ELECTRICITY' ? 'Strom' : 'Gas'})</dt>
|
||||||
<dd className="font-mono flex items-center gap-1">
|
<dd className="font-mono flex items-center gap-1">
|
||||||
{c.energyDetails.maloId}
|
{malo}
|
||||||
<CopyButton value={c.energyDetails.maloId} />
|
<CopyButton value={malo} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2812,15 +2812,19 @@ export default function ContractDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{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 ? (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">MaLo-ID</dt>
|
<dt className="text-sm text-gray-500">MaLo-ID ({c.type === 'ELECTRICITY' ? 'Strom' : 'Gas'})</dt>
|
||||||
<dd className="font-mono flex items-center gap-1">
|
<dd className="font-mono flex items-center gap-1">
|
||||||
{c.energyDetails.maloId}
|
{malo}
|
||||||
<CopyButton value={c.energyDetails.maloId} />
|
<CopyButton value={malo} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null;
|
||||||
|
})()}
|
||||||
{c.energyDetails.annualConsumption ? (
|
{c.energyDetails.annualConsumption ? (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">
|
<dt className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -349,7 +349,6 @@ export default function ContractForm() {
|
|||||||
notes: c.notes || '',
|
notes: c.notes || '',
|
||||||
// Energy details
|
// Energy details
|
||||||
meterId: c.energyDetails?.meterId?.toString() || '',
|
meterId: c.energyDetails?.meterId?.toString() || '',
|
||||||
maloId: c.energyDetails?.maloId || '',
|
|
||||||
annualConsumption: c.energyDetails?.annualConsumption || '',
|
annualConsumption: c.energyDetails?.annualConsumption || '',
|
||||||
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
|
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
|
||||||
basePrice: c.energyDetails?.basePrice || '',
|
basePrice: c.energyDetails?.basePrice || '',
|
||||||
@@ -672,7 +671,7 @@ export default function ContractForm() {
|
|||||||
if (['ELECTRICITY', 'GAS'].includes(data.type)) {
|
if (['ELECTRICITY', 'GAS'].includes(data.type)) {
|
||||||
contractData.energyDetails = {
|
contractData.energyDetails = {
|
||||||
meterId: safeParseInt(data.meterId) ?? null,
|
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,
|
annualConsumption: data.annualConsumption ? parseFloat(data.annualConsumption) : null,
|
||||||
annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null,
|
annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null,
|
||||||
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
|
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
|
||||||
@@ -1448,10 +1447,46 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<Input
|
{(() => {
|
||||||
label="MaLo-ID (Marktlokations-ID)"
|
// MaLo-ID gehört zur Lieferadresse (nicht zum Vertrag) → Lesefeld,
|
||||||
{...register('maloId')}
|
// 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 (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
MaLo-ID (Marktlokations-ID {sparte})
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={maloValue || ''}
|
||||||
|
readOnly
|
||||||
|
placeholder={!watch('addressId')
|
||||||
|
? 'Erst Lieferadresse wählen…'
|
||||||
|
: `Keine MaLo-ID ${sparte} an der Lieferadresse hinterlegt`}
|
||||||
|
className="block w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-700 cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
|
{maloValue && <CopyButton value={maloValue} />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Wird aus der Lieferadresse übernommen –{' '}
|
||||||
|
{customerId ? (
|
||||||
|
<a
|
||||||
|
href={`/customers/${customerId}?tab=addresses`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
dort pflegen
|
||||||
|
</a>
|
||||||
|
) : 'in der Kundenakte an der Adresse pflegen'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
|
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
|
||||||
|
|||||||
@@ -2490,6 +2490,8 @@ function AddressModal({
|
|||||||
city: address?.city || '',
|
city: address?.city || '',
|
||||||
country: address?.country || 'Deutschland',
|
country: address?.country || 'Deutschland',
|
||||||
isDefault: address?.isDefault || false,
|
isDefault: address?.isDefault || false,
|
||||||
|
maloIdElectricity: address?.maloIdElectricity || '',
|
||||||
|
maloIdGas: address?.maloIdGas || '',
|
||||||
ownerCompany: address?.ownerCompany || '',
|
ownerCompany: address?.ownerCompany || '',
|
||||||
ownerFirstName: address?.ownerFirstName || '',
|
ownerFirstName: address?.ownerFirstName || '',
|
||||||
ownerLastName: address?.ownerLastName || '',
|
ownerLastName: address?.ownerLastName || '',
|
||||||
@@ -2605,6 +2607,31 @@ function AddressModal({
|
|||||||
Als Standard setzen
|
Als Standard setzen
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Marktlokations-IDs (nur Lieferadresse) – gehören zur Adresse, nicht
|
||||||
|
zum Vertrag; im Vertrag werden sie je Sparte nur noch angezeigt. */}
|
||||||
|
{formData.type === 'DELIVERY_RESIDENCE' && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-1">Marktlokations-IDs (MaLo)</h4>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Je Sparte getrennt. Wird im Strom-/Gasvertrag automatisch aus dieser Lieferadresse angezeigt.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="MaLo-ID Strom"
|
||||||
|
value={formData.maloIdElectricity}
|
||||||
|
onChange={(e) => setFormData({ ...formData, maloIdElectricity: e.target.value })}
|
||||||
|
placeholder="z.B. 11XXXXXXXXXXX"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="MaLo-ID Gas"
|
||||||
|
value={formData.maloIdGas}
|
||||||
|
onChange={(e) => setFormData({ ...formData, maloIdGas: e.target.value })}
|
||||||
|
placeholder="z.B. 11XXXXXXXXXXX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */}
|
{/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */}
|
||||||
{formData.type === 'DELIVERY_RESIDENCE' && (
|
{formData.type === 'DELIVERY_RESIDENCE' && (
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export interface Address {
|
|||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
// Marktlokations-IDs (MaLo) je Sparte – gehören zur (Liefer-)Adresse
|
||||||
|
maloIdElectricity?: string;
|
||||||
|
maloIdGas?: string;
|
||||||
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
||||||
ownerCompany?: string;
|
ownerCompany?: string;
|
||||||
ownerFirstName?: string;
|
ownerFirstName?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user