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() { -
+
{ const updated = [...phoneNumbers]; - updated[index].phoneNumber = e.target.value; + updated[index].areaCode = e.target.value; setPhoneNumbers(updated); }} - placeholder="z.B. 030 123456" + placeholder="z.B. 030" + /> + { + const updated = [...phoneNumbers]; + updated[index].subscriber = e.target.value; + setPhoneNumbers(updated); + }} + placeholder="z.B. 123456" /> { setPhoneNumbers([...phoneNumbers, { - phoneNumber: '', + areaCode: '', + subscriber: '', sipUsername: '', sipPassword: '', sipServer: '', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ee0859e2..064dc9ba 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -540,7 +540,11 @@ export interface InternetContractDetails { export interface PhoneNumber { id: number; + // Komplette Nummer (Vorwahl + Anschluss). Bleibt für bestehende + // Listen/Suchen die Quelle der Wahrheit. Vorwahl zusätzlich separat + // gepflegt, damit Wechselauftrag-PDFs sie verlässlich befüllen. phoneNumber: string; + areaCode?: string; isMain: boolean; // SIP-Zugangsdaten sipUsername?: string;