calls) {
submit("Anrufdaten", () -> calls.forEach(mongo::save));
diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/JobCleanupService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/JobCleanupService.java
new file mode 100644
index 0000000..dc7c208
--- /dev/null
+++ b/backend/src/main/java/de/appcreation/swyxweb/storage/JobCleanupService.java
@@ -0,0 +1,58 @@
+package de.appcreation.swyxweb.storage;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+/**
+ * Räumt die Sammlung {@code jobs} auf: Jede Nacht um 0 Uhr werden die Jobs
+ * gelöscht, deren {@code ordertime} mehr als vier Wochen zurückliegt.
+ *
+ * Die {@code ordertime} liegt als ISO-8601-Zeichenkette mit Zeitzonenversatz
+ * in der Datenbank (z. B. {@code 2026-07-31T16:30:00+02:00}, siehe
+ * {@link JobEntry}). Verglichen wird als Text gegen eine Grenze im selben
+ * Format – ISO-8601 sortiert als Text richtig; die wenigen Stunden Unschärfe
+ * durch unterschiedliche Zeitzonenversätze fallen bei einer Vier-Wochen-Grenze
+ * nicht ins Gewicht. Jobs ohne {@code ordertime} bleiben unangetastet – ihr
+ * Alter ist nicht zu beurteilen.
+ */
+@Service
+public class JobCleanupService {
+
+ private static final Logger log = LoggerFactory.getLogger(JobCleanupService.class);
+
+ /** „Mehr als vier Wochen in der Vergangenheit" – die Grenze der Bereinigung. */
+ public static final int MAX_AGE_WEEKS = 4;
+
+ /** Zeitzone des Zeitplans wie der Grenze: „0 Uhr" heißt 0 Uhr deutscher Zeit. */
+ public static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
+
+ private final ArchiveService archive;
+
+ public JobCleanupService(ArchiveService archive) {
+ this.archive = archive;
+ }
+
+ /** Jede Nacht um 0 Uhr; verpasste Läufe (Backend aus) werden nicht nachgeholt. */
+ @Scheduled(cron = "0 0 0 * * *", zone = "Europe/Berlin")
+ public void purgeOldJobs() {
+ String cutoff = cutoffOrdertime(ZonedDateTime.now(ZONE));
+ long removed = archive.removeJobsBefore(cutoff);
+ if (removed > 0) {
+ log.info("Job-Bereinigung: {} Job(s) mit ordertime vor {} gelöscht.", removed, cutoff);
+ } else {
+ log.debug("Job-Bereinigung: nichts zu löschen (Grenze {}).", cutoff);
+ }
+ }
+
+ /** Die Grenze im Format der {@code ordertime}, z. B. {@code 2026-07-31T00:00:00+02:00}. */
+ public static String cutoffOrdertime(ZonedDateTime now) {
+ return now.minusWeeks(MAX_AGE_WEEKS)
+ .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxxx"));
+ }
+}
diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java
index 0138a2b..18f1771 100644
--- a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java
+++ b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java
@@ -3,6 +3,7 @@ package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
+import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -11,6 +12,7 @@ import tools.jackson.databind.ObjectMapper;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.data.mongodb.core.mapping.Field;
/**
* Ein Job (Kurierauftrag), wie ihn das Fremdsystem über den Webhook schickt
@@ -22,18 +24,27 @@ import org.springframework.data.mongodb.core.mapping.Document;
* {@code modified}) ersetzt also seinen alten Stand.
*
*
Die Zeitangaben bleiben die Zeichenketten der Nutzlast
- * (z. B. {@code 2026-07-31T16:30:00+02:00}): ISO-8601 sortiert auch als Text
- * richtig, und der Zeitzonenversatz des Fremdsystems geht nicht verloren.
+ * (z. B. {@code 2026-08-13T13:35:27}, teils auch mit Zeitzonenversatz):
+ * ISO-8601 sortiert auch als Text richtig, und die Schreibweise des
+ * Fremdsystems geht nicht verloren.
+ *
+ *
Die Rufnummern hießen in einer älteren Form der Nutzlast {@code number}
+ * statt {@code phone} – beide Schreibweisen werden angenommen.
*
* @param id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs
+ * @param url Sprungadresse des Fremdsystems zur Auftragsansicht
* @param state Zustand des Jobs im Fremdsystem
- * @param ordertime Auftragszeit, z. B. "2026-07-31T16:30:00+02:00"
- * @param orderdate Auftragsdatum, z. B. "2026-07-31"
+ * @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
+ * @param orderdate Auftragsdatum, z. B. "2026-08-13"
* @param modified letzte Änderung im Fremdsystem
- * @param vehicle Fahrzeugart, z. B. "Transporter"
+ * @param finished wann der Job abgeschlossen wurde, sonst leer
+ * @param vehicle Fahrzeugart, z. B. "Transporter XL"
+ * @param service gebuchte Leistung, kann fehlen
+ * @param canceled ist der Job storniert?
+ * @param global bundesweite Vermittlung?
* @param customer der beauftragende Kunde
- * @param courier der ausführende Kurier
+ * @param courier der ausführende Kurier, solange keiner zugeteilt ist leer
* @param tours die Stationen des Jobs, per {@code sort} geordnet
*/
@Document("jobs")
@@ -41,11 +52,16 @@ import org.springframework.data.mongodb.core.mapping.Document;
public record JobEntry(
@Id long id,
Instant receivedAt,
+ String url,
Integer state,
String ordertime,
String orderdate,
String modified,
+ String finished,
String vehicle,
+ String service,
+ Boolean canceled,
+ Boolean global,
Customer customer,
Courier courier,
List tours) {
@@ -56,21 +72,30 @@ public record JobEntry(
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param name Firmenname
* @param hq Niederlassung, z. B. "Bremen"
+ * @param phone Rufnummer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
- public record Customer(@JsonProperty("csc_id") Integer cscId, String name, String hq) {
+ public record Customer(
+ @JsonProperty("csc_id") Integer cscId,
+ String name,
+ String hq,
+ @JsonAlias("number") String phone) {
}
/**
* Der ausführende Kurier.
*
- * @param crId Kurierkennung des Fremdsystems ({@code cr_id})
- * @param sid Kurzkennung, z. B. "B1006"
- * @param name Anzeigename
- * @param number Rufnummer
+ * @param crId Kurierkennung des Fremdsystems ({@code cr_id})
+ * @param sid Kurzkennung, z. B. "B1006"
+ * @param name Anzeigename
+ * @param phone Rufnummer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
- public record Courier(@JsonProperty("cr_id") Integer crId, String sid, String name, String number) {
+ public record Courier(
+ @JsonProperty("cr_id") Integer crId,
+ String sid,
+ String name,
+ @JsonAlias("number") String phone) {
}
/**
@@ -79,29 +104,33 @@ public record JobEntry(
* @param id Kennung des Fremdsystems
* @param sort Reihenfolge innerhalb des Jobs, 1-basiert
* @param state Zustand der Station
- * @param mode Art der Station
+ * @param mode Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung)
* @param comp Firma an der Station
* @param person Ansprechperson
- * @param number Rufnummer an der Station, kann fehlen
+ * @param phone Rufnummer an der Station, kann fehlen
* @param street Straße und Hausnummer
* @param zip Postleitzahl
* @param city Ort
* @param com Bemerkung
+ * @param remark Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …)
* @param finished wann die Station abgeschlossen wurde, sonst leer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Tour(
- Long id,
+ // Ausdrücklich als "id" ablegen – sonst macht Spring Data auch im
+ // Unterdokument ein "_id" daraus.
+ @Field("id") Long id,
Integer sort,
Integer state,
String mode,
String comp,
String person,
- String number,
+ @JsonAlias("number") String phone,
String street,
String zip,
String city,
String com,
+ String remark,
String finished) {
}
@@ -113,7 +142,8 @@ public record JobEntry(
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
JobEntry job = mapper.convertValue(json, JobEntry.class);
- return new JobEntry(job.id(), receivedAt, job.state(), job.ordertime(), job.orderdate(),
- job.modified(), job.vehicle(), job.customer(), job.courier(), job.tours());
+ return new JobEntry(job.id(), receivedAt, job.url(), job.state(), job.ordertime(),
+ job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
+ job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
}
}
diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java
index cdd5c18..e18f32c 100644
--- a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java
+++ b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java
@@ -54,7 +54,7 @@ class AddressControllerTests {
@Test
void listPassesQueryToArchive() throws Exception {
when(archive.addresses("muster"))
- .thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null)));
+ .thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null, null, null, null, null)));
mvc.perform(get("/api/addresses").param("q", "muster"))
.andExpect(status().isOk())
diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java
index 42d7d3f..4ed84f0 100644
--- a/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java
+++ b/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java
@@ -27,14 +27,34 @@ class AddressEntryTests {
@Test
void readsSingleWebhookEntry() {
List entries = parse("""
- {"name":"SYSGEN GmbH","number":"+49421409660",
- "description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"}""");
+ {"name":"SYSGEN GmbH","number":"+491602107449",
+ "description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen",
+ "role":"customer","csc_id":100164,"job_ids":[21891263,21891262],
+ "url":"https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449"}""");
assertThat(entries).hasSize(1);
AddressEntry entry = entries.getFirst();
assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
- assertThat(entry.number()).isEqualTo("+49421409660");
- assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164");
+ assertThat(entry.number()).isEqualTo("+491602107449");
+ assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen");
+ assertThat(entry.role()).isEqualTo("customer");
+ assertThat(entry.cscId()).isEqualTo(100164);
+ assertThat(entry.jobIds()).containsExactly(21891263L, 21891262L);
+ assertThat(entry.url())
+ .isEqualTo("https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449");
+ }
+
+ /** Die Felder ab {@code role} darf das Fremdsystem auch weglassen. */
+ @Test
+ void extraFieldsStayNullWhenAbsent() {
+ List entries = parse("""
+ {"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}""");
+
+ AddressEntry entry = entries.getFirst();
+ assertThat(entry.role()).isNull();
+ assertThat(entry.cscId()).isNull();
+ assertThat(entry.jobIds()).isNull();
+ assertThat(entry.url()).isNull();
}
/** Kuriere und Jobs kommen analog – auch als Liste. */
diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobCleanupServiceTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobCleanupServiceTests.java
new file mode 100644
index 0000000..1cf4aad
--- /dev/null
+++ b/backend/src/test/java/de/appcreation/swyxweb/JobCleanupServiceTests.java
@@ -0,0 +1,32 @@
+package de.appcreation.swyxweb;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.ZonedDateTime;
+
+import org.junit.jupiter.api.Test;
+
+import de.appcreation.swyxweb.storage.JobCleanupService;
+
+/**
+ * Die nächtliche Job-Bereinigung vergleicht die {@code ordertime} als Text –
+ * hier wird die Grenze und der Vergleich gegen das Format der Nutzlast geprüft.
+ */
+class JobCleanupServiceTests {
+
+ @Test
+ void cutoffLiesFourWeeksBackInOrdertimeFormat() {
+ ZonedDateTime now = ZonedDateTime.of(2026, 8, 28, 0, 0, 0, 0, JobCleanupService.ZONE);
+
+ // Vier Wochen vor dem 28.08. ist der 31.07.; im Sommer gilt +02:00.
+ assertThat(JobCleanupService.cutoffOrdertime(now)).isEqualTo("2026-07-31T00:00:00+02:00");
+ }
+
+ @Test
+ void ordertimeStringsCompareChronologically() {
+ String cutoff = "2026-07-31T00:00:00+02:00";
+
+ assertThat("2026-07-30T18:14:02+02:00").isLessThan(cutoff);
+ assertThat("2026-08-01T08:00:00+02:00").isGreaterThan(cutoff);
+ }
+}
diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java
index ef5fbca..d312982 100644
--- a/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java
+++ b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java
@@ -13,7 +13,9 @@ import de.appcreation.swyxweb.storage.JobEntry;
/**
* {@link JobEntry} wird aus der Webhook-Nutzlast mit {@code "type":"job"}
- * gefüllt – samt Kunde, Kurier und Stationen.
+ * gefüllt – samt Kunde, Kurier und Stationen. Eine ältere Form der Nutzlast
+ * nannte die Rufnummern {@code number} statt {@code phone}; auch sie wird
+ * weiterhin verstanden.
*/
class JobEntryTests {
@@ -22,21 +24,30 @@ class JobEntryTests {
private static final String SAMPLE = """
{
"type": "job",
- "id": 21891253,
- "state": 2,
- "ordertime": "2026-07-31T16:30:00+02:00",
- "orderdate": "2026-07-31",
- "modified": "2026-07-30T18:14:02+02:00",
- "vehicle": "Transporter",
- "customer": { "csc_id": 868374, "name": "B+M isol nord GmbH", "hq": "Bremen" },
- "courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006", "number": "+4917xxxxxxx" },
+ "id": 21891263,
+ "url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
+ "state": 9,
+ "ordertime": "2026-08-13T13:35:27",
+ "orderdate": "2026-08-13",
+ "modified": "2026-08-13T13:35:27",
+ "finished": null,
+ "vehicle": "Transporter XL",
+ "service": null,
+ "canceled": false,
+ "global": false,
+ "customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
+ "phone": "+491602107449" },
+ "courier": null,
"tours": [
- { "id": 22396738, "sort": 1, "state": 1, "mode": "", "comp": "B+M isol nord GmbH",
- "person": "", "number": "+4942037010xx", "street": "Löwenhof 2", "zip": "28844",
- "city": "Weyhe-Dreye", "com": "", "finished": "2026-07-30T18:13:11+02:00" },
- { "id": 22396739, "sort": 2, "state": 1, "mode": "", "comp": "Reichspräsident Ebert Kaserne",
- "person": "", "number": null, "street": "Osdorfer Landstr. 365", "zip": "22589",
- "city": "Hamburg", "com": "", "finished": "2026-07-30T18:13:51+02:00" }
+ { "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
+ "person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
+ "remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
+ "zip": "28327", "city": "Bremen", "finished": null },
+ { "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
+ "comp": "Super Micro Computer B.V.", "person": null, "phone": null, "com": null,
+ "remark": "Abholreferenz: 8801420234\\nAnzahl an Paletten: 8",
+ "street": "Het Sterrenbeeld 12-16", "zip": "5215", "city": "'s-Hertogenbosch",
+ "finished": null }
]
}""";
@@ -49,33 +60,60 @@ class JobEntryTests {
@Test
void fillsAllFieldsFromSample() {
- Instant receivedAt = Instant.parse("2026-08-26T10:00:00Z");
+ Instant receivedAt = Instant.parse("2026-08-28T10:00:00Z");
JsonNode json = mapper.readTree(SAMPLE);
JobEntry job = JobEntry.from(json, mapper, receivedAt);
- assertThat(job.id()).isEqualTo(21891253L);
+ assertThat(job.id()).isEqualTo(21891263L);
assertThat(job.receivedAt()).isEqualTo(receivedAt);
- assertThat(job.state()).isEqualTo(2);
- assertThat(job.ordertime()).isEqualTo("2026-07-31T16:30:00+02:00");
- assertThat(job.orderdate()).isEqualTo("2026-07-31");
- assertThat(job.vehicle()).isEqualTo("Transporter");
+ assertThat(job.url()).isEqualTo("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263");
+ assertThat(job.state()).isEqualTo(9);
+ assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
+ assertThat(job.orderdate()).isEqualTo("2026-08-13");
+ assertThat(job.finished()).isNull();
+ assertThat(job.vehicle()).isEqualTo("Transporter XL");
+ assertThat(job.service()).isNull();
+ assertThat(job.canceled()).isFalse();
+ assertThat(job.global()).isFalse();
- assertThat(job.customer().cscId()).isEqualTo(868374);
- assertThat(job.customer().name()).isEqualTo("B+M isol nord GmbH");
+ assertThat(job.customer().cscId()).isEqualTo(100164);
+ assertThat(job.customer().name()).isEqualTo("SYSGEN GmbH");
assertThat(job.customer().hq()).isEqualTo("Bremen");
+ assertThat(job.customer().phone()).isEqualTo("+491602107449");
- assertThat(job.courier().crId()).isEqualTo(14116);
- assertThat(job.courier().sid()).isEqualTo("B1006");
- assertThat(job.courier().number()).isEqualTo("+4917xxxxxxx");
+ // Noch kein Kurier zugeteilt.
+ assertThat(job.courier()).isNull();
assertThat(job.tours()).hasSize(2);
JobEntry.Tour first = job.tours().getFirst();
- assertThat(first.id()).isEqualTo(22396738L);
+ assertThat(first.id()).isEqualTo(22396758L);
assertThat(first.sort()).isEqualTo(1);
- assertThat(first.street()).isEqualTo("Löwenhof 2");
- assertThat(first.finished()).isEqualTo("2026-07-30T18:13:11+02:00");
+ assertThat(first.mode()).isEqualTo("del");
+ assertThat(first.person()).isEqualTo("Frau Hoffmann");
+ assertThat(first.phone()).isEqualTo("+49421409660");
+ assertThat(first.remark()).contains("Hebebühne benötigt.");
+ assertThat(first.street()).isEqualTo("Am Hallacker 48");
// Fehlende Rufnummer der zweiten Station bleibt null.
- assertThat(job.tours().get(1).number()).isNull();
+ assertThat(job.tours().get(1).phone()).isNull();
+ assertThat(job.tours().get(1).city()).isEqualTo("'s-Hertogenbosch");
+ }
+
+ /** Die ältere Form nannte die Rufnummern {@code number} statt {@code phone}. */
+ @Test
+ void acceptsLegacyNumberFields() {
+ JsonNode json = mapper.readTree("""
+ {
+ "type": "job",
+ "id": 21891253,
+ "courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006",
+ "number": "+4917xxxxxxx" },
+ "tours": [ { "id": 22396738, "sort": 1, "number": "+4942037010xx" } ]
+ }""");
+
+ JobEntry job = JobEntry.from(json, mapper, Instant.parse("2026-08-28T10:00:00Z"));
+
+ assertThat(job.courier().phone()).isEqualTo("+4917xxxxxxx");
+ assertThat(job.tours().getFirst().phone()).isEqualTo("+4942037010xx");
}
}
diff --git a/frontend/src/addresses.test.ts b/frontend/src/addresses.test.ts
new file mode 100644
index 0000000..c4fa0a1
--- /dev/null
+++ b/frontend/src/addresses.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest'
+import { pickCustomer, sameNumber, type StoredAddress } from './addresses'
+
+/**
+ * SwyxIt! meldet Rufnummern mit Amtsholung ("001602107449"), die Webhooks
+ * liefern sie international ("+491602107449") – der Abgleich muss beide
+ * Schreibweisen als denselben Anschluss erkennen.
+ */
+describe('sameNumber', () => {
+ it('erkennt dieselbe Nummer trotz unterschiedlicher Schreibweise', () => {
+ expect(sameNumber('+491602107449', '001602107449')).toBe(true)
+ expect(sameNumber('+491602107449', '01602107449')).toBe(true)
+ expect(sameNumber('+49421409660', '0421409660')).toBe(true)
+ expect(sameNumber('+491602107449', '+491602107449')).toBe(true)
+ })
+
+ it('unterscheidet verschiedene Anschlüsse', () => {
+ expect(sameNumber('+491602107449', '+491602107440')).toBe(false)
+ expect(sameNumber('+49421409660', '+494212409661')).toBe(false)
+ })
+
+ it('verlangt bei kurzen Nummern (Durchwahlen) die volle Übereinstimmung', () => {
+ expect(sameNumber('7587', '7587')).toBe(true)
+ expect(sameNumber('7587', '587')).toBe(false)
+ // Eine Durchwahl ist nicht "dieselbe Nummer" wie ein externer Anschluss.
+ expect(sameNumber('7449', '+491602107449')).toBe(false)
+ })
+
+ it('liefert ohne Ziffern kein Ergebnis', () => {
+ expect(sameNumber('', '+491602107449')).toBe(false)
+ expect(sameNumber('unbekannt', '+491602107449')).toBe(false)
+ })
+})
+
+describe('pickCustomer', () => {
+ const entries: StoredAddress[] = [
+ { number: '5215', name: 'Abdul, Rokhsareh', description: 'Globales Telefonbuch' },
+ {
+ number: '+491602107449',
+ name: 'SYSGEN GmbH',
+ description: 'Kunde | SYSGEN, SYSTEME UND | NL Bremen',
+ role: 'customer',
+ cscId: 100164,
+ url: 'https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449',
+ },
+ { number: '+49171677xxxx', name: 'Rainer Peters (HH1003)', role: 'courier' },
+ ]
+
+ it('findet den Kunden zur gemeldeten Rufnummer', () => {
+ const match = pickCustomer(entries, '001602107449')
+ expect(match?.name).toBe('SYSGEN GmbH')
+ expect(match?.url).toContain('tapi_wrapper')
+ })
+
+ it('übergeht Telefonbuch-Einträge und Kuriere', () => {
+ expect(pickCustomer(entries, '5215')).toBeNull()
+ expect(pickCustomer(entries, '+49171677xxxx')).toBeNull()
+ })
+
+ it('liefert null, wenn keine Rufnummer passt', () => {
+ expect(pickCustomer(entries, '+49404711')).toBeNull()
+ })
+})
diff --git a/frontend/src/addresses.ts b/frontend/src/addresses.ts
index 06bedab..5c797a0 100644
--- a/frontend/src/addresses.ts
+++ b/frontend/src/addresses.ts
@@ -1,5 +1,89 @@
import type { Contact } from './swyx/protocol'
+/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
+export interface StoredAddress {
+ number?: string | null
+ name?: string | null
+ description?: string | null
+ /** Rolle im Fremdsystem, z. B. "customer". */
+ role?: string | null
+ /** Kundenkennung des Fremdsystems (`csc_id`). */
+ cscId?: number | null
+ /** Kennungen der zugehörigen Jobs. */
+ jobIds?: number[] | null
+ /** Sprungadresse des Fremdsystems zu diesem Eintrag, z. B. zum Auftrag. */
+ url?: string | null
+}
+
+/**
+ * Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab –
+ * den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem
+ * SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in
+ * Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten.
+ */
+export async function fetchAddressEntries(query = ''): Promise {
+ const wanted = query.trim()
+ const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses'
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`)
+ }
+ return (await response.json()) as StoredAddress[]
+}
+
+/** Wie {@link fetchAddressEntries}, verengt auf die Felder der Adressanzeige. */
+export async function fetchAddresses(query = ''): Promise {
+ const entries = await fetchAddressEntries(query)
+ return entries.map((entry) => ({
+ name: entry.name ?? undefined,
+ number: entry.number ?? undefined,
+ description: entry.description ?? undefined,
+ }))
+}
+
+/** Nur die Ziffern, ohne führende Nullen (Amtsholung, nationale Schreibweise). */
+function significantDigits(number: string): string {
+ return number.replace(/\D/g, '').replace(/^0+/, '')
+}
+
+/**
+ * Meinen zwei Rufnummern denselben Anschluss? `+491602107449`, `01602107449`
+ * und die von SwyxIt! gemeldete Form mit Amtsholung `001602107449`
+ * unterscheiden sich nur in Vorwahl-Schreibweise und führenden Nullen –
+ * verglichen werden deshalb die letzten (bis zu neun) signifikanten Ziffern.
+ * Kurze Nummern (interne Durchwahlen) müssen ganz übereinstimmen.
+ */
+export function sameNumber(a: string, b: string): boolean {
+ const left = significantDigits(a)
+ const right = significantDigits(b)
+ if (!left || !right) return false
+ const length = Math.min(left.length, right.length)
+ if (length < 6) return left === right
+ const tail = Math.min(9, length)
+ return left.slice(-tail) === right.slice(-tail)
+}
+
+/** Der erste Kunde (Rolle `customer`) aus `entries`, dessen Rufnummer passt. */
+export function pickCustomer(entries: StoredAddress[], number: string): StoredAddress | null {
+ return (
+ entries.find(
+ (entry) => entry.role === 'customer' && entry.number && sameNumber(entry.number, number),
+ ) ?? null
+ )
+}
+
+/**
+ * Sucht in der Ablage den Kunden zur Rufnummer eines Anrufs. Die Datenbank
+ * sucht als Teilzeichenkette – abgefragt werden deshalb die letzten Ziffern,
+ * das genaue Passen prüft {@link sameNumber} hier im Browser.
+ */
+export async function findCustomerByNumber(number: string): Promise {
+ const digits = significantDigits(number)
+ if (!digits) return null
+ const entries = await fetchAddressEntries(digits.slice(-9))
+ return pickCustomer(entries, number)
+}
+
/**
* Meldet den Adress-Cache der SwyxTray-App an das Backend, das die Einträge in
* der MongoDB ablegt (Aktualisierung statt Verdopplung, siehe ArchiveService).
@@ -8,34 +92,6 @@ import type { Contact } from './swyx/protocol'
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter.
*/
-/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
-interface StoredAddress {
- number?: string | null
- name?: string | null
- description?: string | null
-}
-
-/**
- * Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab –
- * den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem
- * SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in
- * Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten.
- */
-export async function fetchAddresses(query = ''): Promise {
- const wanted = query.trim()
- const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses'
- const response = await fetch(url)
- if (!response.ok) {
- throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`)
- }
- const entries = (await response.json()) as StoredAddress[]
- return entries.map((entry) => ({
- name: entry.name ?? undefined,
- number: entry.number ?? undefined,
- description: entry.description ?? undefined,
- }))
-}
-
export function reportAddresses(contacts: Contact[]): void {
if (contacts.length === 0) return
void fetch('/api/addresses', {
diff --git a/frontend/src/components/CustomerCallPopup.tsx b/frontend/src/components/CustomerCallPopup.tsx
new file mode 100644
index 0000000..06cf59f
--- /dev/null
+++ b/frontend/src/components/CustomerCallPopup.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useRef } from 'react'
+import type { StoredAddress } from '../addresses'
+
+interface Props {
+ /** Der Kunde aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */
+ customer: StoredAddress
+ onClose: () => void
+}
+
+/**
+ * Popup zu einem eingehenden Anruf, dessen Rufnummer zu einem Kunden in der
+ * Adress-Ablage gehört: zeigt die Kundendaten und – wenn das Fremdsystem eine
+ * Sprungadresse mitgeliefert hat – einen Knopf, der den Auftrag in einem neuen
+ * Tab öffnet. Gleiches Overlay wie der Wartedialog (siehe LoadingDialog zur
+ * Begründung gegen `