@bytebrand/export-cars 1.26.31 → 1.26.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/configs/autouncle.config.d.ts +14 -0
  2. package/dist/configs/autouncle.config.js +36 -3
  3. package/dist/configs/autouncle.config.js.map +1 -1
  4. package/dist/configs/meinstadt.config.d.ts +15 -0
  5. package/dist/configs/meinstadt.config.js +42 -3
  6. package/dist/configs/meinstadt.config.js.map +1 -1
  7. package/dist/constants/autouncle.contants.d.ts +1 -3
  8. package/dist/constants/autouncle.contants.js +2 -4
  9. package/dist/constants/autouncle.contants.js.map +1 -1
  10. package/dist/exporters/autouncle.exporter.d.ts +3 -2
  11. package/dist/exporters/autouncle.exporter.js +45 -27
  12. package/dist/exporters/autouncle.exporter.js.map +1 -1
  13. package/dist/exporters/meinstadt.exporter.d.ts +3 -2
  14. package/dist/exporters/meinstadt.exporter.js +40 -23
  15. package/dist/exporters/meinstadt.exporter.js.map +1 -1
  16. package/dist/formats/autouncle-json.builder.js +1 -4
  17. package/dist/formats/autouncle-json.builder.js.map +1 -1
  18. package/dist/interfaces/export-item.interface.d.ts +8 -12
  19. package/dist/interfaces/openai-ads.interface.d.ts +2 -1
  20. package/dist/interfaces/openai-ads.interface.js +1 -0
  21. package/dist/interfaces/openai-ads.interface.js.map +1 -1
  22. package/dist/interfaces/params.interface.d.ts +6 -0
  23. package/dist/services/autouncle-feed-run.d.ts +66 -0
  24. package/dist/services/autouncle-feed-run.js +135 -0
  25. package/dist/services/autouncle-feed-run.js.map +1 -0
  26. package/dist/services/meinstadt-feed-run.d.ts +81 -0
  27. package/dist/services/meinstadt-feed-run.js +166 -0
  28. package/dist/services/meinstadt-feed-run.js.map +1 -0
  29. package/dist/services/openai-ads-feed-run.d.ts +1 -0
  30. package/dist/services/openai-ads-feed-run.js +4 -0
  31. package/dist/services/openai-ads-feed-run.js.map +1 -1
  32. package/dist/transformers/autouncle.transformer.d.ts +38 -22
  33. package/dist/transformers/autouncle.transformer.js +156 -144
  34. package/dist/transformers/autouncle.transformer.js.map +1 -1
  35. package/dist/transformers/meinstadt.transformer.d.ts +17 -13
  36. package/dist/transformers/meinstadt.transformer.js +88 -61
  37. package/dist/transformers/meinstadt.transformer.js.map +1 -1
  38. package/dist/transformers/openai-ads-finance.d.ts +2 -0
  39. package/dist/transformers/openai-ads-finance.js +39 -0
  40. package/dist/transformers/openai-ads-finance.js.map +1 -0
  41. package/dist/transformers/openai-ads.transformer.d.ts +9 -1
  42. package/dist/transformers/openai-ads.transformer.js +25 -5
  43. package/dist/transformers/openai-ads.transformer.js.map +1 -1
  44. package/dist/tsconfig.build.tsbuildinfo +1 -1
  45. package/docs/autouncle/AUDIT.md +97 -0
  46. package/docs/autouncle/PARTNER-QUESTIONS.md +53 -0
  47. package/docs/autouncle/RUNBOOK.md +64 -0
  48. package/docs/meinestadt/AUDIT.md +146 -0
  49. package/docs/meinestadt/PARTNER-QUESTIONS.md +54 -0
  50. package/docs/meinestadt/RUNBOOK.md +64 -0
  51. package/docs/openai-ads/AUDIT.md +2 -1
  52. package/docs/openai-ads/RUNBOOK.md +26 -2
  53. package/package.json +3 -2
  54. package/src/configs/autouncle.config.ts +51 -3
  55. package/src/configs/meinstadt.config.ts +57 -2
  56. package/src/constants/autouncle.contants.ts +2 -3
  57. package/src/exporters/autouncle.exporter.ts +59 -37
  58. package/src/exporters/meinstadt.exporter.ts +53 -32
  59. package/src/formats/autouncle-json.builder.ts +2 -4
  60. package/src/interfaces/export-item.interface.ts +15 -12
  61. package/src/interfaces/openai-ads.interface.ts +2 -0
  62. package/src/interfaces/params.interface.ts +10 -0
  63. package/src/services/autouncle-feed-run.ts +197 -0
  64. package/src/services/meinstadt-feed-run.ts +240 -0
  65. package/src/services/openai-ads-feed-run.ts +6 -0
  66. package/src/transformers/autouncle.transformer.ts +217 -175
  67. package/src/transformers/meinstadt.transformer.ts +114 -68
  68. package/src/transformers/openai-ads-finance.ts +60 -0
  69. package/src/transformers/openai-ads.transformer.ts +32 -6
  70. package/test/autouncle-feed-run.test.ts +256 -0
  71. package/test/autouncle-mapping.test.ts +225 -0
  72. package/test/fixtures/meinstadt.fixture.ts +87 -0
  73. package/test/fixtures/openai-ads-finance-titles.json +325 -0
  74. package/test/meinstadt-feed-run.test.ts +288 -0
  75. package/test/meinstadt-mapping.test.ts +215 -0
  76. package/test/openai-ads-finance.test.ts +109 -0
@@ -1,181 +1,259 @@
1
1
  import { TranslatorExtended } from '@bytebrand/schema-tools';
2
2
  import { de, deVehicleDetailsPage, deVehicleProps } from '@bytebrand/i18n-dictionaries';
3
- import { get, toString, isEmpty, toSafeInteger } from 'lodash';
3
+ import { get, toString, isEmpty, toNumber, toSafeInteger } from 'lodash';
4
4
  import * as moment from 'moment';
5
5
  import { DataSchemes } from '@bytebrand/car-schema';
6
6
  import { DePostalCodesService } from '@bytebrand/car-basic-data-scope';
7
7
  import { DePostalCodesEntryInterface } from '@bytebrand/car-basic-data-scope/dist/interfaces/DePostalCodesInterface';
8
8
 
9
- import { ConfigService, ImagesService } from '../services';
10
9
  import { Injectable } from '@nestjs/common';
11
- import { DEFAULT_ADDR1, DEFAULT_CITY, DEFAULT_ZIP, MIN_MILEAGES } from '../constants/autouncle.contants';
10
+ import { DEFAULT_REGION, MIN_MILEAGES } from '../constants/autouncle.contants';
12
11
  import { AutouncleJsonItem } from '../interfaces';
13
- import { ImboImageResponseInterface } from '@bytebrand/chunkable';
12
+ import { OpenAiImageSelection } from './openai-image-fields';
13
+ import { toUnixSeconds } from './meinstadt.transformer';
14
14
 
15
- @Injectable()
16
- export class AutouncleTransformer {
17
- private translator: TranslatorExtended;
18
- private postalCodesService: DePostalCodesService;
15
+ export enum AutouncleExclusion {
16
+ duplicateId = 'duplicate_id',
17
+ filtered = 'filtered',
18
+ noId = 'no_id',
19
+ noDealer = 'no_dealer',
20
+ notAvailable = 'reserved_or_sold',
21
+ noPrice = 'no_valid_price',
22
+ noImage = 'no_valid_image',
23
+ }
19
24
 
20
- constructor(private configService: ConfigService, private imageService: ImagesService) {
21
- const dict = { ...de, ...deVehicleProps, ...deVehicleDetailsPage };
22
- this.translator = new TranslatorExtended(dict);
23
- this.postalCodesService = DePostalCodesService.getInstance();
25
+ // Online cars flagged like this are not sellable stock; the partner gets the file without them.
26
+ const NOT_AVAILABLE = ['selector_offerAvailability_reserved', 'selector_offerAvailability_sold'];
27
+
28
+ export interface AutouncleAddress {
29
+ city: string;
30
+ addr1: string;
31
+ region: string;
32
+ postal_code: string;
33
+ }
34
+
35
+ export interface AutouncleMappingContext {
36
+ domain: string;
37
+ address: AutouncleAddress;
38
+ // final, validated image URLs, hero first
39
+ images: string[];
40
+ // German label of a selector (TranslatorExtended.translateVal)
41
+ translate: (key: string) => string;
42
+ }
43
+
44
+ export type AutouncleMapResult = { ok: true; item: AutouncleJsonItem } | { ok: false; reason: AutouncleExclusion };
45
+
46
+ /**
47
+ * The AutoUncle `images` list (ADV-323) from the shared validated clean selection (ADV-336, same
48
+ * rules as the OpenAI and meinestadt feeds): valid 3/4 hero first, then every other valid image in
49
+ * clean-profile order. No valid hero => [] (car is skipped, never a placeholder). maxImages > 0
50
+ * cuts the list — no partner limit is known, so the default is no cut.
51
+ */
52
+ export const toAutouncleImages = (selection: OpenAiImageSelection, maxImages = 0): string[] => {
53
+ if (!selection?.fields) {
54
+ return [];
24
55
  }
56
+ const urls = [selection.fields.image_url, ...selection.fields.additional_image_urls];
57
+ return maxImages > 0 ? urls.slice(0, maxImages) : urls;
58
+ };
25
59
 
26
- async transform({
27
- car,
28
- images,
29
- owner,
30
- }: {
31
- car: DataSchemes.Car;
32
- images: ImboImageResponseInterface[];
33
- metadata?: unknown;
34
- owner: unknown;
35
- }): Promise<AutouncleJsonItem> {
36
- const autodeId = toString(get(car, '_id', ''));
37
- const address = await this.getAddress(car, owner);
38
- const image_urls = await this.imageService.getImages(car, images, this.configService.config.imageType, false);
39
- const transformed_images = image_urls.map(url => {
40
- return { url };
41
- });
42
- return {
43
- back_link: `https://www.${this.configService.config.domain}/search/vehicle/${autodeId}?source=classifieds&medium=cpc&campaign=autouncle`,
60
+ /**
61
+ * Year of the first registration. Missing, 0, null or unparsable => '' (unknown), never the Unix
62
+ * epoch year 1970 (ACF-3717/3725: `get(car, path, 0)` returned null for a null value and
63
+ * moment(0) made it 1970). A year outside 1900..next year is a unit error (ms instead of s) => ''.
64
+ */
65
+ export const getAutouncleYear = (firstRegistration: unknown, now: Date = new Date()): string => {
66
+ const seconds = toUnixSeconds(firstRegistration);
67
+ if (seconds === null) {
68
+ return '';
69
+ }
70
+ const year = toSafeInteger(moment(seconds * 1000).format('YYYY'));
71
+ return year >= 1900 && year <= now.getFullYear() + 1 ? toString(year) : '';
72
+ };
73
+
74
+ // Unchanged rule: a car without a first registration is new.
75
+ export const getAutouncleStatus = (firstRegistration: unknown): 'Neuwagen' | 'Gebrauchtwagen' =>
76
+ toUnixSeconds(firstRegistration) === null ? 'Neuwagen' : 'Gebrauchtwagen';
77
+
78
+ /**
79
+ * End-customer purchase price, `EUR <amount>` as the feed always had it. Only price.currentSalesPrice —
80
+ * never a monthly financing/leasing rate, never price.purchasePrice (dealer cost). null = no valid
81
+ * positive price; the car is not exported (it was sent as "EUR " before).
82
+ */
83
+ export const getAutounclePrice = (value: unknown): string | null => {
84
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
85
+ return null;
86
+ }
87
+ const amount = toNumber(value);
88
+ return Number.isFinite(amount) && amount > 0 ? `EUR ${value}` : null;
89
+ };
90
+
91
+ const withUnit = (value: unknown, unit: string): string => {
92
+ const text = toString(value).trim();
93
+ return text ? `${text}${unit}` : '';
94
+ };
95
+
96
+ const BODY_STYLES = {
97
+ selector_unknown: 'Other',
98
+ selector_category_combi: 'Wagon',
99
+ selector_category_sedan: 'Sedan',
100
+ selector_category_compact: 'Small_car',
101
+ selector_category_coupe: 'Coupe',
102
+ selector_category_convertible: 'Convertible',
103
+ selector_category_suv: 'Suv',
104
+ selector_category_van: 'Van',
105
+ selector_category_transporter: 'Truck',
106
+ selector_category_minibus: 'Minivan',
107
+ };
108
+
109
+ const FUELS = {
110
+ selector_fuel_petrol: 'Gasoline',
111
+ selector_fuel_diesel: 'Diesel',
112
+ selector_fuel_electrical: 'Electric',
113
+ selector_fuel_hybridElectricalPetrol: 'Hybrid',
114
+ selector_fuel_hybridElectricalDiesel: 'Hybrid',
115
+ selector_fuel_carGas: 'CarGas',
116
+ selector_fuel_naturalGas: 'NaturalGas',
117
+ selector_fuel_ethanol: 'Ethanol',
118
+ selector_fuel_hydrogen: 'Hydrogen',
119
+ selector_unknown: 'Other',
120
+ };
121
+
122
+ const TRANSMISSIONS = {
123
+ selector_gearbox_manualShift: 'manual',
124
+ selector_gearbox_semiAutomatic: 'automatic',
125
+ selector_gearbox_automatic: 'automatic',
126
+ selector_unknown: 'other',
127
+ };
128
+
129
+ // Unmapped, null or missing selector -> the map's selector_unknown value; the key never drops out
130
+ // of the JSON (JSON.stringify omits undefined).
131
+ const fromSelector = (map: Record<string, string>, selector: unknown): string =>
132
+ (typeof selector === 'string' && map[selector]) || map.selector_unknown;
133
+
134
+ const getMileage = (car: DataSchemes.Car): number => {
135
+ const mileage = get(car, 'mainData.mileage', 0);
136
+ return mileage <= MIN_MILEAGES ? MIN_MILEAGES + 1 : mileage;
137
+ };
138
+
139
+ const getExteriorColor = (car: DataSchemes.Car, translate: (key: string) => string): string => {
140
+ let colorSelection = get(car, 'exterior.exteriorColor', 'other');
141
+ if (!colorSelection || colorSelection === 'selector_unknown') {
142
+ colorSelection = 'other';
143
+ }
144
+ return translate(colorSelection);
145
+ };
146
+
147
+ // Car-level exclusions that need no dealer lookup and no image probe; null = exportable.
148
+ export const getAutouncleExclusion = (car: DataSchemes.Car): AutouncleExclusion | null => {
149
+ if (!toString(get(car, '_id', '')).trim()) {
150
+ return AutouncleExclusion.noId;
151
+ }
152
+ if (!toString(get(car, 'metaData.ownerId', '')).trim()) {
153
+ return AutouncleExclusion.noDealer;
154
+ }
155
+ if (NOT_AVAILABLE.includes(toString(get(car, 'offer.offerAvailability', '')))) {
156
+ return AutouncleExclusion.notAvailable;
157
+ }
158
+ if (!getAutounclePrice(get(car, 'price.currentSalesPrice', null))) {
159
+ return AutouncleExclusion.noPrice;
160
+ }
161
+ return null;
162
+ };
163
+
164
+ /**
165
+ * One `ads[]` object. Keys, their order and the historic spellings (co2_effiency_class,
166
+ * enviromental_sticker) are kept until AutoUncle confirms a current schema.
167
+ * Identity: back_link carries the stable CBD car `_id`; seller_id is the DEALER (metaData.ownerId),
168
+ * the same for every car of one dealer.
169
+ */
170
+ export const mapAutouncleItem = (car: DataSchemes.Car, ctx: AutouncleMappingContext): AutouncleMapResult => {
171
+ const excluded = getAutouncleExclusion(car);
172
+ if (excluded) {
173
+ return { ok: false, reason: excluded };
174
+ }
175
+ if (!ctx.images.length) {
176
+ return { ok: false, reason: AutouncleExclusion.noImage };
177
+ }
178
+ const autodeId = toString(get(car, '_id', '')).trim();
179
+ const sellerId = toString(get(car, 'metaData.ownerId', '')).trim();
180
+ const price = getAutounclePrice(get(car, 'price.currentSalesPrice', null));
181
+ const firstRegistration = get(car, 'mainData.firstRegistration', null);
182
+
183
+ return {
184
+ ok: true,
185
+ item: {
186
+ back_link: `https://www.${ctx.domain}/search/vehicle/${autodeId}?source=classifieds&medium=cpc&campaign=autouncle`,
44
187
  make: toString(get(car, 'mainData.make', '')),
45
188
  model_name: toString(get(car, 'mainData.model', '')),
46
189
  model_variant: toString(get(car, 'mainData.subModel', '')),
47
- fuel: this.mapFuel(car),
48
- body: this.mapBodyStyle(car),
190
+ fuel: fromSelector(FUELS, get(car, 'consumption.fuel', null)),
191
+ body: fromSelector(BODY_STYLES, get(car, 'mainData.category', null)),
49
192
  doors: toString(get(car, 'mainData.doors', '')),
50
- price: this.getPrice(car),
51
- year: toString(this.getYear(car)),
52
- status: toString(this.getStatus(car)),
53
- mileage: toString(this.getMileage(car)) + 'km',
54
- leasing: toString(get(car, '', '')),
55
- color: this.getExteriorColor(car),
56
- engine_size: toString(get(car, 'engineData.cubicCapacity', '')) + 'ccm',
193
+ price,
194
+ year: getAutouncleYear(firstRegistration),
195
+ status: getAutouncleStatus(firstRegistration),
196
+ mileage: toString(getMileage(car)) + 'km',
197
+ leasing: '',
198
+ color: getExteriorColor(car, ctx.translate),
199
+ // no unit without a value (was "ccm")
200
+ engine_size: withUnit(get(car, 'engineData.cubicCapacity', ''), 'ccm'),
57
201
  power: toString(get(car, 'engineData.powerKW', '')),
58
- gear_type: this.mapTransmission(car),
59
- has_4wd: this.has4WD(),
202
+ gear_type: fromSelector(TRANSMISSIONS, get(car, 'driveSuspension.gearbox', null)),
203
+ has_4wd: '',
60
204
  seller_type: 'Dealer',
61
- seller_region: toString(get(address, 'region', '')),
62
- seller_city: toString(get(address, 'city', '')),
63
- seller_street_address: toString(get(address, 'addr1', '')),
64
- seller_postcode: toString(get(address, 'postal_code', '')),
205
+ seller_region: ctx.address.region,
206
+ seller_city: ctx.address.city,
207
+ seller_street_address: ctx.address.addr1,
208
+ seller_postcode: ctx.address.postal_code,
65
209
  seller_name: 'auto.de',
66
- seller_id: toString(get(car, 'metaData.ownerId', '')),
210
+ seller_id: sellerId,
67
211
  description: toString(get(car, 'offer.offerDescription', '')),
68
- images: transformed_images,
212
+ images: ctx.images.map(url => ({ url })),
69
213
  fuel_economy: toString(get(car, 'consumption.consumptionCombined', '')),
70
214
  co2_emission: toString(get(car, 'environmentEmissions.co2', '')),
71
215
  // ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
72
216
  co2_effiency_class: toString(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', '')),
73
217
  euro_emission_class: toString(get(car, 'environmentEmissions.emissionClass', '')),
74
218
  enviromental_sticker: toString(get(car, 'environmentEmissions.emissionSticker', '')),
75
- };
76
- }
219
+ },
220
+ };
221
+ };
77
222
 
78
- private getStatus(car: DataSchemes.Car): 'Neuwagen' | 'Gebrauchtwagen' {
79
- if (get(car, 'mainData.firstRegistration', 0) === 0) {
80
- return 'Neuwagen';
81
- }
82
- return 'Gebrauchtwagen';
83
- }
223
+ @Injectable()
224
+ export class AutouncleTransformer {
225
+ private translator: TranslatorExtended;
226
+ private postalCodesService: DePostalCodesService;
84
227
 
85
- private getYear(car: DataSchemes.Car): number | '' {
86
- const firstRegistration = get(car, 'mainData.firstRegistration', 0) as number;
87
- if (firstRegistration !== 0) {
88
- return toSafeInteger(moment(firstRegistration * 1000).format('YYYY'));
89
- }
90
- return '';
228
+ constructor() {
229
+ const dict = { ...de, ...deVehicleProps, ...deVehicleDetailsPage };
230
+ this.translator = new TranslatorExtended(dict);
231
+ this.postalCodesService = DePostalCodesService.getInstance();
91
232
  }
92
233
 
93
- private mapBodyStyle(car: DataSchemes.Car): string {
94
- const available = {
95
- selector_unknown: 'Other',
96
- selector_category_combi: 'Wagon',
97
- selector_category_sedan: 'Sedan',
98
- selector_category_compact: 'Small_car',
99
- selector_category_coupe: 'Coupe',
100
- selector_category_convertible: 'Convertible',
101
- selector_category_suv: 'Suv',
102
- selector_category_van: 'Van',
103
- selector_category_transporter: 'Truck',
104
- selector_category_minibus: 'Minivan',
105
- };
106
- const category = get(car, 'mainData.category', 'selector_unknown');
107
- return get(available, category);
108
- }
109
-
110
- private mapFuel(car: DataSchemes.Car): string {
111
- const available = {
112
- selector_fuel_petrol: 'Gasoline',
113
- selector_fuel_diesel: 'Diesel',
114
- selector_fuel_electrical: 'Electric',
115
- selector_fuel_hybridElectricalPetrol: 'Hybrid',
116
- selector_fuel_hybridElectricalDiesel: 'Hybrid',
117
- selector_fuel_carGas: 'CarGas',
118
- selector_fuel_naturalGas: 'NaturalGas',
119
- selector_fuel_ethanol: 'Ethanol',
120
- selector_fuel_hydrogen: 'Hydrogen',
121
- selector_unknown: 'Other',
122
- };
123
- const fuel = get(car, 'consumption.fuel', 'selector_unknown');
124
- return get(available, fuel);
234
+ translate(key: string): string {
235
+ return this.translator.translateVal(key);
125
236
  }
126
237
 
127
- private mapTransmission(car: DataSchemes.Car): string {
128
- const available = {
129
- selector_gearbox_manualShift: 'manual',
130
- selector_gearbox_semiAutomatic: 'automatic',
131
- selector_gearbox_automatic: 'automatic',
132
- selector_unknown: 'other',
133
- };
134
- const transmission = get(car, 'driveSuspension.gearbox', 'selector_unknown');
135
- return get(available, transmission);
136
- }
137
-
138
- private getPrice(car: DataSchemes.Car): string {
139
- const price = get(car, 'price.currentSalesPrice', '');
140
- return `EUR ${price}`;
141
- }
142
-
143
- private async getAddress(
144
- car: DataSchemes.Car,
145
- owner: unknown,
146
- ): Promise<{
147
- city: string;
148
- addr1: unknown;
149
- region: string;
150
- country: string;
151
- postal_code: string;
152
- latitude: number;
153
- longitude: number;
154
- }> {
238
+ /**
239
+ * Dealer address from the UPA profile (businessAddress, else mainAddress). The postal code table
240
+ * only adds the region; when it does not know the place the dealer's own street/city/zip stay
241
+ * (they were blanked before) and the region is "Deutschland".
242
+ */
243
+ async getAddress(owner: unknown): Promise<AutouncleAddress> {
155
244
  let addressPath = 'businessAddress';
156
245
  if (this.isEmptyAddress(get(owner, addressPath, {}))) {
157
246
  addressPath = 'mainAddress';
158
247
  }
159
- let city: string = get(owner, `${addressPath}.city`, '');
160
- let zip: string = get(owner, `${addressPath}.zip`, '');
161
- let addr1 = get(owner, `${addressPath}.street`, '');
162
- let locationEntity = await this.getLocationByInfo({ city, zip: toSafeInteger(zip) });
163
-
164
- if (this.isEmptyLocation(locationEntity)) {
165
- addr1 = DEFAULT_ADDR1;
166
- city = DEFAULT_CITY;
167
- zip = DEFAULT_ZIP;
168
- locationEntity = await this.getLocationByInfo({ city, zip: toSafeInteger(zip) });
169
- }
248
+ const city = toString(get(owner, `${addressPath}.city`, ''));
249
+ const zip = toString(get(owner, `${addressPath}.zip`, ''));
250
+ const addr1 = toString(get(owner, `${addressPath}.street`, ''));
251
+ const location = await this.getLocationByInfo({ city, zip: toSafeInteger(zip) });
170
252
 
171
- const coords = await this.getCoords(car, locationEntity);
172
- const country: string = get(owner, `${addressPath}.country.name`, '');
173
253
  return {
174
- ...coords,
175
254
  city,
176
255
  addr1,
177
- region: get(locationEntity, 'Place Name', 'Deutschland'),
178
- country: country || 'Deutschland',
256
+ region: this.isEmptyLocation(location) ? DEFAULT_REGION : toString(location['Place Name']),
179
257
  postal_code: zip,
180
258
  };
181
259
  }
@@ -190,48 +268,12 @@ export class AutouncleTransformer {
190
268
  return ['city', 'zip', 'country'].every((key: string) => isEmpty(address[key]));
191
269
  }
192
270
 
193
- private async getCoords(
194
- car: DataSchemes.Car,
195
- locationEntry: DePostalCodesEntryInterface,
196
- ): Promise<Pick<DataSchemes.Location, 'latitude' | 'longitude'>> {
197
- const data: Pick<DataSchemes.Location, 'latitude' | 'longitude'> = {
198
- latitude: get(car, 'location.latitude'),
199
- longitude: get(car, 'location.longitude'),
200
- };
201
-
202
- if (data.latitude && data.longitude) {
203
- return data;
204
- }
205
-
206
- return {
207
- longitude: get(locationEntry, 'Longitude'),
208
- latitude: get(locationEntry, 'Latitude'),
209
- };
210
- }
211
-
212
271
  private async getLocationByInfo(info: { zip: number; city: string }): Promise<DePostalCodesEntryInterface> {
213
- let entry: DePostalCodesEntryInterface = await this.postalCodesService.getLocationByPostalCode(info.zip);
272
+ let entry: DePostalCodesEntryInterface = info.zip ? await this.postalCodesService.getLocationByPostalCode(info.zip) : undefined;
214
273
 
215
- if (!entry) {
274
+ if (!entry && info.city) {
216
275
  entry = await this.postalCodesService.getLocationByCity(info.city);
217
276
  }
218
277
  return entry;
219
278
  }
220
-
221
- private getExteriorColor(car: DataSchemes.Car): string {
222
- let colorSelection = get(car, 'exterior.exteriorColor', 'other');
223
- if (!colorSelection || colorSelection === 'selector_unknown') {
224
- colorSelection = 'other';
225
- }
226
- return this.translator.translateVal(colorSelection);
227
- }
228
-
229
- private getMileage(car: DataSchemes.Car): number {
230
- const mileage = get(car, 'mainData.mileage', 0);
231
- return mileage <= MIN_MILEAGES ? MIN_MILEAGES + 1 : mileage;
232
- }
233
-
234
- private has4WD(): string {
235
- return '';
236
- }
237
279
  }
@@ -3,90 +3,136 @@ import { get, toString } from 'lodash';
3
3
  import { Injectable } from '@nestjs/common';
4
4
 
5
5
  import { CarDescriptionFactory, DescriptionTypeEnum } from '@bytebrand/car-description';
6
- import { ImboImageResponseInterface } from '@bytebrand/chunkable';
7
6
  import { DataSchemes } from '@bytebrand/car-schema';
8
7
  import { de } from '@bytebrand/i18n-dictionaries';
9
8
 
10
- import { ConfigService, ImagesService, UserService } from '../services';
9
+ import { UserService } from '../services';
11
10
  import { MeinstadtJsonItem } from '../interfaces';
11
+ import { OpenAiImageSelection } from './openai-image-fields';
12
12
 
13
- @Injectable()
14
- export class MeinstadtTransformer {
15
- constructor(
16
- private configService: ConfigService,
17
- private readonly imageService: ImagesService,
18
- private userService: UserService
19
- ) {}
20
-
21
- async transform({
22
- car,
23
- images,
24
- }: {
25
- car: DataSchemes.Car;
26
- images: ImboImageResponseInterface[];
27
- }): Promise<Partial<MeinstadtJsonItem>> {
28
- const carImages = await this.imageService.getImages(car, images, this.configService.config.imageType, false);
13
+ /**
14
+ * The meinestadt `images` list from the shared validated clean selection (ADV-336, same rules as
15
+ * the OpenAI feed): valid 3/4 hero first, then every other valid image in clean-profile order.
16
+ * No valid hero => [] (car is skipped). maxImages > 0 cuts the exported list only; the gallery
17
+ * itself is untouched. Another image profile (e.g. the planned marketing designs) plugs in by
18
+ * producing a different selection — imagesCount always follows the list exported here.
19
+ */
20
+ export const toMeinstadtImages = (selection: OpenAiImageSelection, maxImages = 0): string[] => {
21
+ if (!selection?.fields) {
22
+ return [];
23
+ }
24
+ const urls = [selection.fields.image_url, ...selection.fields.additional_image_urls];
25
+ return maxImages > 0 ? urls.slice(0, maxImages) : urls;
26
+ };
29
27
 
30
- const carId = toString(get(car, '_id', null));
28
+ export interface MeinstadtMappingContext {
29
+ domain: string;
30
+ // validated tariff (parseMeinstadtCpc) or null
31
+ cpc: string | null;
32
+ equipment: string;
33
+ // final, validated image URLs — imagesCount is derived from exactly this list
34
+ images: string[];
35
+ translate?: (key: string) => string | undefined;
36
+ }
31
37
 
32
- return {
33
- id: get(car, 'metaData.vehicleId', null),
34
- make: get(car, 'mainData.make', null),
35
- model: get(car, 'mainData.model', null),
36
- cpc: get(this.configService.config, 'cpc', '0.08'),
37
- listingUrl: `https://www.${this.configService.config.domain}/search/vehicle/${carId}?source=classifieds&medium=listing&campaign=meinestadt`,
38
- title: this.getTitle(car),
39
- accidentFree: get(car, 'mainData.accidentDamaged', null),
40
- mileage: get(car, 'mainData.mileage', null),
41
- initialRegistration: get(car, 'mainData.firstRegistration', null),
42
- offerCreationDate: get(car, 'metaData.creationDate', null),
43
- numberOfDoors: get(car, 'mainData.doors', null),
44
- numberOfSeats: get(car, 'mainData.seats', null),
45
- numberOfPreviousOwners: get(car, 'mainData.numberOfPreviousOwners', null),
46
- price: get(car, 'price.currentSalesPrice', null),
47
- power: get(car, 'engineData.powerPS', null),
48
- co2Emissions: get(car, 'environmentEmissions.co2', null),
49
- fuelConsumptionCombined: get(car, 'consumption.consumptionCombined', null),
50
- equipment: await this.getDescription(car),
51
- color: this.translate(get(car, 'exterior.exteriorColor', null)),
52
- bodyType: this.translate(get(car, 'mainData.category', null)),
53
- emissionClass: this.translate(get(car, 'environmentEmissions.emissionClass', null)),
54
- emissionSticker: this.translate(get(car, 'environmentEmissions.emissionSticker', null)),
55
- fuelType: this.translate(get(car, 'consumption.fuel', null)),
56
- 'transmissionType/transmission': this.translate(get(car, 'driveSuspension.gearbox', null)),
57
- imagesCount: carImages.length,
58
- 'customerType/sellerType': 'Händler',
59
- usageState: this.translate(this.getUsageState(car)),
60
- images: carImages[0],
61
- };
38
+ /**
39
+ * mainData.accidentDamaged is a DAMAGE flag ("Unfallschaden"), accidentFree its negation.
40
+ * Only an explicit `false` makes a car accident-free; missing/null (no damage history) stays
41
+ * unknown and is sent as null, never as accident-free.
42
+ */
43
+ export const toAccidentFree = (accidentDamaged: unknown): boolean | null => {
44
+ if (accidentDamaged === false) {
45
+ return true;
62
46
  }
63
-
64
- private getTitle(car: DataSchemes.Car): string {
65
- return `${car.mainData.make} ${car.mainData.model} ${car.mainData.subModel}`;
47
+ if (accidentDamaged === true) {
48
+ return false;
66
49
  }
50
+ return null;
51
+ };
67
52
 
68
- private translate(val: string | null): string {
69
- if (!val) return null;
70
- if (de[val]) return de[val];
53
+ /**
54
+ * CBD dates (firstRegistration, creationDate) as Unix seconds, the format the feed always had.
55
+ * Date objects / date strings are converted; 0, negative, unparsable or missing values are null —
56
+ * never a Unix-zero (1970) date.
57
+ */
58
+ export const toUnixSeconds = (value: unknown): number | null => {
59
+ let seconds: number;
60
+ if (typeof value === 'number') {
61
+ seconds = value;
62
+ } else if (value instanceof Date) {
63
+ seconds = value.getTime() / 1000;
64
+ } else if (typeof value === 'string' && value.trim()) {
65
+ seconds = Date.parse(value) / 1000;
66
+ } else {
71
67
  return null;
72
68
  }
69
+ return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : null;
70
+ };
73
71
 
74
- private getUsageState(car: DataSchemes.Car): string {
75
- const condition = get(car, 'mainData.condition', null);
76
- if (!condition || condition === 'selector_unknown') {
77
- const mileage = get(car, 'mainData.mileage', 0);
78
- if (mileage >= 100) {
79
- return 'selector_condition_used';
80
- }
81
- return 'selector_condition_new';
72
+ // German label of a selector; unknown/unmapped selectors are null, not "Unbekannt".
73
+ export const translateSelector = (val: string | null, translate: (key: string) => string | undefined = key => de[key]): string | null => {
74
+ if (!val || val === 'selector_unknown') return null;
75
+ return translate(val) || null;
76
+ };
77
+
78
+ export const getUsageState = (car: DataSchemes.Car): string => {
79
+ const condition = get(car, 'mainData.condition', null);
80
+ if (!condition || condition === 'selector_unknown') {
81
+ const mileage = get(car, 'mainData.mileage', 0);
82
+ if (mileage >= 100) {
83
+ return 'selector_condition_used';
82
84
  }
83
- return condition;
85
+ return 'selector_condition_new';
84
86
  }
87
+ return condition;
88
+ };
89
+
90
+ export const mapMeinstadtItem = (car: DataSchemes.Car, ctx: MeinstadtMappingContext): MeinstadtJsonItem => {
91
+ const carId = toString(get(car, '_id', null));
92
+ const translate = (val: string | null) => translateSelector(val, ctx.translate);
93
+
94
+ return {
95
+ id: get(car, 'metaData.vehicleId', null),
96
+ make: get(car, 'mainData.make', null),
97
+ model: get(car, 'mainData.model', null),
98
+ cpc: ctx.cpc,
99
+ listingUrl: `https://www.${ctx.domain}/search/vehicle/${carId}?source=classifieds&medium=listing&campaign=meinestadt`,
100
+ title: `${car.mainData.make} ${car.mainData.model} ${car.mainData.subModel}`,
101
+ accidentFree: toAccidentFree(get(car, 'mainData.accidentDamaged', null)),
102
+ mileage: get(car, 'mainData.mileage', null),
103
+ initialRegistration: toUnixSeconds(get(car, 'mainData.firstRegistration', null)),
104
+ offerCreationDate: toUnixSeconds(get(car, 'metaData.creationDate', null)),
105
+ numberOfDoors: get(car, 'mainData.doors', null),
106
+ numberOfSeats: get(car, 'mainData.seats', null),
107
+ numberOfPreviousOwners: get(car, 'mainData.numberOfPreviousOwners', null),
108
+ // end-customer purchase price (EUR) — never a rate, never price.purchasePrice (dealer cost)
109
+ price: get(car, 'price.currentSalesPrice', null),
110
+ // PS, not kW (workbook: "only put the value for PS")
111
+ power: get(car, 'engineData.powerPS', null),
112
+ co2Emissions: get(car, 'environmentEmissions.co2', null),
113
+ fuelConsumptionCombined: get(car, 'consumption.consumptionCombined', null),
114
+ equipment: ctx.equipment,
115
+ color: translate(get(car, 'exterior.exteriorColor', null)),
116
+ bodyType: translate(get(car, 'mainData.category', null)),
117
+ emissionClass: translate(get(car, 'environmentEmissions.emissionClass', null)),
118
+ emissionSticker: translate(get(car, 'environmentEmissions.emissionSticker', null)),
119
+ fuelType: translate(get(car, 'consumption.fuel', null)),
120
+ 'transmissionType/transmission': translate(get(car, 'driveSuspension.gearbox', null)),
121
+ imagesCount: ctx.images.length,
122
+ 'customerType/sellerType': 'Händler',
123
+ usageState: translate(getUsageState(car)),
124
+ images: [...ctx.images],
125
+ };
126
+ };
127
+
128
+ @Injectable()
129
+ export class MeinstadtTransformer {
130
+ constructor(private userService: UserService) {}
85
131
 
86
- private async getDescription(car: DataSchemes.Car): Promise<string> {
132
+ async getDescription(car: DataSchemes.Car): Promise<string> {
87
133
  const dealerId = get(car, 'metaData.ownerId');
88
- const descriptionSchema = await this.userService.getDefaultSummaryByDealerId(dealerId);
89
- const descriptionBuilder = CarDescriptionFactory.create(DescriptionTypeEnum.Mobile, car);
90
- return descriptionBuilder.transform(descriptionSchema);
134
+ const descriptionSchema = await this.userService.getDefaultSummaryByDealerId(dealerId);
135
+ const descriptionBuilder = CarDescriptionFactory.create(DescriptionTypeEnum.Mobile, car);
136
+ return descriptionBuilder.transform(descriptionSchema);
91
137
  }
92
138
  }