From 5a9e16dc1035d307d5cc9d03187360db277c3240 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 16:26:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Anschrift=20unter=20=E2=80=9EAlle=20Kon?= =?UTF-8?q?taktinfos"=20anzeigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jede Kontaktquelle zeigt ihre Anschrift (inkl. Zusatz, Land nur im Ausland), sofern das ERP eine pflegt. Die Anschrift zählt beim Zusammenführen gleicher Quellen mit — gleicher Name an anderer Adresse bleibt eine eigene Karte. API-Client neu generiert. Co-Authored-By: Claude Opus 5.5 --- lib/data/mapper/tour_mapper.dart | 1 + lib/domain/entity/contact_source.dart | 11 ++++ lib/domain/entity/tour_details.dart | 4 ++ .../detail/presentation/steps/step_info.dart | 30 ++++++++++ openapi/holzleitner.json | 24 +++++++- packages/holzleitner_api/doc/ContactSource.md | 1 + .../holzleitner_api/doc/SyncContactSource.md | 1 + .../lib/src/model/contact_source.dart | 23 +++++++- .../lib/src/model/contact_source.g.dart | 56 ++++++++++++++----- .../lib/src/model/sync_contact_source.dart | 21 +++++++ .../lib/src/model/sync_contact_source.g.dart | 15 +++++ test/delivery_recipient_test.dart | 35 ++++++++++++ 12 files changed, 205 insertions(+), 17 deletions(-) diff --git a/lib/data/mapper/tour_mapper.dart b/lib/data/mapper/tour_mapper.dart index 4ff4e57..00cc01b 100644 --- a/lib/data/mapper/tour_mapper.dart +++ b/lib/data/mapper/tour_mapper.dart @@ -117,6 +117,7 @@ extension ApiContactSourceMapper on api.ContactSource { name3: name3, abteilung: abteilung, funktion: funktion, + address: address?.toDomain(), ); } diff --git a/lib/domain/entity/contact_source.dart b/lib/domain/entity/contact_source.dart index a4e260e..0e405e3 100644 --- a/lib/domain/entity/contact_source.dart +++ b/lib/domain/entity/contact_source.dart @@ -1,3 +1,5 @@ +import 'address.dart'; + /// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am /// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt /// das primär als Gruppierungs-Label in der Detail-Ansicht. @@ -95,6 +97,7 @@ class ContactSource { this.name3, this.abteilung, this.funktion, + this.address, }); final String id; @@ -109,6 +112,10 @@ class ContactSource { final String? abteilung; final String? funktion; + /// Anschrift dieses ERP-Adress-Datensatzes; `null`, wenn dort weder + /// Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner). + final Address? address; + /// Zusammengesetzte Anzeige des Namens — Anrede + Titel + Name1..3 in /// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null` /// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn @@ -189,6 +196,7 @@ class MergedContactSource { required this.abteilung, required this.funktion, required this.channels, + this.address, }); /// Alle Rollen, die diesen zusammengeführten Kontakt liefern. @@ -208,6 +216,9 @@ class MergedContactSource { /// liefert (kind + ERP-Position). final List channels; + /// Anschrift, falls im ERP gepflegt (bei allen Quellen der Gruppe gleich). + final Address? address; + /// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName]. String? get displayName { final parts = [ diff --git a/lib/domain/entity/tour_details.dart b/lib/domain/entity/tour_details.dart index ada6d46..f72d1be 100644 --- a/lib/domain/entity/tour_details.dart +++ b/lib/domain/entity/tour_details.dart @@ -211,6 +211,9 @@ class TourDetails { s.name3 ?? '', s.abteilung ?? '', s.funktion ?? '', + // Anschrift gehört zur Identität: gleicher Name an anderer Adresse + // ist ein anderer Datensatz. + s.address?.oneLineWithAddition ?? '', ].join('|'); final sortedChannels = List.of(channels) ..sort((a, b) { @@ -240,6 +243,7 @@ class TourDetails { abteilung: first.abteilung, funktion: first.funktion, channels: channelsOf(first), + address: first.address, ); } diff --git a/lib/feature/delivery/detail/presentation/steps/step_info.dart b/lib/feature/delivery/detail/presentation/steps/step_info.dart index 329923c..19ee519 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_info.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_info.dart @@ -1151,6 +1151,36 @@ class _ContactSourceTile extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), + if (source.address case final address?) ...[ + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.home_outlined, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${address.street} ${address.houseNumber}'.trim(), + style: const TextStyle(fontSize: 14), + ), + Text( + '${address.postalCode} ${address.city}'.trim(), + style: const TextStyle(fontSize: 14), + ), + ..._addressExtras(address, fontSize: 14), + ], + ), + ), + ], + ), + ], if (channels.isEmpty) ...[ const SizedBox(height: 6), Text( diff --git a/openapi/holzleitner.json b/openapi/holzleitner.json index 833aaab..6171aad 100644 --- a/openapi/holzleitner.json +++ b/openapi/holzleitner.json @@ -2353,7 +2353,7 @@ }, "ContactSource": { "type": "object", - "description": "Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs\nan einer Lieferung hing \u2014 Namensblock ohne Anschrift, weil die Adresse\nihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die\neigentlichen Telefonnummern, E-Mails etc. liegen in den\nzugeh\u00f6rigen [`ContactChannel`]s.", + "description": "Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs\nan einer Lieferung hing \u2014 Namensblock plus (falls im ERP gepflegt) die\nAnschrift dieses Datensatzes. Die eigentlichen Telefonnummern, E-Mails\netc. liegen in den zugeh\u00f6rigen [`ContactChannel`]s.", "required": [ "id", "deliveryId", @@ -2366,6 +2366,17 @@ "null" ] }, + "address": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Address", + "description": "Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder\nStra\u00dfe noch Ort gepflegt sind (z. B. reiner Ansprechpartner)." + } + ] + }, "anrede": { "type": [ "string", @@ -3839,6 +3850,17 @@ "null" ] }, + "address": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Address", + "description": "Anschrift dieses Adress-Datensatzes, falls gepflegt." + } + ] + }, "anrede": { "type": [ "string", diff --git a/packages/holzleitner_api/doc/ContactSource.md b/packages/holzleitner_api/doc/ContactSource.md index a3b7284..e96940e 100644 --- a/packages/holzleitner_api/doc/ContactSource.md +++ b/packages/holzleitner_api/doc/ContactSource.md @@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **abteilung** | **String** | | [optional] +**address** | [**Address**](Address.md) | Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner). | [optional] **anrede** | **String** | | [optional] **deliveryId** | **String** | | **funktion** | **String** | | [optional] diff --git a/packages/holzleitner_api/doc/SyncContactSource.md b/packages/holzleitner_api/doc/SyncContactSource.md index 2f7b6eb..a2d0edf 100644 --- a/packages/holzleitner_api/doc/SyncContactSource.md +++ b/packages/holzleitner_api/doc/SyncContactSource.md @@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **abteilung** | **String** | | [optional] +**address** | [**Address**](Address.md) | Anschrift dieses Adress-Datensatzes, falls gepflegt. | [optional] **anrede** | **String** | | [optional] **channels** | [**BuiltList<SyncContactChannel>**](SyncContactChannel.md) | | [optional] **funktion** | **String** | | [optional] diff --git a/packages/holzleitner_api/lib/src/model/contact_source.dart b/packages/holzleitner_api/lib/src/model/contact_source.dart index 14086e3..0a3d23d 100644 --- a/packages/holzleitner_api/lib/src/model/contact_source.dart +++ b/packages/holzleitner_api/lib/src/model/contact_source.dart @@ -3,16 +3,18 @@ // // ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/address.dart'; import 'package:holzleitner_api/src/model/contact_role.dart'; import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; part 'contact_source.g.dart'; -/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die eigentlichen Telefonnummern, E-Mails etc. liegen in den zugehörigen [`ContactChannel`]s. +/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs an einer Lieferung hing — Namensblock plus (falls im ERP gepflegt) die Anschrift dieses Datensatzes. Die eigentlichen Telefonnummern, E-Mails etc. liegen in den zugehörigen [`ContactChannel`]s. /// /// Properties: /// * [abteilung] +/// * [address] - Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner). /// * [anrede] /// * [deliveryId] /// * [funktion] @@ -27,6 +29,10 @@ abstract class ContactSource implements Built { specifiedType: const FullType.nullable(String), ); } + if (object.address != null) { + yield r'address'; + yield serializers.serialize( + object.address, + specifiedType: const FullType.nullable(Address), + ); + } if (object.anrede != null) { yield r'anrede'; yield serializers.serialize( @@ -173,6 +186,14 @@ class _$ContactSourceSerializer implements PrimitiveSerializer { if (valueDes == null) continue; result.abteilung = valueDes; break; + case r'address': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(Address), + ) as Address?; + if (valueDes == null) continue; + result.address.replace(valueDes); + break; case r'anrede': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/contact_source.g.dart b/packages/holzleitner_api/lib/src/model/contact_source.g.dart index 115158c..efd6c1c 100644 --- a/packages/holzleitner_api/lib/src/model/contact_source.g.dart +++ b/packages/holzleitner_api/lib/src/model/contact_source.g.dart @@ -10,6 +10,8 @@ class _$ContactSource extends ContactSource { @override final String? abteilung; @override + final Address? address; + @override final String? anrede; @override final String deliveryId; @@ -33,6 +35,7 @@ class _$ContactSource extends ContactSource { _$ContactSource._( {this.abteilung, + this.address, this.anrede, required this.deliveryId, this.funktion, @@ -55,6 +58,7 @@ class _$ContactSource extends ContactSource { if (identical(other, this)) return true; return other is ContactSource && abteilung == other.abteilung && + address == other.address && anrede == other.anrede && deliveryId == other.deliveryId && funktion == other.funktion && @@ -70,6 +74,7 @@ class _$ContactSource extends ContactSource { int get hashCode { var _$hash = 0; _$hash = $jc(_$hash, abteilung.hashCode); + _$hash = $jc(_$hash, address.hashCode); _$hash = $jc(_$hash, anrede.hashCode); _$hash = $jc(_$hash, deliveryId.hashCode); _$hash = $jc(_$hash, funktion.hashCode); @@ -87,6 +92,7 @@ class _$ContactSource extends ContactSource { String toString() { return (newBuiltValueToStringHelper(r'ContactSource') ..add('abteilung', abteilung) + ..add('address', address) ..add('anrede', anrede) ..add('deliveryId', deliveryId) ..add('funktion', funktion) @@ -108,6 +114,10 @@ class ContactSourceBuilder String? get abteilung => _$this._abteilung; set abteilung(String? abteilung) => _$this._abteilung = abteilung; + AddressBuilder? _address; + AddressBuilder get address => _$this._address ??= AddressBuilder(); + set address(AddressBuilder? address) => _$this._address = address; + String? _anrede; String? get anrede => _$this._anrede; set anrede(String? anrede) => _$this._anrede = anrede; @@ -152,6 +162,7 @@ class ContactSourceBuilder final $v = _$v; if ($v != null) { _abteilung = $v.abteilung; + _address = $v.address?.toBuilder(); _anrede = $v.anrede; _deliveryId = $v.deliveryId; _funktion = $v.funktion; @@ -180,21 +191,36 @@ class ContactSourceBuilder ContactSource build() => _build(); _$ContactSource _build() { - final _$result = _$v ?? - _$ContactSource._( - abteilung: abteilung, - anrede: anrede, - deliveryId: BuiltValueNullFieldError.checkNotNull( - deliveryId, r'ContactSource', 'deliveryId'), - funktion: funktion, - id: BuiltValueNullFieldError.checkNotNull(id, r'ContactSource', 'id'), - name1: name1, - name2: name2, - name3: name3, - role: BuiltValueNullFieldError.checkNotNull( - role, r'ContactSource', 'role'), - titel: titel, - ); + _$ContactSource _$result; + try { + _$result = _$v ?? + _$ContactSource._( + abteilung: abteilung, + address: _address?.build(), + anrede: anrede, + deliveryId: BuiltValueNullFieldError.checkNotNull( + deliveryId, r'ContactSource', 'deliveryId'), + funktion: funktion, + id: BuiltValueNullFieldError.checkNotNull( + id, r'ContactSource', 'id'), + name1: name1, + name2: name2, + name3: name3, + role: BuiltValueNullFieldError.checkNotNull( + role, r'ContactSource', 'role'), + titel: titel, + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'address'; + _address?.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'ContactSource', _$failedField, e.toString()); + } + rethrow; + } replace(_$result); return _$result; } diff --git a/packages/holzleitner_api/lib/src/model/sync_contact_source.dart b/packages/holzleitner_api/lib/src/model/sync_contact_source.dart index 350f1c6..bca4884 100644 --- a/packages/holzleitner_api/lib/src/model/sync_contact_source.dart +++ b/packages/holzleitner_api/lib/src/model/sync_contact_source.dart @@ -3,6 +3,7 @@ // // ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/address.dart'; import 'package:holzleitner_api/src/model/contact_role.dart'; import 'package:built_collection/built_collection.dart'; import 'package:holzleitner_api/src/model/sync_contact_channel.dart'; @@ -15,6 +16,7 @@ part 'sync_contact_source.g.dart'; /// /// Properties: /// * [abteilung] +/// * [address] - Anschrift dieses Adress-Datensatzes, falls gepflegt. /// * [anrede] /// * [channels] /// * [funktion] @@ -28,6 +30,10 @@ abstract class SyncContactSource implements Built? channels; @@ -32,6 +34,7 @@ class _$SyncContactSource extends SyncContactSource { _$SyncContactSource._( {this.abteilung, + this.address, this.anrede, this.channels, this.funktion, @@ -54,6 +57,7 @@ class _$SyncContactSource extends SyncContactSource { if (identical(other, this)) return true; return other is SyncContactSource && abteilung == other.abteilung && + address == other.address && anrede == other.anrede && channels == other.channels && funktion == other.funktion && @@ -68,6 +72,7 @@ class _$SyncContactSource extends SyncContactSource { int get hashCode { var _$hash = 0; _$hash = $jc(_$hash, abteilung.hashCode); + _$hash = $jc(_$hash, address.hashCode); _$hash = $jc(_$hash, anrede.hashCode); _$hash = $jc(_$hash, channels.hashCode); _$hash = $jc(_$hash, funktion.hashCode); @@ -84,6 +89,7 @@ class _$SyncContactSource extends SyncContactSource { String toString() { return (newBuiltValueToStringHelper(r'SyncContactSource') ..add('abteilung', abteilung) + ..add('address', address) ..add('anrede', anrede) ..add('channels', channels) ..add('funktion', funktion) @@ -104,6 +110,10 @@ class SyncContactSourceBuilder String? get abteilung => _$this._abteilung; set abteilung(String? abteilung) => _$this._abteilung = abteilung; + AddressBuilder? _address; + AddressBuilder get address => _$this._address ??= AddressBuilder(); + set address(AddressBuilder? address) => _$this._address = address; + String? _anrede; String? get anrede => _$this._anrede; set anrede(String? anrede) => _$this._anrede = anrede; @@ -146,6 +156,7 @@ class SyncContactSourceBuilder final $v = _$v; if ($v != null) { _abteilung = $v.abteilung; + _address = $v.address?.toBuilder(); _anrede = $v.anrede; _channels = $v.channels?.toBuilder(); _funktion = $v.funktion; @@ -178,6 +189,7 @@ class SyncContactSourceBuilder _$result = _$v ?? _$SyncContactSource._( abteilung: abteilung, + address: _address?.build(), anrede: anrede, channels: _channels?.build(), funktion: funktion, @@ -191,6 +203,9 @@ class SyncContactSourceBuilder } catch (_) { late String _$failedField; try { + _$failedField = 'address'; + _address?.build(); + _$failedField = 'channels'; _channels?.build(); } catch (e) { diff --git a/test/delivery_recipient_test.dart b/test/delivery_recipient_test.dart index 77543be..db76acf 100644 --- a/test/delivery_recipient_test.dart +++ b/test/delivery_recipient_test.dart @@ -185,4 +185,39 @@ void main() { ); }); }); + + test('Kontaktinfos: gleicher Name an anderer Anschrift bleibt getrennt', () { + final details = _details( + deliveryAddress: _ordererAddress, + sources: [ + ContactSource( + id: 'h', + deliveryId: 'd1', + role: ContactRole.header, + name1: 'Kraus Heike', + address: _ordererAddress, + ), + ContactSource( + id: 'm', + deliveryId: 'd1', + role: ContactRole.customerMaster, + name1: 'Kraus Heike', + address: _ordererAddress, + ), + ContactSource( + id: 'b', + deliveryId: 'd1', + role: ContactRole.billing, + name1: 'Kraus Heike', + address: _otherAddress, + ), + ], + ); + final merged = details.mergedContactSourcesOf(details.deliveries.single); + + expect(merged, hasLength(2)); + expect(merged.first.roles, [ContactRole.header, ContactRole.customerMaster]); + expect(merged.first.address, _ordererAddress); + expect(merged.last.address, _otherAddress); + }); }