Jobs typisiert und erweitert, nächtliche Bereinigung, Kunden-Popup bei Anruf

Job-Klassen in Frontend (Job/JobCustomer/JobCourier/JobTour) und Backend
auf die neue Webhook-Form erweitert (url, finished, service, canceled,
global, phone statt number – alte Schreibweise bleibt verstanden, remark
je Station); Stationen legen ihre Kennung wieder als "id" ab. Adressen
tragen zusätzlich role, csc_id, job_ids und url. Ein Zeitplan löscht
jede Nacht um 0 Uhr Jobs, deren ordertime über vier Wochen zurückliegt.
Meldet SwyxIt! einen Anruf, sucht die Startseite die Rufnummer in der
Ablage (Schreibweisen-Abgleich über die Endziffern) und zeigt bei einem
Kunden ein Popup mit dessen Daten; die Auftrags-URL öffnet als Knopf
einen neuen Tab und schließt das Popup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 15:40:20 +02:00
co-authored by Claude Fable 5
parent ee81f25942
commit 3a86bc1eee
16 changed files with 933 additions and 90 deletions
@@ -5,8 +5,11 @@ import de.appcreation.swyxweb.config.WebhookProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
// Zeitpläne wie die nächtliche Job-Bereinigung (JobCleanupService).
@EnableScheduling
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class }) @EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
public class BackendApplication { public class BackendApplication {
@@ -19,29 +19,39 @@ import org.springframework.data.mongodb.core.mapping.Document;
* Einträge ohne Rufnummer werden deshalb übergangen ohne sie ließe sich die * Einträge ohne Rufnummer werden deshalb übergangen ohne sie ließe sich die
* Dublette nicht erkennen. * Dublette nicht erkennen.
* *
* <p>Alle Quellen liefern dieselben drei Felder, nur unterschiedlich verpackt; * <p>{@link #manyFrom(JsonNode)} versteht jede der gelieferten Formen:
* {@link #manyFrom(JsonNode)} versteht jede dieser Formen:
* <ul> * <ul>
* <li>Webhook, ein Eintrag je Aufruf: * <li>Webhook, ein Eintrag je Aufruf:
* {@code {"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}}</li> * {@code {"name":"SYSGEN GmbH","number":"+491602107449","description":"Kunde | …",
* "role":"customer","csc_id":100164,"job_ids":[21891263,21891262],"url":"https://…"}}
* die Felder ab {@code role} kann das Fremdsystem auch weglassen</li>
* <li>eine JSON-Liste solcher Einträge</li> * <li>eine JSON-Liste solcher Einträge</li>
* <li>die Adress-Nachricht der SwyxTray-App: * <li>die Adress-Nachricht der SwyxTray-App (nur Name, Rufnummer, Beschreibung):
* {@code {"addresses":[{"name":…,"number":…,"description":…}, …],"type":"addresses"}}</li> * {@code {"addresses":[{"name":…,"number":…,"description":…}, …],"type":"addresses"}}</li>
* </ul> * </ul>
* *
* @param id Kennung des Dokuments, vergibt die MongoDB * @param id Kennung des Dokuments, vergibt die MongoDB
* @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette * @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)" * @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164" * @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen"
* oder "Kurier | NL Hamburg | PKW | cr 2024" die Herkunft * oder "Kurier | NL Hamburg | PKW"
* eines Eintrags steht damit im Eintrag selbst * @param role Rolle im Fremdsystem, z. B. "customer"
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param jobIds Kennungen der zugehörigen Jobs ({@code job_ids}), passend
* zur Kennung in der Sammlung {@code jobs}
* @param url Sprungadresse des Fremdsystems zu diesem Eintrag, z. B.
* der TAPI-Wrapper zur Rufnummer
*/ */
@Document("addresses") @Document("addresses")
public record AddressEntry( public record AddressEntry(
@Id String id, @Id String id,
String number, String number,
String name, String name,
String description) { String description,
String role,
Integer cscId,
List<Long> jobIds,
String url) {
/** /**
* Liest aus beliebigem JSON alle Adressdaten-Einträge heraus (siehe die * Liest aus beliebigem JSON alle Adressdaten-Einträge heraus (siehe die
@@ -66,7 +76,15 @@ public record AddressEntry(
if (!node.isObject()) return; if (!node.isObject()) return;
String number = text(node, "number"); String number = text(node, "number");
if (number == null) return; if (number == null) return;
entries.add(new AddressEntry(null, number, text(node, "name"), text(node, "description"))); entries.add(new AddressEntry(
null,
number,
text(node, "name"),
text(node, "description"),
text(node, "role"),
integer(node, "csc_id"),
longs(node, "job_ids"),
text(node, "url")));
} }
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */ /** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
@@ -76,4 +94,21 @@ public record AddressEntry(
String text = value.stringValue(""); String text = value.stringValue("");
return text.isBlank() ? null : text; return text.isBlank() ? null : text;
} }
/** Ganzzahl der Nutzlast; alles andere wird {@code null}. */
private static Integer integer(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isIntegralNumber() ? value.intValue() : null;
}
/** Liste von Kennungen; fehlt sie oder ist sie keine Liste, wird sie {@code null}. */
private static List<Long> longs(JsonNode node, String field) {
JsonNode value = node.path(field);
if (!value.isArray()) return null;
List<Long> values = new ArrayList<>();
for (JsonNode element : value) {
if (element.isIntegralNumber()) values.add(element.longValue());
}
return values;
}
} }
@@ -66,6 +66,10 @@ public class ArchiveService {
new Update() new Update()
.set("name", entry.name()) .set("name", entry.name())
.set("description", entry.description()) .set("description", entry.description())
.set("role", entry.role())
.set("cscId", entry.cscId())
.set("jobIds", entry.jobIds())
.set("url", entry.url())
// Altbestand angleichen: Diese Felder wurden früher // Altbestand angleichen: Diese Felder wurden früher
// mitgeschrieben und sollen aus der Sammlung verschwinden. // mitgeschrieben und sollen aus der Sammlung verschwinden.
.unset("receivedAt") .unset("receivedAt")
@@ -101,6 +105,27 @@ public class ArchiveService {
submit("Jobdaten", () -> mongo.save(job)); submit("Jobdaten", () -> mongo.save(job));
} }
/**
* Löscht alle Jobs, deren {@code ordertime} vor der Grenze liegt (Vergleich
* als Text, siehe {@link JobCleanupService}). Läuft anders als die
* Schreibzugriffe direkt: Der Aufrufer ist der nächtliche Zeitplan, der auf
* die Anzahl wartet und Fehler selbst behandelt bekommt hier genügt die
* Warnung im Log.
*
* @return wie viele Jobs gelöscht wurden; 0 auch, wenn die Datenbank nicht
* erreichbar war
*/
public long removeJobsBefore(String cutoffOrdertime) {
try {
return mongo.remove(
new Query(Criteria.where("ordertime").lt(cutoffOrdertime)),
JobEntry.class).getDeletedCount();
} catch (RuntimeException e) {
log.warn("MongoDB: alte Jobs nicht gelöscht ({}).", e.getMessage());
return 0;
}
}
/** Legt Anruf-Ereignisse in der Sammlung {@code calls} ab. */ /** Legt Anruf-Ereignisse in der Sammlung {@code calls} ab. */
public void saveCalls(List<StoredCall> calls) { public void saveCalls(List<StoredCall> calls) {
submit("Anrufdaten", () -> calls.forEach(mongo::save)); submit("Anrufdaten", () -> calls.forEach(mongo::save));
@@ -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.
*
* <p>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"));
}
}
@@ -3,6 +3,7 @@ package de.appcreation.swyxweb.storage;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty; 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.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; 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 * 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. * {@code modified}) ersetzt also seinen alten Stand.
* *
* <p>Die Zeitangaben bleiben die Zeichenketten der Nutzlast * <p>Die Zeitangaben bleiben die Zeichenketten der Nutzlast
* (z. B. {@code 2026-07-31T16:30:00+02:00}): ISO-8601 sortiert auch als Text * (z. B. {@code 2026-08-13T13:35:27}, teils auch mit Zeitzonenversatz):
* richtig, und der Zeitzonenversatz des Fremdsystems geht nicht verloren. * ISO-8601 sortiert auch als Text richtig, und die Schreibweise des
* Fremdsystems geht nicht verloren.
*
* <p>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 id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs * @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 state Zustand des Jobs im Fremdsystem
* @param ordertime Auftragszeit, z. B. "2026-07-31T16:30:00+02:00" * @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
* @param orderdate Auftragsdatum, z. B. "2026-07-31" * @param orderdate Auftragsdatum, z. B. "2026-08-13"
* @param modified letzte Änderung im Fremdsystem * @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 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 * @param tours die Stationen des Jobs, per {@code sort} geordnet
*/ */
@Document("jobs") @Document("jobs")
@@ -41,11 +52,16 @@ import org.springframework.data.mongodb.core.mapping.Document;
public record JobEntry( public record JobEntry(
@Id long id, @Id long id,
Instant receivedAt, Instant receivedAt,
String url,
Integer state, Integer state,
String ordertime, String ordertime,
String orderdate, String orderdate,
String modified, String modified,
String finished,
String vehicle, String vehicle,
String service,
Boolean canceled,
Boolean global,
Customer customer, Customer customer,
Courier courier, Courier courier,
List<Tour> tours) { List<Tour> tours) {
@@ -56,9 +72,14 @@ public record JobEntry(
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id}) * @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param name Firmenname * @param name Firmenname
* @param hq Niederlassung, z. B. "Bremen" * @param hq Niederlassung, z. B. "Bremen"
* @param phone Rufnummer
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @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) {
} }
/** /**
@@ -67,10 +88,14 @@ public record JobEntry(
* @param crId Kurierkennung des Fremdsystems ({@code cr_id}) * @param crId Kurierkennung des Fremdsystems ({@code cr_id})
* @param sid Kurzkennung, z. B. "B1006" * @param sid Kurzkennung, z. B. "B1006"
* @param name Anzeigename * @param name Anzeigename
* @param number Rufnummer * @param phone Rufnummer
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @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 id Kennung des Fremdsystems
* @param sort Reihenfolge innerhalb des Jobs, 1-basiert * @param sort Reihenfolge innerhalb des Jobs, 1-basiert
* @param state Zustand der Station * @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 comp Firma an der Station
* @param person Ansprechperson * @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 street Straße und Hausnummer
* @param zip Postleitzahl * @param zip Postleitzahl
* @param city Ort * @param city Ort
* @param com Bemerkung * @param com Bemerkung
* @param remark Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …)
* @param finished wann die Station abgeschlossen wurde, sonst leer * @param finished wann die Station abgeschlossen wurde, sonst leer
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record Tour( 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 sort,
Integer state, Integer state,
String mode, String mode,
String comp, String comp,
String person, String person,
String number, @JsonAlias("number") String phone,
String street, String street,
String zip, String zip,
String city, String city,
String com, String com,
String remark,
String finished) { String finished) {
} }
@@ -113,7 +142,8 @@ public record JobEntry(
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */ /** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) { public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
JobEntry job = mapper.convertValue(json, JobEntry.class); JobEntry job = mapper.convertValue(json, JobEntry.class);
return new JobEntry(job.id(), receivedAt, job.state(), job.ordertime(), job.orderdate(), return new JobEntry(job.id(), receivedAt, job.url(), job.state(), job.ordertime(),
job.modified(), job.vehicle(), job.customer(), job.courier(), job.tours()); job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
} }
} }
@@ -54,7 +54,7 @@ class AddressControllerTests {
@Test @Test
void listPassesQueryToArchive() throws Exception { void listPassesQueryToArchive() throws Exception {
when(archive.addresses("muster")) 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")) mvc.perform(get("/api/addresses").param("q", "muster"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -27,14 +27,34 @@ class AddressEntryTests {
@Test @Test
void readsSingleWebhookEntry() { void readsSingleWebhookEntry() {
List<AddressEntry> entries = parse(""" List<AddressEntry> entries = parse("""
{"name":"SYSGEN GmbH","number":"+49421409660", {"name":"SYSGEN GmbH","number":"+491602107449",
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"}"""); "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); assertThat(entries).hasSize(1);
AddressEntry entry = entries.getFirst(); AddressEntry entry = entries.getFirst();
assertThat(entry.name()).isEqualTo("SYSGEN GmbH"); assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
assertThat(entry.number()).isEqualTo("+49421409660"); assertThat(entry.number()).isEqualTo("+491602107449");
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"); 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<AddressEntry> 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. */ /** Kuriere und Jobs kommen analog auch als Liste. */
@@ -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);
}
}
@@ -13,7 +13,9 @@ import de.appcreation.swyxweb.storage.JobEntry;
/** /**
* {@link JobEntry} wird aus der Webhook-Nutzlast mit {@code "type":"job"} * {@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 { class JobEntryTests {
@@ -22,21 +24,30 @@ class JobEntryTests {
private static final String SAMPLE = """ private static final String SAMPLE = """
{ {
"type": "job", "type": "job",
"id": 21891253, "id": 21891263,
"state": 2, "url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
"ordertime": "2026-07-31T16:30:00+02:00", "state": 9,
"orderdate": "2026-07-31", "ordertime": "2026-08-13T13:35:27",
"modified": "2026-07-30T18:14:02+02:00", "orderdate": "2026-08-13",
"vehicle": "Transporter", "modified": "2026-08-13T13:35:27",
"customer": { "csc_id": 868374, "name": "B+M isol nord GmbH", "hq": "Bremen" }, "finished": null,
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006", "number": "+4917xxxxxxx" }, "vehicle": "Transporter XL",
"service": null,
"canceled": false,
"global": false,
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
"phone": "+491602107449" },
"courier": null,
"tours": [ "tours": [
{ "id": 22396738, "sort": 1, "state": 1, "mode": "", "comp": "B+M isol nord GmbH", { "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
"person": "", "number": "+4942037010xx", "street": "Löwenhof 2", "zip": "28844", "person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
"city": "Weyhe-Dreye", "com": "", "finished": "2026-07-30T18:13:11+02:00" }, "remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
{ "id": 22396739, "sort": 2, "state": 1, "mode": "", "comp": "Reichspräsident Ebert Kaserne", "zip": "28327", "city": "Bremen", "finished": null },
"person": "", "number": null, "street": "Osdorfer Landstr. 365", "zip": "22589", { "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
"city": "Hamburg", "com": "", "finished": "2026-07-30T18:13:51+02:00" } "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 @Test
void fillsAllFieldsFromSample() { 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); JsonNode json = mapper.readTree(SAMPLE);
JobEntry job = JobEntry.from(json, mapper, receivedAt); JobEntry job = JobEntry.from(json, mapper, receivedAt);
assertThat(job.id()).isEqualTo(21891253L); assertThat(job.id()).isEqualTo(21891263L);
assertThat(job.receivedAt()).isEqualTo(receivedAt); assertThat(job.receivedAt()).isEqualTo(receivedAt);
assertThat(job.state()).isEqualTo(2); assertThat(job.url()).isEqualTo("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263");
assertThat(job.ordertime()).isEqualTo("2026-07-31T16:30:00+02:00"); assertThat(job.state()).isEqualTo(9);
assertThat(job.orderdate()).isEqualTo("2026-07-31"); assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
assertThat(job.vehicle()).isEqualTo("Transporter"); 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().cscId()).isEqualTo(100164);
assertThat(job.customer().name()).isEqualTo("B+M isol nord GmbH"); assertThat(job.customer().name()).isEqualTo("SYSGEN GmbH");
assertThat(job.customer().hq()).isEqualTo("Bremen"); assertThat(job.customer().hq()).isEqualTo("Bremen");
assertThat(job.customer().phone()).isEqualTo("+491602107449");
assertThat(job.courier().crId()).isEqualTo(14116); // Noch kein Kurier zugeteilt.
assertThat(job.courier().sid()).isEqualTo("B1006"); assertThat(job.courier()).isNull();
assertThat(job.courier().number()).isEqualTo("+4917xxxxxxx");
assertThat(job.tours()).hasSize(2); assertThat(job.tours()).hasSize(2);
JobEntry.Tour first = job.tours().getFirst(); JobEntry.Tour first = job.tours().getFirst();
assertThat(first.id()).isEqualTo(22396738L); assertThat(first.id()).isEqualTo(22396758L);
assertThat(first.sort()).isEqualTo(1); assertThat(first.sort()).isEqualTo(1);
assertThat(first.street()).isEqualTo("Löwenhof 2"); assertThat(first.mode()).isEqualTo("del");
assertThat(first.finished()).isEqualTo("2026-07-30T18:13:11+02:00"); 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. // 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");
} }
} }
+63
View File
@@ -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()
})
})
+84 -28
View File
@@ -1,5 +1,89 @@
import type { Contact } from './swyx/protocol' 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<StoredAddress[]> {
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<Contact[]> {
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<StoredAddress | null> {
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 * Meldet den Adress-Cache der SwyxTray-App an das Backend, das die Einträge in
* der MongoDB ablegt (Aktualisierung statt Verdopplung, siehe ArchiveService). * 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 * diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter. * 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<Contact[]> {
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 { export function reportAddresses(contacts: Contact[]): void {
if (contacts.length === 0) return if (contacts.length === 0) return
void fetch('/api/addresses', { void fetch('/api/addresses', {
@@ -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 `<dialog>`).
*/
export default function CustomerCallPopup({ customer, onClose }: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
// Fokus in den Dialog holen, damit Escape sofort greift; der Auftrags-Knopf
// ist ein Link und behält die Bedienung des Browsers (neuer Tab).
useEffect(() => {
closeRef.current?.focus()
}, [])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<div className="overlay">
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="caller-popup-title">
<p className="dialog__status">Eingehender Anruf von</p>
<h2 className="dialog__title" id="caller-popup-title">
{customer.name ?? customer.number}
</h2>
{customer.number && <p className="note">{customer.number}</p>}
{customer.description && <p className="note">{customer.description}</p>}
{customer.cscId != null && <p className="note">Kundennummer {customer.cscId}</p>}
<div className="dialog__actions">
{customer.url && (
<a
className="button button--primary"
href={customer.url}
target="_blank"
rel="noopener noreferrer"
// Der Browser öffnet den neuen Tab; das Popup hat damit seinen
// Zweck erfüllt und schließt sich.
onClick={onClose}
>
Auftrag öffnen
</a>
)}
<button ref={closeRef} type="button" className="button" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
+7
View File
@@ -287,6 +287,13 @@ code {
cursor: pointer; cursor: pointer;
} }
/* Auch Links treten als Knopf auf (z. B. „Auftrag öffnen" im Anruf-Popup). */
a.button {
display: inline-flex;
align-items: center;
text-decoration: none;
}
.button:hover:not(:disabled) { .button:hover:not(:disabled) {
background: var(--brand-soft); background: var(--brand-soft);
} }
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { Job } from './jobs'
/**
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java)
* beide Seiten verstehen denselben Webhook.
*/
const SAMPLE = `{
"type": "job",
"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": 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 }
]
}`
describe('Job.isJob', () => {
it('erkennt die Job-Nutzlast am Kennzeichen "type":"job"', () => {
expect(Job.isJob(JSON.parse(SAMPLE))).toBe(true)
expect(Job.isJob({ name: 'SYSGEN GmbH' })).toBe(false)
expect(Job.isJob({ type: 'addresses', addresses: [] })).toBe(false)
expect(Job.isJob(null)).toBe(false)
})
})
describe('Job.fromJson', () => {
it('füllt alle Felder aus der Beispiel-Nutzlast', () => {
const job = Job.fromJson(JSON.parse(SAMPLE))
expect(job.id).toBe(21891263)
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
expect(job.state).toBe(9)
expect(job.ordertime).toBe('2026-08-13T13:35:27')
expect(job.orderdate).toBe('2026-08-13')
expect(job.finished).toBeUndefined()
expect(job.vehicle).toBe('Transporter XL')
expect(job.service).toBeUndefined()
expect(job.canceled).toBe(false)
expect(job.global).toBe(false)
expect(job.customer?.cscId).toBe(100164)
expect(job.customer?.name).toBe('SYSGEN GmbH')
expect(job.customer?.hq).toBe('Bremen')
expect(job.customer?.phone).toBe('+491602107449')
// Noch kein Kurier zugeteilt (in der Nutzlast null).
expect(job.courier).toBeUndefined()
expect(job.tours).toHaveLength(2)
const first = job.tours[0]
expect(first.id).toBe(22396758)
expect(first.sort).toBe(1)
expect(first.mode).toBe('del')
expect(first.person).toBe('Frau Hoffmann')
expect(first.phone).toBe('+49421409660')
expect(first.remark).toContain('Hebebühne benötigt.')
expect(first.street).toBe('Am Hallacker 48')
// Fehlende Rufnummer und leere Felder der zweiten Station bleiben undefined.
expect(job.tours[1].phone).toBeUndefined()
expect(job.tours[1].city).toBe("'s-Hertogenbosch")
})
it('versteht die ältere Form mit "number" statt "phone"', () => {
const job = Job.fromJson({
type: 'job',
id: 21891253,
courier: { cr_id: 14116, sid: 'B1006', name: 'CA Kurier B1006', number: '+4917xxxxxxx' },
tours: [{ id: 22396738, sort: 1, number: '+4942037010xx' }],
})
expect(job.courier?.phone).toBe('+4917xxxxxxx')
expect(job.tours[0].phone).toBe('+4942037010xx')
})
it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => {
const bare = Job.fromJson({ id: 4711 })
expect(bare.id).toBe(4711)
expect(bare.customer).toBeUndefined()
expect(bare.tours).toEqual([])
expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/)
expect(() => Job.fromJson('kein Objekt')).toThrowError(/"id" fehlt/)
})
})
describe('Job.toJson', () => {
it('liefert wieder die Form des Webhooks', () => {
const job = Job.fromJson(JSON.parse(SAMPLE))
const json = job.toJson()
expect(json.type).toBe('job')
expect(json.id).toBe(21891263)
expect(json.canceled).toBe(false)
expect((json.customer as Record<string, unknown>).csc_id).toBe(100164)
expect((json.customer as Record<string, unknown>).phone).toBe('+491602107449')
expect((json.tours as unknown[]).length).toBe(2)
// Einmal hin und zurück ändert nichts mehr.
expect(Job.fromJson(json)).toEqual(job)
})
})
+255
View File
@@ -0,0 +1,255 @@
/**
* Jobs (Kurieraufträge), wie sie das Fremdsystem über den Webhook schickt
* (`POST /api/webhook/jobs` bzw. `"type":"job"` am generischen Webhook) und wie
* sie das Backend in der MongoDB-Sammlung `jobs` ablegt (JobEntry.java).
*
* Die Klasse {@link Job} ist das typisierte Gegenstück im Browser:
* {@link Job.fromJson} übernimmt beliebiges JSON und liefert einen geprüften
* Job unbekannte Felder werden übergangen, fehlende bleiben `undefined`.
* {@link Job.toJson} liefert wieder die Form des Webhooks.
*
* Die Zeitangaben bleiben die Zeichenketten der Nutzlast
* (z. B. `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 `number` statt
* `phone` beide Schreibweisen werden angenommen.
*/
/** Zeichenkette der Nutzlast; leer, `null` oder fehlend wird `undefined`. */
function text(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
/** Ganzzahl der Nutzlast; alles andere wird `undefined`. */
function integer(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
/** Wahrheitswert der Nutzlast; alles andere wird `undefined`. */
function bool(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
/** Der beauftragende Kunde. */
export class JobCustomer {
constructor(
/** Kundenkennung des Fremdsystems (`csc_id`). */
readonly cscId?: number,
/** Firmenname. */
readonly name?: string,
/** Niederlassung, z. B. "Bremen". */
readonly hq?: string,
/** Rufnummer. */
readonly phone?: string,
) {}
static fromJson(value: unknown): JobCustomer {
const json = record(value)
return new JobCustomer(
integer(json.csc_id),
text(json.name),
text(json.hq),
text(json.phone) ?? text(json.number),
)
}
toJson(): Record<string, unknown> {
return { csc_id: this.cscId, name: this.name, hq: this.hq, phone: this.phone }
}
}
/** Der ausführende Kurier. */
export class JobCourier {
constructor(
/** Kurierkennung des Fremdsystems (`cr_id`). */
readonly crId?: number,
/** Kurzkennung, z. B. "B1006". */
readonly sid?: string,
/** Anzeigename. */
readonly name?: string,
/** Rufnummer. */
readonly phone?: string,
) {}
static fromJson(value: unknown): JobCourier {
const json = record(value)
return new JobCourier(
integer(json.cr_id),
text(json.sid),
text(json.name),
text(json.phone) ?? text(json.number),
)
}
toJson(): Record<string, unknown> {
return { cr_id: this.crId, sid: this.sid, name: this.name, phone: this.phone }
}
}
/** Eine Station des Jobs. */
export class JobTour {
constructor(
/** Kennung des Fremdsystems. */
readonly id?: number,
/** Reihenfolge innerhalb des Jobs, 1-basiert. */
readonly sort?: number,
/** Zustand der Station. */
readonly state?: number,
/** Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung). */
readonly mode?: string,
/** Firma an der Station. */
readonly comp?: string,
/** Ansprechperson. */
readonly person?: string,
/** Rufnummer an der Station, kann fehlen. */
readonly phone?: string,
/** Straße und Hausnummer. */
readonly street?: string,
/** Postleitzahl. */
readonly zip?: string,
/** Ort. */
readonly city?: string,
/** Bemerkung. */
readonly com?: string,
/** Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …). */
readonly remark?: string,
/** Wann die Station abgeschlossen wurde, sonst leer. */
readonly finished?: string,
) {}
static fromJson(value: unknown): JobTour {
const json = record(value)
return new JobTour(
integer(json.id),
integer(json.sort),
integer(json.state),
text(json.mode),
text(json.comp),
text(json.person),
text(json.phone) ?? text(json.number),
text(json.street),
text(json.zip),
text(json.city),
text(json.com),
text(json.remark),
text(json.finished),
)
}
toJson(): Record<string, unknown> {
return {
id: this.id,
sort: this.sort,
state: this.state,
mode: this.mode,
comp: this.comp,
person: this.person,
phone: this.phone,
street: this.street,
zip: this.zip,
city: this.city,
com: this.com,
remark: this.remark,
finished: this.finished,
}
}
}
/** Ein Job (Kurierauftrag) mit Kunde, Kurier und Stationen. */
export class Job {
constructor(
/** Kennung des Fremdsystems im Backend zugleich Schlüssel der Sammlung. */
readonly id: number,
/** Sprungadresse des Fremdsystems zur Auftragsansicht. */
readonly url?: string,
/** Zustand des Jobs im Fremdsystem. */
readonly state?: number,
/** Auftragszeit, z. B. "2026-08-13T13:35:27". */
readonly ordertime?: string,
/** Auftragsdatum, z. B. "2026-08-13". */
readonly orderdate?: string,
/** Letzte Änderung im Fremdsystem. */
readonly modified?: string,
/** Wann der Job abgeschlossen wurde, sonst leer. */
readonly finished?: string,
/** Fahrzeugart, z. B. "Transporter XL". */
readonly vehicle?: string,
/** Gebuchte Leistung, kann fehlen. */
readonly service?: string,
/** Ist der Job storniert? */
readonly canceled?: boolean,
/** Bundesweite Vermittlung? */
readonly global?: boolean,
/** Der beauftragende Kunde. */
readonly customer?: JobCustomer,
/** Der ausführende Kurier, solange keiner zugeteilt ist `undefined`. */
readonly courier?: JobCourier,
/** Die Stationen des Jobs, in der Reihenfolge der Nutzlast (`sort`). */
readonly tours: JobTour[] = [],
) {}
/** Trägt die Nutzlast das Kennzeichen `"type":"job"`? */
static isJob(value: unknown): boolean {
return record(value).type === 'job'
}
/**
* Füllt die Klasse aus der Webhook-Nutzlast.
*
* @throws wenn die Kennung fehlt ohne sie ist es kein Job
*/
static fromJson(value: unknown): Job {
const json = record(value)
const id = integer(json.id)
if (id === undefined) {
throw new Error('Nutzlast ist kein Job das Feld "id" fehlt.')
}
const tours = Array.isArray(json.tours) ? json.tours.map(JobTour.fromJson) : []
// `null` heißt: noch kein Kurier zugeteilt dann bleibt es undefined.
const hasCustomer = json.customer !== undefined && json.customer !== null
const hasCourier = json.courier !== undefined && json.courier !== null
return new Job(
id,
text(json.url),
integer(json.state),
text(json.ordertime),
text(json.orderdate),
text(json.modified),
text(json.finished),
text(json.vehicle),
text(json.service),
bool(json.canceled),
bool(json.global),
hasCustomer ? JobCustomer.fromJson(json.customer) : undefined,
hasCourier ? JobCourier.fromJson(json.courier) : undefined,
tours,
)
}
/** Die Form des Webhooks, z. B. zum Weiterreichen oder Anzeigen. */
toJson(): Record<string, unknown> {
return {
type: 'job',
id: this.id,
url: this.url,
state: this.state,
ordertime: this.ordertime,
orderdate: this.orderdate,
modified: this.modified,
finished: this.finished,
vehicle: this.vehicle,
service: this.service,
canceled: this.canceled,
global: this.global,
customer: this.customer?.toJson(),
courier: this.courier?.toJson(),
tours: this.tours.map((tour) => tour.toJson()),
}
}
}
+34
View File
@@ -5,7 +5,9 @@ import { useCallNotifications } from '../hooks/useCallNotifications'
import { useWebhook } from '../hooks/useWebhook' import { useWebhook } from '../hooks/useWebhook'
import { countUnseen } from '../webhook' import { countUnseen } from '../webhook'
import { applyBrand, loadBrand, type Brand } from '../brand' import { applyBrand, loadBrand, type Brand } from '../brand'
import { findCustomerByNumber, type StoredAddress } from '../addresses'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import CustomerCallPopup from '../components/CustomerCallPopup'
import StadtboteSignet from '../components/StadtboteSignet' import StadtboteSignet from '../components/StadtboteSignet'
import HansetransSignet from '../components/HansetransSignet' import HansetransSignet from '../components/HansetransSignet'
import MessageLog from '../components/MessageLog' import MessageLog from '../components/MessageLog'
@@ -51,6 +53,36 @@ export default function HomePage() {
const { permission, requestPermission } = useCallNotifications(ringingCall) const { permission, requestPermission } = useCallNotifications(ringingCall)
// Meldet SwyxIt! einen eingehenden Anruf, wird die Rufnummer in der
// Adress-Ablage gesucht; gehört sie einem Kunden, geht das Popup mit dessen
// Daten auf. Je Anruf nur eine Abfrage der Snapshot kommt mehrfach.
const [caller, setCaller] = useState<StoredAddress | null>(null)
const lookedUpRef = useRef<string | null>(null)
useEffect(() => {
if (!ringingCall) {
// Anruf vorbei: Der nächste auch von derselben Nummer zählt neu.
lookedUpRef.current = null
return
}
const number = ringingCall.peerNumber?.trim()
if (!number) return
const key = `${ringingCall.line}:${number}`
if (lookedUpRef.current === key) return
lookedUpRef.current = key
let stale = false
findCustomerByNumber(number)
.then((match) => {
if (!stale && match) setCaller(match)
})
.catch(() => {
// Ablage nicht erreichbar dann eben kein Popup; der Anruf selbst
// wird ohnehin über die Anrufkarte gemeldet.
})
return () => {
stale = true
}
}, [ringingCall])
// Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht // Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht
// an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein // an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
// anderer Bereich offen ist. // anderer Bereich offen ist.
@@ -316,6 +348,8 @@ export default function HomePage() {
</section> </section>
</TabPanel> </TabPanel>
</main> </main>
{caller && <CustomerCallPopup customer={caller} onClose={() => setCaller(null)} />}
</div> </div>
) )
} }