diff --git a/backend/prisma/migrations/20260601200000_phone_area_code/migration.sql b/backend/prisma/migrations/20260601200000_phone_area_code/migration.sql
new file mode 100644
index 00000000..f1c52dfc
--- /dev/null
+++ b/backend/prisma/migrations/20260601200000_phone_area_code/migration.sql
@@ -0,0 +1,14 @@
+-- Telefonnummern bekommen eine zusätzliche Spalte `areaCode`, damit
+-- Wechselauftrag-PDFs die Vorwahl verlässlich befüllen können, ohne
+-- den heuristischen String-Split, der bei Sonderformaten danebenfasst.
+--
+-- `phoneNumber` bleibt unverändert die komplette Nummer (Vorwahl +
+-- Anschlussnummer), damit Reports/Listen/Suchen weiter funktionieren.
+-- `areaCode` ist optional, weil Bestandsnummern erst beim nächsten
+-- Edit nachgepflegt werden.
+--
+-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
+-- schon `prisma db push` gefahren hat.
+
+ALTER TABLE `PhoneNumber`
+ ADD COLUMN IF NOT EXISTS `areaCode` VARCHAR(191) NULL;
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 79115da0..e42b7b9c 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -892,7 +892,12 @@ model PhoneNumber {
id Int @id @default(autoincrement())
internetContractDetailsId Int
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
+ // phoneNumber bleibt die komplette Nummer (Vorwahl + Anschluss) für
+ // bestehende Reports/Listen. Vorwahl wird zusätzlich getrennt erfasst,
+ // damit Auftragsformulare (Wechselauftrag PDF) sie verlässlich
+ // befüllen können – ohne heuristischen String-Split.
phoneNumber String
+ areaCode String?
isMain Boolean @default(false)
// SIP-Zugangsdaten
sipUsername String?
diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts
index 13e6bd71..13c41a99 100644
--- a/backend/src/services/contract.service.ts
+++ b/backend/src/services/contract.service.ts
@@ -250,6 +250,7 @@ interface ContractCreateData {
phoneNumbers?: {
id?: number;
phoneNumber: string;
+ areaCode?: string;
isMain?: boolean;
sipUsername?: string;
sipPassword?: string;
@@ -345,6 +346,7 @@ export async function createContract(data: ContractCreateData) {
? {
create: internetDetails.phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
+ areaCode: pn.areaCode,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
@@ -542,6 +544,7 @@ export async function updateContract(
return {
internetContractDetailsId: existing.id,
phoneNumber: pn.phoneNumber,
+ areaCode: pn.areaCode,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
// Preserve existing sipPassword if no new value provided
@@ -565,6 +568,7 @@ export async function updateContract(
? {
create: phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
+ areaCode: pn.areaCode,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
diff --git a/backend/src/services/pdfTemplate.service.ts b/backend/src/services/pdfTemplate.service.ts
index a28ca376..08780774 100644
--- a/backend/src/services/pdfTemplate.service.ts
+++ b/backend/src/services/pdfTemplate.service.ts
@@ -561,8 +561,31 @@ export async function generateFilledPdf(
const maxFields = template.maxPhoneFields || 8;
for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) {
- const fullNumber = phoneNumbers[i]?.phoneNumber || '';
- const { areaCode, local } = splitPhoneNumber(fullNumber);
+ const entry = phoneNumbers[i];
+ const fullNumber = entry?.phoneNumber || '';
+ // Bevorzugt den explizit gepflegten areaCode aus der DB (verlässlich),
+ // fällt sonst auf die Heuristik zurück (Altbestand ohne separates
+ // Vorwahl-Feld). `phoneLocal` analog: aus phoneNumber abgeleitet,
+ // wenn areaCode da → den Vorwahl-Prefix abschneiden, sonst Heuristik.
+ let areaCode = '';
+ let local = '';
+ if (entry?.areaCode) {
+ areaCode = entry.areaCode;
+ const split = splitPhoneNumber(fullNumber);
+ // Wenn der heuristische areaCode mit dem DB-Wert übereinstimmt,
+ // ist der heuristische local-Anteil korrekt – sonst pragmatisch:
+ // alles nach dem areaCode-Prefix bis zum Ende
+ if (split.areaCode === entry.areaCode) {
+ local = split.local;
+ } else {
+ const stripped = fullNumber.replace(new RegExp('^' + entry.areaCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\-/]*'), '').trim();
+ local = stripped || split.local;
+ }
+ } else {
+ const split = splitPhoneNumber(fullNumber);
+ areaCode = split.areaCode;
+ local = split.local;
+ }
dataContext[`phoneNumbers[${i}]`] = fullNumber;
dataContext[`phoneAreaCode[${i}]`] = areaCode;
dataContext[`phoneLocal[${i}]`] = local;
diff --git a/frontend/src/pages/contracts/ContractForm.tsx b/frontend/src/pages/contracts/ContractForm.tsx
index a4b7ae9d..c91f3437 100644
--- a/frontend/src/pages/contracts/ContractForm.tsx
+++ b/frontend/src/pages/contracts/ContractForm.tsx
@@ -199,7 +199,11 @@ export default function ContractForm() {
// Rufnummern State für Internet-Verträge
interface PhoneNumberInput {
id?: number;
- phoneNumber: string;
+ // areaCode + subscriber sind die getrennten Eingabefelder.
+ // phoneNumber-Feld in der DB ist immer die zusammengesetzte
+ // "Vorwahl Anschluss"-Form, wird im Submit kombiniert.
+ areaCode: string;
+ subscriber: string;
sipUsername: string;
sipPassword: string;
hasExistingSipPassword?: boolean;
@@ -388,15 +392,41 @@ export default function ContractForm() {
// Load phoneNumbers if available (Internet contracts)
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
- setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => ({
- id: pn.id,
- phoneNumber: pn.phoneNumber || '',
- sipUsername: pn.sipUsername || '',
- sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
- hasExistingSipPassword: !!pn.sipPasswordEncrypted,
- sipServer: pn.sipServer || '',
- isMain: pn.isMain,
- })));
+ setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => {
+ // Wenn areaCode gepflegt ist → 1:1 nutzen, sonst aus phoneNumber
+ // heuristisch splitten (dann sollte der User das Ergebnis prüfen
+ // und beim nächsten Speichern fixen).
+ const splitLegacy = (s: string) => {
+ const cleaned = s.replace(/[()]/g, '').trim();
+ const sep = cleaned.match(/^(\d{2,5})[\/\s\-](.+)$/);
+ if (sep) return { ac: sep[1], rest: sep[2].replace(/[\s\-/]/g, '') };
+ const noSep = cleaned.match(/^(0\d{1,4})(\d{3,})$/);
+ if (noSep) return { ac: noSep[1], rest: noSep[2] };
+ return { ac: '', rest: cleaned };
+ };
+ const full = pn.phoneNumber || '';
+ let ac = pn.areaCode || '';
+ let rest = '';
+ if (ac && full.startsWith(ac)) {
+ rest = full.slice(ac.length).replace(/^[\s\-/]+/, '').trim();
+ } else if (ac) {
+ rest = full.replace(ac, '').replace(/^[\s\-/]+/, '').trim();
+ } else {
+ const s = splitLegacy(full);
+ ac = s.ac;
+ rest = s.rest;
+ }
+ return {
+ id: pn.id,
+ areaCode: ac,
+ subscriber: rest,
+ sipUsername: pn.sipUsername || '',
+ sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
+ hasExistingSipPassword: !!pn.sipPasswordEncrypted,
+ sipServer: pn.sipServer || '',
+ isMain: pn.isMain,
+ };
+ }));
} else {
setPhoneNumbers([]);
}
@@ -581,14 +611,22 @@ export default function ContractForm() {
// Vodafone DSL/Kabel spezifisch
activationCode: emptyToNull(data.activationCode),
// Rufnummern mit SIP-Daten
- phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => ({
- id: pn.id,
- phoneNumber: pn.phoneNumber || '',
- isMain: pn.isMain ?? false,
- sipUsername: emptyToNull(pn.sipUsername),
- sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
- sipServer: emptyToNull(pn.sipServer),
- })) : undefined,
+ phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => {
+ const ac = (pn.areaCode || '').trim();
+ const sub = (pn.subscriber || '').trim();
+ // phoneNumber bleibt das kombinierte "Vorwahl Anschluss"-Feld
+ // für Listen/Suchen, areaCode/subscriber sind die getrennten Werte.
+ const combined = ac && sub ? `${ac} ${sub}` : (sub || ac);
+ return {
+ id: pn.id,
+ phoneNumber: combined,
+ areaCode: ac || undefined,
+ isMain: pn.isMain ?? false,
+ sipUsername: emptyToNull(pn.sipUsername),
+ sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
+ sipServer: emptyToNull(pn.sipServer),
+ };
+ }) : undefined,
};
}
@@ -1327,16 +1365,26 @@ export default function ContractForm() {