feat: Anschrift unter „Alle Kontaktinfos" anzeigen

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 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-25 16:26:01 +02:00
parent 335cf02a9f
commit 5a9e16dc10
12 changed files with 205 additions and 17 deletions

View File

@ -117,6 +117,7 @@ extension ApiContactSourceMapper on api.ContactSource {
name3: name3, name3: name3,
abteilung: abteilung, abteilung: abteilung,
funktion: funktion, funktion: funktion,
address: address?.toDomain(),
); );
} }

View File

@ -1,3 +1,5 @@
import 'address.dart';
/// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am /// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am
/// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt /// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt
/// das primär als Gruppierungs-Label in der Detail-Ansicht. /// das primär als Gruppierungs-Label in der Detail-Ansicht.
@ -95,6 +97,7 @@ class ContactSource {
this.name3, this.name3,
this.abteilung, this.abteilung,
this.funktion, this.funktion,
this.address,
}); });
final String id; final String id;
@ -109,6 +112,10 @@ class ContactSource {
final String? abteilung; final String? abteilung;
final String? funktion; 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 /// Zusammengesetzte Anzeige des Namens — Anrede + Titel + Name1..3 in
/// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null` /// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null`
/// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn /// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn
@ -189,6 +196,7 @@ class MergedContactSource {
required this.abteilung, required this.abteilung,
required this.funktion, required this.funktion,
required this.channels, required this.channels,
this.address,
}); });
/// Alle Rollen, die diesen zusammengeführten Kontakt liefern. /// Alle Rollen, die diesen zusammengeführten Kontakt liefern.
@ -208,6 +216,9 @@ class MergedContactSource {
/// liefert (kind + ERP-Position). /// liefert (kind + ERP-Position).
final List<ContactChannel> channels; final List<ContactChannel> channels;
/// Anschrift, falls im ERP gepflegt (bei allen Quellen der Gruppe gleich).
final Address? address;
/// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName]. /// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName].
String? get displayName { String? get displayName {
final parts = <String>[ final parts = <String>[

View File

@ -211,6 +211,9 @@ class TourDetails {
s.name3 ?? '', s.name3 ?? '',
s.abteilung ?? '', s.abteilung ?? '',
s.funktion ?? '', s.funktion ?? '',
// Anschrift gehört zur Identität: gleicher Name an anderer Adresse
// ist ein anderer Datensatz.
s.address?.oneLineWithAddition ?? '',
].join('|'); ].join('|');
final sortedChannels = List<ContactChannel>.of(channels) final sortedChannels = List<ContactChannel>.of(channels)
..sort((a, b) { ..sort((a, b) {
@ -240,6 +243,7 @@ class TourDetails {
abteilung: first.abteilung, abteilung: first.abteilung,
funktion: first.funktion, funktion: first.funktion,
channels: channelsOf(first), channels: channelsOf(first),
address: first.address,
); );
} }

View File

@ -1151,6 +1151,36 @@ class _ContactSourceTile extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant, 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) ...[ if (channels.isEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(

View File

@ -2353,7 +2353,7 @@
}, },
"ContactSource": { "ContactSource": {
"type": "object", "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": [ "required": [
"id", "id",
"deliveryId", "deliveryId",
@ -2366,6 +2366,17 @@
"null" "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": { "anrede": {
"type": [ "type": [
"string", "string",
@ -3839,6 +3850,17 @@
"null" "null"
] ]
}, },
"address": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Address",
"description": "Anschrift dieses Adress-Datensatzes, falls gepflegt."
}
]
},
"anrede": { "anrede": {
"type": [ "type": [
"string", "string",

View File

@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart';
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**abteilung** | **String** | | [optional] **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] **anrede** | **String** | | [optional]
**deliveryId** | **String** | | **deliveryId** | **String** | |
**funktion** | **String** | | [optional] **funktion** | **String** | | [optional]

View File

@ -9,6 +9,7 @@ import 'package:holzleitner_api/api.dart';
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**abteilung** | **String** | | [optional] **abteilung** | **String** | | [optional]
**address** | [**Address**](Address.md) | Anschrift dieses Adress-Datensatzes, falls gepflegt. | [optional]
**anrede** | **String** | | [optional] **anrede** | **String** | | [optional]
**channels** | [**BuiltList&lt;SyncContactChannel&gt;**](SyncContactChannel.md) | | [optional] **channels** | [**BuiltList&lt;SyncContactChannel&gt;**](SyncContactChannel.md) | | [optional]
**funktion** | **String** | | [optional] **funktion** | **String** | | [optional]

View File

@ -3,16 +3,18 @@
// //
// ignore_for_file: unused_element // ignore_for_file: unused_element
import 'package:holzleitner_api/src/model/address.dart';
import 'package:holzleitner_api/src/model/contact_role.dart'; import 'package:holzleitner_api/src/model/contact_role.dart';
import 'package:built_value/built_value.dart'; import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart'; import 'package:built_value/serializer.dart';
part 'contact_source.g.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: /// Properties:
/// * [abteilung] /// * [abteilung]
/// * [address] - Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner).
/// * [anrede] /// * [anrede]
/// * [deliveryId] /// * [deliveryId]
/// * [funktion] /// * [funktion]
@ -27,6 +29,10 @@ abstract class ContactSource implements Built<ContactSource, ContactSourceBuilde
@BuiltValueField(wireName: r'abteilung') @BuiltValueField(wireName: r'abteilung')
String? get abteilung; String? get abteilung;
/// Anschrift dieses Adress-Datensatzes; `None`, wenn im ERP weder Straße noch Ort gepflegt sind (z. B. reiner Ansprechpartner).
@BuiltValueField(wireName: r'address')
Address? get address;
@BuiltValueField(wireName: r'anrede') @BuiltValueField(wireName: r'anrede')
String? get anrede; String? get anrede;
@ -85,6 +91,13 @@ class _$ContactSourceSerializer implements PrimitiveSerializer<ContactSource> {
specifiedType: const FullType.nullable(String), 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) { if (object.anrede != null) {
yield r'anrede'; yield r'anrede';
yield serializers.serialize( yield serializers.serialize(
@ -173,6 +186,14 @@ class _$ContactSourceSerializer implements PrimitiveSerializer<ContactSource> {
if (valueDes == null) continue; if (valueDes == null) continue;
result.abteilung = valueDes; result.abteilung = valueDes;
break; 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': case r'anrede':
final valueDes = serializers.deserialize( final valueDes = serializers.deserialize(
value, value,

View File

@ -10,6 +10,8 @@ class _$ContactSource extends ContactSource {
@override @override
final String? abteilung; final String? abteilung;
@override @override
final Address? address;
@override
final String? anrede; final String? anrede;
@override @override
final String deliveryId; final String deliveryId;
@ -33,6 +35,7 @@ class _$ContactSource extends ContactSource {
_$ContactSource._( _$ContactSource._(
{this.abteilung, {this.abteilung,
this.address,
this.anrede, this.anrede,
required this.deliveryId, required this.deliveryId,
this.funktion, this.funktion,
@ -55,6 +58,7 @@ class _$ContactSource extends ContactSource {
if (identical(other, this)) return true; if (identical(other, this)) return true;
return other is ContactSource && return other is ContactSource &&
abteilung == other.abteilung && abteilung == other.abteilung &&
address == other.address &&
anrede == other.anrede && anrede == other.anrede &&
deliveryId == other.deliveryId && deliveryId == other.deliveryId &&
funktion == other.funktion && funktion == other.funktion &&
@ -70,6 +74,7 @@ class _$ContactSource extends ContactSource {
int get hashCode { int get hashCode {
var _$hash = 0; var _$hash = 0;
_$hash = $jc(_$hash, abteilung.hashCode); _$hash = $jc(_$hash, abteilung.hashCode);
_$hash = $jc(_$hash, address.hashCode);
_$hash = $jc(_$hash, anrede.hashCode); _$hash = $jc(_$hash, anrede.hashCode);
_$hash = $jc(_$hash, deliveryId.hashCode); _$hash = $jc(_$hash, deliveryId.hashCode);
_$hash = $jc(_$hash, funktion.hashCode); _$hash = $jc(_$hash, funktion.hashCode);
@ -87,6 +92,7 @@ class _$ContactSource extends ContactSource {
String toString() { String toString() {
return (newBuiltValueToStringHelper(r'ContactSource') return (newBuiltValueToStringHelper(r'ContactSource')
..add('abteilung', abteilung) ..add('abteilung', abteilung)
..add('address', address)
..add('anrede', anrede) ..add('anrede', anrede)
..add('deliveryId', deliveryId) ..add('deliveryId', deliveryId)
..add('funktion', funktion) ..add('funktion', funktion)
@ -108,6 +114,10 @@ class ContactSourceBuilder
String? get abteilung => _$this._abteilung; String? get abteilung => _$this._abteilung;
set abteilung(String? abteilung) => _$this._abteilung = 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? _anrede;
String? get anrede => _$this._anrede; String? get anrede => _$this._anrede;
set anrede(String? anrede) => _$this._anrede = anrede; set anrede(String? anrede) => _$this._anrede = anrede;
@ -152,6 +162,7 @@ class ContactSourceBuilder
final $v = _$v; final $v = _$v;
if ($v != null) { if ($v != null) {
_abteilung = $v.abteilung; _abteilung = $v.abteilung;
_address = $v.address?.toBuilder();
_anrede = $v.anrede; _anrede = $v.anrede;
_deliveryId = $v.deliveryId; _deliveryId = $v.deliveryId;
_funktion = $v.funktion; _funktion = $v.funktion;
@ -180,21 +191,36 @@ class ContactSourceBuilder
ContactSource build() => _build(); ContactSource build() => _build();
_$ContactSource _build() { _$ContactSource _build() {
final _$result = _$v ?? _$ContactSource _$result;
_$ContactSource._( try {
abteilung: abteilung, _$result = _$v ??
anrede: anrede, _$ContactSource._(
deliveryId: BuiltValueNullFieldError.checkNotNull( abteilung: abteilung,
deliveryId, r'ContactSource', 'deliveryId'), address: _address?.build(),
funktion: funktion, anrede: anrede,
id: BuiltValueNullFieldError.checkNotNull(id, r'ContactSource', 'id'), deliveryId: BuiltValueNullFieldError.checkNotNull(
name1: name1, deliveryId, r'ContactSource', 'deliveryId'),
name2: name2, funktion: funktion,
name3: name3, id: BuiltValueNullFieldError.checkNotNull(
role: BuiltValueNullFieldError.checkNotNull( id, r'ContactSource', 'id'),
role, r'ContactSource', 'role'), name1: name1,
titel: titel, 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); replace(_$result);
return _$result; return _$result;
} }

View File

@ -3,6 +3,7 @@
// //
// ignore_for_file: unused_element // ignore_for_file: unused_element
import 'package:holzleitner_api/src/model/address.dart';
import 'package:holzleitner_api/src/model/contact_role.dart'; import 'package:holzleitner_api/src/model/contact_role.dart';
import 'package:built_collection/built_collection.dart'; import 'package:built_collection/built_collection.dart';
import 'package:holzleitner_api/src/model/sync_contact_channel.dart'; import 'package:holzleitner_api/src/model/sync_contact_channel.dart';
@ -15,6 +16,7 @@ part 'sync_contact_source.g.dart';
/// ///
/// Properties: /// Properties:
/// * [abteilung] /// * [abteilung]
/// * [address] - Anschrift dieses Adress-Datensatzes, falls gepflegt.
/// * [anrede] /// * [anrede]
/// * [channels] /// * [channels]
/// * [funktion] /// * [funktion]
@ -28,6 +30,10 @@ abstract class SyncContactSource implements Built<SyncContactSource, SyncContact
@BuiltValueField(wireName: r'abteilung') @BuiltValueField(wireName: r'abteilung')
String? get abteilung; String? get abteilung;
/// Anschrift dieses Adress-Datensatzes, falls gepflegt.
@BuiltValueField(wireName: r'address')
Address? get address;
@BuiltValueField(wireName: r'anrede') @BuiltValueField(wireName: r'anrede')
String? get anrede; String? get anrede;
@ -83,6 +89,13 @@ class _$SyncContactSourceSerializer implements PrimitiveSerializer<SyncContactSo
specifiedType: const FullType.nullable(String), 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) { if (object.anrede != null) {
yield r'anrede'; yield r'anrede';
yield serializers.serialize( yield serializers.serialize(
@ -168,6 +181,14 @@ class _$SyncContactSourceSerializer implements PrimitiveSerializer<SyncContactSo
if (valueDes == null) continue; if (valueDes == null) continue;
result.abteilung = valueDes; result.abteilung = valueDes;
break; 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': case r'anrede':
final valueDes = serializers.deserialize( final valueDes = serializers.deserialize(
value, value,

View File

@ -10,6 +10,8 @@ class _$SyncContactSource extends SyncContactSource {
@override @override
final String? abteilung; final String? abteilung;
@override @override
final Address? address;
@override
final String? anrede; final String? anrede;
@override @override
final BuiltList<SyncContactChannel>? channels; final BuiltList<SyncContactChannel>? channels;
@ -32,6 +34,7 @@ class _$SyncContactSource extends SyncContactSource {
_$SyncContactSource._( _$SyncContactSource._(
{this.abteilung, {this.abteilung,
this.address,
this.anrede, this.anrede,
this.channels, this.channels,
this.funktion, this.funktion,
@ -54,6 +57,7 @@ class _$SyncContactSource extends SyncContactSource {
if (identical(other, this)) return true; if (identical(other, this)) return true;
return other is SyncContactSource && return other is SyncContactSource &&
abteilung == other.abteilung && abteilung == other.abteilung &&
address == other.address &&
anrede == other.anrede && anrede == other.anrede &&
channels == other.channels && channels == other.channels &&
funktion == other.funktion && funktion == other.funktion &&
@ -68,6 +72,7 @@ class _$SyncContactSource extends SyncContactSource {
int get hashCode { int get hashCode {
var _$hash = 0; var _$hash = 0;
_$hash = $jc(_$hash, abteilung.hashCode); _$hash = $jc(_$hash, abteilung.hashCode);
_$hash = $jc(_$hash, address.hashCode);
_$hash = $jc(_$hash, anrede.hashCode); _$hash = $jc(_$hash, anrede.hashCode);
_$hash = $jc(_$hash, channels.hashCode); _$hash = $jc(_$hash, channels.hashCode);
_$hash = $jc(_$hash, funktion.hashCode); _$hash = $jc(_$hash, funktion.hashCode);
@ -84,6 +89,7 @@ class _$SyncContactSource extends SyncContactSource {
String toString() { String toString() {
return (newBuiltValueToStringHelper(r'SyncContactSource') return (newBuiltValueToStringHelper(r'SyncContactSource')
..add('abteilung', abteilung) ..add('abteilung', abteilung)
..add('address', address)
..add('anrede', anrede) ..add('anrede', anrede)
..add('channels', channels) ..add('channels', channels)
..add('funktion', funktion) ..add('funktion', funktion)
@ -104,6 +110,10 @@ class SyncContactSourceBuilder
String? get abteilung => _$this._abteilung; String? get abteilung => _$this._abteilung;
set abteilung(String? abteilung) => _$this._abteilung = 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? _anrede;
String? get anrede => _$this._anrede; String? get anrede => _$this._anrede;
set anrede(String? anrede) => _$this._anrede = anrede; set anrede(String? anrede) => _$this._anrede = anrede;
@ -146,6 +156,7 @@ class SyncContactSourceBuilder
final $v = _$v; final $v = _$v;
if ($v != null) { if ($v != null) {
_abteilung = $v.abteilung; _abteilung = $v.abteilung;
_address = $v.address?.toBuilder();
_anrede = $v.anrede; _anrede = $v.anrede;
_channels = $v.channels?.toBuilder(); _channels = $v.channels?.toBuilder();
_funktion = $v.funktion; _funktion = $v.funktion;
@ -178,6 +189,7 @@ class SyncContactSourceBuilder
_$result = _$v ?? _$result = _$v ??
_$SyncContactSource._( _$SyncContactSource._(
abteilung: abteilung, abteilung: abteilung,
address: _address?.build(),
anrede: anrede, anrede: anrede,
channels: _channels?.build(), channels: _channels?.build(),
funktion: funktion, funktion: funktion,
@ -191,6 +203,9 @@ class SyncContactSourceBuilder
} catch (_) { } catch (_) {
late String _$failedField; late String _$failedField;
try { try {
_$failedField = 'address';
_address?.build();
_$failedField = 'channels'; _$failedField = 'channels';
_channels?.build(); _channels?.build();
} catch (e) { } catch (e) {

View File

@ -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);
});
} }