@bytebrand/export-cars 1.26.18 → 1.26.19-hotfix.2

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 (85) hide show
  1. package/README.md +58 -0
  2. package/dist/configs/config.d.ts +4 -1
  3. package/dist/configs/config.js +3 -0
  4. package/dist/configs/config.js.map +1 -1
  5. package/dist/configs/facebook-shop.config.js +1 -1
  6. package/dist/configs/facebook-shop.config.js.map +1 -1
  7. package/dist/configs/fb.config.js +1 -1
  8. package/dist/configs/fb.config.js.map +1 -1
  9. package/dist/configs/google.config.js +26 -1
  10. package/dist/configs/google.config.js.map +1 -1
  11. package/dist/constants/feed-price.constants.d.ts +1 -0
  12. package/dist/constants/feed-price.constants.js +4 -1
  13. package/dist/constants/feed-price.constants.js.map +1 -1
  14. package/dist/export.command.js +1 -0
  15. package/dist/export.command.js.map +1 -1
  16. package/dist/exporters/facebook-shop.exporter.js +1 -3
  17. package/dist/exporters/facebook-shop.exporter.js.map +1 -1
  18. package/dist/exporters/google.exporter.js +20 -2
  19. package/dist/exporters/google.exporter.js.map +1 -1
  20. package/dist/formats/facebook-shop.builder.d.ts +1 -1
  21. package/dist/formats/facebook-shop.builder.js +6 -9
  22. package/dist/formats/facebook-shop.builder.js.map +1 -1
  23. package/dist/interfaces/export-item.interface.d.ts +2 -2
  24. package/dist/interfaces/params.interface.d.ts +19 -0
  25. package/dist/services/feed-items-bigquery.writer.d.ts +17 -0
  26. package/dist/services/feed-items-bigquery.writer.js +96 -0
  27. package/dist/services/feed-items-bigquery.writer.js.map +1 -0
  28. package/dist/services/publication-gate.d.ts +15 -0
  29. package/dist/services/publication-gate.js +107 -0
  30. package/dist/services/publication-gate.js.map +1 -0
  31. package/dist/transformers/auto24.transfomer.js +1 -1
  32. package/dist/transformers/auto24.transfomer.js.map +1 -1
  33. package/dist/transformers/autouncle.transformer.js +1 -1
  34. package/dist/transformers/autouncle.transformer.js.map +1 -1
  35. package/dist/transformers/awin.transformer.js +1 -1
  36. package/dist/transformers/awin.transformer.js.map +1 -1
  37. package/dist/transformers/criteo.transformer.js +1 -1
  38. package/dist/transformers/criteo.transformer.js.map +1 -1
  39. package/dist/transformers/fb.transformer.js +2 -2
  40. package/dist/transformers/fb.transformer.js.map +1 -1
  41. package/dist/transformers/google-feed-envkv.d.ts +10 -0
  42. package/dist/transformers/google-feed-envkv.js +126 -0
  43. package/dist/transformers/google-feed-envkv.js.map +1 -0
  44. package/dist/transformers/google-feed-item-row.d.ts +30 -0
  45. package/dist/transformers/google-feed-item-row.js +39 -0
  46. package/dist/transformers/google-feed-item-row.js.map +1 -0
  47. package/dist/transformers/google.transformer.d.ts +0 -4
  48. package/dist/transformers/google.transformer.js +10 -48
  49. package/dist/transformers/google.transformer.js.map +1 -1
  50. package/dist/transformers/mobile.transformer.js +3 -2
  51. package/dist/transformers/mobile.transformer.js.map +1 -1
  52. package/dist/transformers/rubrikk.transformer.js +1 -1
  53. package/dist/transformers/rubrikk.transformer.js.map +1 -1
  54. package/dist/transformers/santander.transformer.js +3 -2
  55. package/dist/transformers/santander.transformer.js.map +1 -1
  56. package/dist/tsconfig.build.tsbuildinfo +1 -1
  57. package/package.json +4 -2
  58. package/schema/feed-items.json +134 -0
  59. package/src/configs/config.ts +7 -1
  60. package/src/configs/facebook-shop.config.ts +2 -2
  61. package/src/configs/fb.config.ts +2 -2
  62. package/src/configs/google.config.ts +28 -3
  63. package/src/constants/feed-price.constants.ts +7 -0
  64. package/src/export.command.ts +1 -0
  65. package/src/exporters/facebook-shop.exporter.ts +1 -3
  66. package/src/exporters/google.exporter.ts +23 -2
  67. package/src/formats/facebook-shop.builder.ts +8 -9
  68. package/src/interfaces/export-item.interface.ts +2 -2
  69. package/src/interfaces/params.interface.ts +29 -0
  70. package/src/services/feed-items-bigquery.writer.ts +101 -0
  71. package/src/services/publication-gate.ts +120 -0
  72. package/src/transformers/auto24.transfomer.ts +2 -1
  73. package/src/transformers/autouncle.transformer.ts +2 -1
  74. package/src/transformers/awin.transformer.ts +2 -1
  75. package/src/transformers/criteo.transformer.ts +2 -1
  76. package/src/transformers/fb.transformer.ts +5 -2
  77. package/src/transformers/google-feed-envkv.ts +168 -0
  78. package/src/transformers/google-feed-item-row.ts +78 -0
  79. package/src/transformers/google.transformer.ts +24 -50
  80. package/src/transformers/mobile.transformer.ts +6 -2
  81. package/src/transformers/rubrikk.transformer.ts +2 -1
  82. package/src/transformers/santander.transformer.ts +6 -2
  83. package/test/google-feed-envkv.test.ts +132 -0
  84. package/test/google-feed-item-row.test.ts +135 -0
  85. package/test/publication-gate.test.ts +86 -0
@@ -41,7 +41,8 @@ export class Auto24Transformer {
41
41
  fueltype: this.translateSelector(get(car, 'consumption.fuel', '')),
42
42
  consumptionK: toString(get(car, 'consumption.consumptionCombined', '')),
43
43
  co2: toString(get(car, 'environmentEmissions.co2', '')),
44
- efficiencyclass: this.translateSelector(get(car, 'environmentEmissions.energyEfficiencyClass', '')),
44
+ // ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
45
+ efficiencyclass: this.translateSelector(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', '')),
45
46
  image: transformedImages[0],
46
47
  registration: this.getRegistrationField(get(car, 'mainData.firstRegistration') as number),
47
48
  images: transformedImages,
@@ -68,7 +68,8 @@ export class AutouncleTransformer {
68
68
  images: transformed_images,
69
69
  fuel_economy: toString(get(car, 'consumption.consumptionCombined', '')),
70
70
  co2_emission: toString(get(car, 'environmentEmissions.co2', '')),
71
- co2_effiency_class: toString(get(car, 'environmentEmissions.energyEfficiencyClass', '')),
71
+ // ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
72
+ co2_effiency_class: toString(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', '')),
72
73
  euro_emission_class: toString(get(car, 'environmentEmissions.emissionClass', '')),
73
74
  enviromental_sticker: toString(get(car, 'environmentEmissions.emissionSticker', '')),
74
75
  };
@@ -171,7 +171,8 @@ export class AwinTransformer {
171
171
  selector_energyEfficiencyClass_g: 'G',
172
172
  selector_unknown: '',
173
173
  };
174
- const energyClass = get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown');
174
+ // ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
175
+ const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
175
176
  return get(available, energyClass);
176
177
  }
177
178
 
@@ -140,7 +140,8 @@ export class CriteoTransformer {
140
140
  selector_energyEfficiencyClass_g: 'G',
141
141
  selector_unknown: 'default',
142
142
  };
143
- const energyClass = get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown');
143
+ // ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
144
+ const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
144
145
  return get(available, energyClass);
145
146
  }
146
147
 
@@ -413,6 +413,9 @@ export class FbTransformer {
413
413
  return get(car, 'state.isOnline', false) ? 'available' : 'not_available';
414
414
  }
415
415
 
416
+ // ADV-187: no fabricated class for an unknown value — the field previously
417
+ // defaulted every unresolved car to 'G' (worst class), which is a
418
+ // false/misleading statement, not a "no data" state. Empty string here.
416
419
  private getEnergyEfficiencyClass(car: DataSchemes.Car): string {
417
420
  const available = {
418
421
  selector_energyEfficiencyClass_appp: 'A+',
@@ -425,10 +428,10 @@ export class FbTransformer {
425
428
  selector_energyEfficiencyClass_e: 'E',
426
429
  selector_energyEfficiencyClass_f: 'F',
427
430
  selector_energyEfficiencyClass_g: 'G',
428
- selector_unknown: 'G',
431
+ selector_unknown: '',
429
432
  };
430
433
  const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
431
- return get(available, energyClass);
434
+ return get(available, energyClass, '');
432
435
  }
433
436
 
434
437
  private getMileage(car: DataSchemes.Car): number {
@@ -0,0 +1,168 @@
1
+ import { get } from 'lodash';
2
+ import { DataSchemes } from '@bytebrand/car-schema';
3
+
4
+ // Google vehicle ads `engine`: gasoline, diesel, electric, hybrid, lpg, methane, natural_gas,
5
+ // plug-in_hybrid, other (https://support.google.com/merchants/answer/14156068). Any other value
6
+ // is rejected as "Missing attribute [engine]" — CarGas / NaturalGas did that for ~290 items.
7
+ const GOOGLE_ENGINE: Record<string, string> = {
8
+ selector_fuel_petrol: 'Gasoline',
9
+ selector_fuel_diesel: 'Diesel',
10
+ selector_fuel_electrical: 'Electric',
11
+ selector_fuel_hybridElectricalPetrol: 'Hybrid',
12
+ selector_fuel_hybridElectricalDiesel: 'Hybrid',
13
+ selector_fuel_carGas: 'lpg',
14
+ selector_fuel_naturalGas: 'natural_gas',
15
+ selector_fuel_ethanol: 'other',
16
+ selector_fuel_hydrogen: 'other',
17
+ selector_unknown: 'other',
18
+ };
19
+
20
+ const HYBRID_FUELS = ['selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'];
21
+
22
+ const positive = (value: unknown): number | undefined => {
23
+ const n = Number(value);
24
+ return Number.isFinite(n) && n > 0 ? n : undefined;
25
+ };
26
+
27
+ const isBev = (car: DataSchemes.Car): boolean => get(car, 'consumption.fuel') === 'selector_fuel_electrical';
28
+ const isPlugIn = (car: DataSchemes.Car): boolean =>
29
+ HYBRID_FUELS.includes(get(car, 'consumption.fuel')) && !!get(car, 'engineData.hybridPlugin');
30
+
31
+ export function googleEngine(car: DataSchemes.Car): string {
32
+ const engine = GOOGLE_ENGINE[get(car, 'consumption.fuel', 'selector_unknown')] || 'other';
33
+ return engine === 'Hybrid' && get(car, 'engineData.hybridPlugin') ? 'plug-in_hybrid' : engine;
34
+ }
35
+
36
+ // BEV: combined WLTP range (CoC 49.2). Plug-in: the EAER (`wltpTotalRange`, CoC 49.5) when present,
37
+ // else the range the dealer delivered. City ranges are never used; 0 counts as missing.
38
+ const BEV_RANGE_FIELDS = ['battery.wltpRange', 'battery.batteryRangeElectric'];
39
+ const PLUG_IN_RANGE_FIELDS = ['battery.wltpTotalRange', 'battery.wltpRange', 'battery.batteryRangeElectric'];
40
+
41
+ const pickElectricRange = (car: DataSchemes.Car): { field: string; range: number } | undefined => {
42
+ const fields = isBev(car) ? BEV_RANGE_FIELDS : isPlugIn(car) ? PLUG_IN_RANGE_FIELDS : [];
43
+ for (const field of fields) {
44
+ const range = positive(get(car, field));
45
+ if (range) return { field, range };
46
+ }
47
+ return undefined;
48
+ };
49
+
50
+ export function googleElectricRange(car: DataSchemes.Car): string | undefined {
51
+ const picked = pickElectricRange(car);
52
+ return picked ? `${picked.range}km` : undefined;
53
+ }
54
+
55
+ // The car field googleElectricRange took its value from (feed audit), null when nothing is sent.
56
+ export function googleElectricRangeSource(car: DataSchemes.Car): string | null {
57
+ return pickElectricRange(car)?.field ?? null;
58
+ }
59
+
60
+ // Plug-in hybrids: Google and Pkw-EnVKV (CoC 49.4) want the WEIGHTED combined values for fuel,
61
+ // electricity and CO2, and the fuel consumption with a DISCHARGED battery separately. In CBD those
62
+ // are wltpWeightedCombined, wltpWeightedPowerCombined, wltpCo2 (weighted for a plug-in) and
63
+ // wltpCombined (discharged for a plug-in); the legacy consumptionCombined / co2 are only a fallback.
64
+ //
65
+ // engineData.hybridPlugin alone does not prove a plug-in: since ADV-124 every hybrid carries it, and
66
+ // the weighted fields of hybrids without an electric range are enrichment-sheet templates
67
+ // (prod 07.10: 4,178 of 5,576 flagged cars, e.g. 2.3 l + 21.5 kWh + 195 g). Only a hybrid with an
68
+ // electric range counts as a confirmed plug-in; the others keep the values sent before.
69
+ const isConfirmedPlugIn = (car: DataSchemes.Car): boolean => isPlugIn(car) && !!googleElectricRange(car);
70
+
71
+ // Missing or 0 is omitted instead of sending "0l/100km". BEVs carry no fuel consumption
72
+ // (CBD mirrors kWh into consumptionCombined for them).
73
+ //
74
+ // Each attribute is picked from the first positive field of a list; the pick keeps the field so the
75
+ // publication gate (EnVKV C8) can tell whether the sent value was estimated by CBD.
76
+ interface Picked {
77
+ field: string;
78
+ value: number;
79
+ }
80
+
81
+ const firstPositive = (car: DataSchemes.Car, fields: string[]): Picked | undefined => {
82
+ for (const field of fields) {
83
+ const value = positive(get(car, field));
84
+ if (value) return { field, value };
85
+ }
86
+ return undefined;
87
+ };
88
+
89
+ const pickFuelConsumption = (car: DataSchemes.Car): Picked | undefined => {
90
+ if (isBev(car)) return undefined;
91
+ if (isConfirmedPlugIn(car)) return firstPositive(car, ['consumption.wltpWeightedCombined', 'consumption.consumptionCombined']);
92
+ if (isPlugIn(car)) return firstPositive(car, ['consumption.consumptionCombined']); // unconfirmed plug-in: as before
93
+ // ICE / HEV: the WLTP combined value the VDP and EnVKV use (EnVKV stage D3), the legacy field as fallback
94
+ return firstPositive(car, ['consumption.wltpCombined', 'consumption.consumptionCombined']);
95
+ };
96
+
97
+ // Confirmed plug-in: wltpCombined, no fallback (the legacy combined value is close to the weighted
98
+ // one, sending it here would repeat the weighted figure). Unconfirmed plug-in: the stored combined
99
+ // value, as before.
100
+ const pickFuelConsumptionDischarged = (car: DataSchemes.Car): Picked | undefined => {
101
+ if (!isPlugIn(car)) return undefined;
102
+ return isConfirmedPlugIn(car)
103
+ ? firstPositive(car, ['consumption.wltpCombined'])
104
+ : firstPositive(car, ['consumption.consumptionCombined', 'consumption.wltpCombined']);
105
+ };
106
+
107
+ // BEV: combined consumption. Confirmed plug-in: weighted combined. Unconfirmed plug-in: the stored
108
+ // electric value, as before. Nothing for other cars.
109
+ const pickEnergyConsumption = (car: DataSchemes.Car): Picked | undefined => {
110
+ if (isConfirmedPlugIn(car)) return firstPositive(car, ['consumption.wltpWeightedPowerCombined']);
111
+ if (isBev(car) || isPlugIn(car)) return firstPositive(car, ['consumption.wltpPowerCombined', 'consumption.consumptionPowerCombined']);
112
+ return undefined;
113
+ };
114
+
115
+ // A BEV emits 0 g/km by definition (no field behind it); a confirmed plug-in and ICE / HEV send the
116
+ // WLTP value (weighted for the plug-in); an unconfirmed plug-in keeps the stored legacy value, as before.
117
+ const pickCo2 = (car: DataSchemes.Car): Picked | undefined => {
118
+ if (isBev(car)) return undefined;
119
+ return isPlugIn(car) && !isConfirmedPlugIn(car)
120
+ ? firstPositive(car, ['environmentEmissions.co2'])
121
+ : firstPositive(car, ['environmentEmissions.wltpCo2', 'environmentEmissions.co2']);
122
+ };
123
+
124
+ export function googleFuelConsumption(car: DataSchemes.Car): string | undefined {
125
+ const picked = pickFuelConsumption(car);
126
+ return picked ? `${picked.value}l/100km` : undefined;
127
+ }
128
+
129
+ export function googleFuelConsumptionDischarged(car: DataSchemes.Car): string | undefined {
130
+ const picked = pickFuelConsumptionDischarged(car);
131
+ return picked ? `${picked.value}l/100km` : undefined;
132
+ }
133
+
134
+ export function googleEnergyConsumption(car: DataSchemes.Car): string | undefined {
135
+ const picked = pickEnergyConsumption(car);
136
+ return picked ? `${picked.value}kWh/100km` : undefined;
137
+ }
138
+
139
+ // A missing or 0 value is omitted.
140
+ export function googleCo2(car: DataSchemes.Car): string | undefined {
141
+ if (isBev(car)) return '0g/km';
142
+ const picked = pickCo2(car);
143
+ return picked ? `${picked.value}g/km` : undefined;
144
+ }
145
+
146
+ // The EnVKV attributes of the Google item (keys of GoogleXmlItem).
147
+ export type GoogleEnvkvAttribute =
148
+ | 'fuel_consumption'
149
+ | 'fuel_consumption_discharged_battery'
150
+ | 'energy_consumption'
151
+ | 'electric_range'
152
+ | 'co2_emissions';
153
+
154
+ // For every EnVKV attribute the item sends: the car field its value came from.
155
+ export function googleEnvkvSourceFields(car: DataSchemes.Car): Partial<Record<GoogleEnvkvAttribute, string>> {
156
+ const picks: Record<GoogleEnvkvAttribute, { field: string } | undefined> = {
157
+ fuel_consumption: pickFuelConsumption(car),
158
+ fuel_consumption_discharged_battery: pickFuelConsumptionDischarged(car),
159
+ energy_consumption: pickEnergyConsumption(car),
160
+ electric_range: pickElectricRange(car),
161
+ co2_emissions: pickCo2(car),
162
+ };
163
+ const fields: Partial<Record<GoogleEnvkvAttribute, string>> = {};
164
+ for (const [attribute, picked] of Object.entries(picks)) {
165
+ if (picked) fields[attribute as GoogleEnvkvAttribute] = picked.field;
166
+ }
167
+ return fields;
168
+ }
@@ -0,0 +1,78 @@
1
+ import { get, isBoolean } from 'lodash';
2
+ import { DataSchemes } from '@bytebrand/car-schema';
3
+ import { GoogleXmlItem } from '../interfaces';
4
+ import { googleElectricRangeSource } from './google-feed-envkv';
5
+
6
+ // One BigQuery row per item the Google feed sent (schema/feed-items.json), so a Merchant Center
7
+ // issue can be traced back to the exact values of a run. The attribute strings are the ones in the
8
+ // XML; null means the attribute was omitted.
9
+ export interface FeedItemRow {
10
+ partitionDay: string;
11
+ exportedAt: string;
12
+ feed: string;
13
+ runId: string | null;
14
+ itemId: string;
15
+ vehicleId: string | null;
16
+ make: string | null;
17
+ model: string | null;
18
+ condition: string | null;
19
+ fuel: string | null;
20
+ hybridPlugin: boolean | null;
21
+ engine: string | null;
22
+ electricRange: string | null;
23
+ fuelConsumption: string | null;
24
+ fuelConsumptionDischarged: string | null;
25
+ energyConsumption: string | null;
26
+ co2Emissions: string | null;
27
+ rangeSourceField: string | null;
28
+ price: number | null;
29
+ availability: string | null;
30
+ // EnVKV C8: gate mode of the run and the attributes whose value CBD estimated (comma-separated);
31
+ // in enforce mode these attributes were left out of the item.
32
+ publicationGate: string | null;
33
+ estimatedAttributes: string | null;
34
+ }
35
+
36
+ const orNull = (value: unknown): string | null =>
37
+ value === undefined || value === null || value === '' ? null : String(value);
38
+
39
+ // "12345.5 EUR" -> 12345.5
40
+ const sentPrice = (price: string | undefined): number | null => {
41
+ const n = parseFloat(price);
42
+ return Number.isFinite(n) ? n : null;
43
+ };
44
+
45
+ // exportedAt is the run's start, shared by all rows of a run; partitionDay is its UTC day.
46
+ export function buildGoogleFeedItemRow(
47
+ car: DataSchemes.Car,
48
+ item: GoogleXmlItem,
49
+ runId: string | null,
50
+ exportedAt: Date,
51
+ gate: { mode: string; estimatedAttributes: string[] } | null = null,
52
+ ): FeedItemRow {
53
+ const hybridPlugin = get(car, 'engineData.hybridPlugin');
54
+ return {
55
+ partitionDay: exportedAt.toISOString().slice(0, 10),
56
+ exportedAt: exportedAt.toISOString(),
57
+ feed: 'google',
58
+ runId: orNull(runId),
59
+ itemId: String(item.id),
60
+ vehicleId: orNull(get(car, 'metaData.vehicleId')),
61
+ make: orNull(item.brand),
62
+ model: orNull(item.model),
63
+ condition: orNull(item.condition),
64
+ fuel: orNull(get(car, 'consumption.fuel')),
65
+ hybridPlugin: isBoolean(hybridPlugin) ? hybridPlugin : null,
66
+ engine: orNull(item.engine),
67
+ electricRange: orNull(item.electric_range),
68
+ fuelConsumption: orNull(item.fuel_consumption),
69
+ fuelConsumptionDischarged: orNull(item.fuel_consumption_discharged_battery),
70
+ energyConsumption: orNull(item.energy_consumption),
71
+ co2Emissions: orNull(item.co2_emissions),
72
+ rangeSourceField: item.electric_range ? googleElectricRangeSource(car) : null,
73
+ price: sentPrice(item.price),
74
+ availability: orNull(item.availability),
75
+ publicationGate: gate ? gate.mode : null,
76
+ estimatedAttributes: gate && gate.estimatedAttributes.length ? gate.estimatedAttributes.join(',') : null,
77
+ };
78
+ }
@@ -8,6 +8,14 @@ import { Injectable } from '@nestjs/common';
8
8
  import { ConfigService, ImagesService, MappingService, TransformerService, UserService } from '../services';
9
9
  import { GoogleXmlItem } from '../interfaces';
10
10
  import { isValidVin } from '../utils';
11
+ import {
12
+ googleCo2,
13
+ googleElectricRange,
14
+ googleEnergyConsumption,
15
+ googleEngine,
16
+ googleFuelConsumption,
17
+ googleFuelConsumptionDischarged,
18
+ } from './google-feed-envkv';
11
19
  import {
12
20
  META_PUSH_PRIORITY_ALLOWED_VALUES,
13
21
  META_PUSH_PRIORITY_STANDARD,
@@ -85,17 +93,17 @@ export class GoogleTransformer {
85
93
  color: this.getColor(car),
86
94
  vehicle_option: this.getExtrasField(car),
87
95
  body_style: this.mapBodyStyle(car),
88
- engine: this.mapFuel(car),
96
+ engine: googleEngine(car),
89
97
  transmission: this.mapTransmission(car),
90
98
  drive: this.mapDrive(car),
91
99
  doors: this.getDoors(car),
92
100
  capacity: this.getCapacity(car),
93
101
  date_first_registered: this.getDateFirstRegistered(car),
94
- fuel_consumption: `${this.transformService.fuelConsumption(car)}l/100km`,
95
- energy_consumption: this.getEnergyConsumption(car),
96
- electric_range: this.getElectricRange(car),
97
- fuel_consumption_discharged_battery: this.getFuelConsumptionDischargedBattery(car),
98
- co2_emissions: `${this.transformService.cO2Emission(car)}g/km`,
102
+ fuel_consumption: googleFuelConsumption(car),
103
+ energy_consumption: googleEnergyConsumption(car),
104
+ electric_range: googleElectricRange(car),
105
+ fuel_consumption_discharged_battery: googleFuelConsumptionDischarged(car),
106
+ co2_emissions: googleCo2(car),
99
107
  description: this.processText((await this.getDescription(car))),
100
108
  identifier_exists: 'yes',
101
109
  availability: 'in_stock',
@@ -121,6 +129,8 @@ export class GoogleTransformer {
121
129
  'certification',
122
130
  'energy_consumption',
123
131
  'electric_range',
132
+ 'fuel_consumption',
133
+ 'co2_emissions',
124
134
  'fuel_consumption_discharged_battery',
125
135
  'installment',
126
136
  'body_style',
@@ -288,8 +298,15 @@ export class GoogleTransformer {
288
298
  return value ? value.replace(/\+/g, '') : '';
289
299
  }
290
300
 
301
+ // ADV-187: the canonical (legally defined, CO2-derived) class lives in
302
+ // wltpEnergyEfficiencyClass — same field the website, PDF exposé and the
303
+ // Facebook feed use. The legacy NEFZ `energyEfficiencyClass` is a different
304
+ // (mass-relative) scale and is NOT validated against CO2, so it must not
305
+ // feed the BMWK Vehicle_CO2_Class certification. No fallback to the legacy
306
+ // field on purpose: an unknown WLTP class omits the certification instead of
307
+ // publishing an unvalidated one.
291
308
  private getEnergyClass(car: DataSchemes.Car): string {
292
- return this.mapEnergyClass(get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown'));
309
+ return this.mapEnergyClass(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown'));
293
310
  }
294
311
 
295
312
  private getDischargedEnergyClass(car: DataSchemes.Car): string {
@@ -322,30 +339,8 @@ export class GoogleTransformer {
322
339
  return certifications.length ? certifications : undefined;
323
340
  }
324
341
 
325
- private getEnergyConsumption(car: DataSchemes.Car): string | undefined {
326
- const fuel = get(car, 'consumption.fuel');
327
- const isElectricOrHybrid =
328
- ['selector_fuel_electrical', 'selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'].includes(fuel) ||
329
- !!get(car, 'engineData.hybridPlugin');
330
- if (!isElectricOrHybrid) {
331
- return undefined;
332
- }
333
- const power = get(car, 'consumption.wltpPowerCombined') || get(car, 'consumption.consumptionPowerCombined');
334
- return power ? `${power}kWh/100km` : undefined;
335
- }
336
342
 
337
- private getElectricRange(car: DataSchemes.Car): string | undefined {
338
- const range = get(car, 'battery.batteryRangeElectric') || get(car, 'battery.wltpRange');
339
- return range ? `${range}km` : undefined;
340
- }
341
343
 
342
- private getFuelConsumptionDischargedBattery(car: DataSchemes.Car): string | undefined {
343
- if (!get(car, 'engineData.hybridPlugin')) {
344
- return undefined;
345
- }
346
- const consumption = get(car, 'consumption.consumptionCombined') || get(car, 'consumption.wltpCombined');
347
- return consumption ? `${consumption}l/100km` : undefined;
348
- }
349
344
 
350
345
  private getInstallment(car: DataSchemes.Car) {
351
346
  const monthlyInstallment = round(get(car, 'rate.monthlyInstallment', null));
@@ -379,27 +374,6 @@ export class GoogleTransformer {
379
374
  }
380
375
  }
381
376
 
382
- private mapFuel(car: DataSchemes.Car): string {
383
- const available = {
384
- selector_fuel_petrol: 'Gasoline',
385
- selector_fuel_diesel: 'Diesel',
386
- selector_fuel_electrical: 'Electric',
387
- selector_fuel_hybridElectricalPetrol: 'Hybrid',
388
- selector_fuel_hybridElectricalDiesel: 'Hybrid',
389
- selector_fuel_carGas: 'CarGas',
390
- selector_fuel_naturalGas: 'NaturalGas',
391
- selector_fuel_ethanol: 'Ethanol',
392
- selector_fuel_hydrogen: 'Hydrogen',
393
- selector_unknown: 'Other',
394
- };
395
- const fuel = get(car, 'consumption.fuel', 'selector_unknown');
396
- let engine = get(available, fuel, 'Other')
397
- const hybridPlugin = car?.engineData?.hybridPlugin;
398
- if (engine === 'Hybrid' && hybridPlugin)
399
- engine = 'plug-in_hybrid';
400
-
401
- return engine;
402
- }
403
377
 
404
378
  private mapBodyStyle(car: DataSchemes.Car): string {
405
379
  const available = {
@@ -258,8 +258,11 @@ export class MobileTransformer {
258
258
  }
259
259
  }
260
260
 
261
+ // ADV-187: export the canonical CO2-derived WLTP class (not the unvalidated
262
+ // legacy NEFZ class) and never invent one — an unknown class is exported
263
+ // empty instead of the former hard-coded 'C'.
261
264
  private getEnergyEfficiencyClass(car: DataSchemes.Car): string {
262
- const energyEfficiencyClass = get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown');
265
+ const energyEfficiencyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
263
266
  switch (energyEfficiencyClass) {
264
267
  case 'selector_energyEfficiencyClass_appp': {
265
268
  return 'A+++';
@@ -271,8 +274,9 @@ export class MobileTransformer {
271
274
  return 'A+';
272
275
  }
273
276
  case 'selector_unknown':
277
+ case undefined:
274
278
  case null: {
275
- return 'C';
279
+ return '';
276
280
  }
277
281
  default: {
278
282
  return energyEfficiencyClass.slice(-1).toUpperCase();
@@ -148,7 +148,8 @@ export class RubrikkTransformer {
148
148
  selector_energyEfficiencyClass_g: 'G',
149
149
  selector_unknown: 'default',
150
150
  };
151
- const energyClass = get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown');
151
+ // ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
152
+ const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
152
153
  return get(available, energyClass);
153
154
  }
154
155
 
@@ -364,8 +364,11 @@ export class SantanderTransformer {
364
364
  }
365
365
  }
366
366
 
367
+ // ADV-187: export the canonical CO2-derived WLTP class (not the unvalidated
368
+ // legacy NEFZ class) and never invent one — an unknown class is exported
369
+ // empty instead of the former hard-coded 'C'.
367
370
  private getEnergyEfficiencyClass(car: DataSchemes.Car): string {
368
- const energyEfficiencyClass = get(car, 'environmentEmissions.energyEfficiencyClass', 'selector_unknown');
371
+ const energyEfficiencyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
369
372
  switch (energyEfficiencyClass) {
370
373
  case 'selector_energyEfficiencyClass_appp': {
371
374
  return 'A+++';
@@ -377,8 +380,9 @@ export class SantanderTransformer {
377
380
  return 'A+';
378
381
  }
379
382
  case 'selector_unknown':
383
+ case undefined:
380
384
  case null: {
381
- return 'C';
385
+ return '';
382
386
  }
383
387
  default: {
384
388
  return energyEfficiencyClass.slice(-1).toUpperCase();
@@ -0,0 +1,132 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ import * as assert from 'node:assert/strict';
3
+ import {
4
+ googleCo2,
5
+ googleElectricRange,
6
+ googleEnergyConsumption,
7
+ googleEngine,
8
+ googleFuelConsumption,
9
+ googleFuelConsumptionDischarged,
10
+ } from '../src/transformers/google-feed-envkv';
11
+
12
+ const car = (fuel: string, extra: Record<string, unknown> = {}): any => ({
13
+ consumption: { fuel, ...(extra.consumption as object) },
14
+ engineData: { hybridPlugin: extra.hybridPlugin },
15
+ battery: extra.battery || {},
16
+ environmentEmissions: extra.environmentEmissions || {},
17
+ });
18
+
19
+ const cases: Array<[string, () => void]> = [
20
+ ['LPG and CNG get the values Google accepts', () => {
21
+ assert.equal(googleEngine(car('selector_fuel_carGas')), 'lpg');
22
+ assert.equal(googleEngine(car('selector_fuel_naturalGas')), 'natural_gas');
23
+ }],
24
+ ['ethanol, hydrogen and unknown fuels become other', () => {
25
+ assert.equal(googleEngine(car('selector_fuel_ethanol')), 'other');
26
+ assert.equal(googleEngine(car('selector_fuel_hydrogen')), 'other');
27
+ assert.equal(googleEngine(car('selector_fuel_somethingNew')), 'other');
28
+ }],
29
+ ['existing engine values are unchanged', () => {
30
+ assert.equal(googleEngine(car('selector_fuel_petrol')), 'Gasoline');
31
+ assert.equal(googleEngine(car('selector_fuel_diesel')), 'Diesel');
32
+ assert.equal(googleEngine(car('selector_fuel_electrical')), 'Electric');
33
+ assert.equal(googleEngine(car('selector_fuel_hybridElectricalPetrol')), 'Hybrid');
34
+ assert.equal(googleEngine(car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true })), 'plug-in_hybrid');
35
+ }],
36
+ ['BEV range is the combined WLTP range, never the city range', () => {
37
+ assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRange: 451, wltpRangeCity: 590 } })), '451km');
38
+ assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRangeCity: 590 } })), undefined);
39
+ }],
40
+ ['plug-in range prefers the EAER, then the delivered range', () => {
41
+ const phev = (battery: object) => car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true, battery });
42
+ assert.equal(googleElectricRange(phev({ wltpTotalRange: 62, wltpRange: 59 })), '62km');
43
+ assert.equal(googleElectricRange(phev({ wltpTotalRange: 0, wltpRange: 59 })), '59km');
44
+ assert.equal(googleElectricRange(phev({ wltpTotalRangeCity: 70 })), undefined);
45
+ }],
46
+ ['a range of 0 counts as missing', () => {
47
+ assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRange: 0 } })), undefined);
48
+ }],
49
+ ['no range for combustion cars or non-plug-in hybrids', () => {
50
+ assert.equal(googleElectricRange(car('selector_fuel_petrol', { battery: { wltpRange: 50 } })), undefined);
51
+ assert.equal(googleElectricRange(car('selector_fuel_hybridElectricalPetrol', { battery: { wltpRange: 50 } })), undefined);
52
+ }],
53
+ ['missing or 0 fuel consumption is omitted, never "0l/100km"', () => {
54
+ assert.equal(googleFuelConsumption(car('selector_fuel_petrol', { consumption: { consumptionCombined: 0 } })), undefined);
55
+ assert.equal(googleFuelConsumption(car('selector_fuel_petrol')), undefined);
56
+ assert.equal(googleFuelConsumption(car('selector_fuel_petrol', { consumption: { consumptionCombined: 6.3 } })), '6.3l/100km');
57
+ }],
58
+ ['BEVs send no fuel consumption (CBD mirrors kWh into it)', () => {
59
+ assert.equal(googleFuelConsumption(car('selector_fuel_electrical', { consumption: { consumptionCombined: 18 } })), undefined);
60
+ }],
61
+ ['CO2: BEV is 0 g/km, other cars omit a missing or 0 value', () => {
62
+ assert.equal(googleCo2(car('selector_fuel_electrical')), '0g/km');
63
+ assert.equal(googleCo2(car('selector_fuel_petrol', { environmentEmissions: { co2: 0 } })), undefined);
64
+ assert.equal(googleCo2(car('selector_fuel_petrol', { environmentEmissions: { co2: 144 } })), '144g/km');
65
+ }],
66
+ ['plug-in: weighted fuel, discharged fuel, weighted electricity and weighted CO2', () => {
67
+ const phev = car('selector_fuel_hybridElectricalPetrol', {
68
+ hybridPlugin: true,
69
+ battery: { wltpTotalRange: 62 },
70
+ consumption: { consumptionCombined: 1.4, wltpCombined: 6.3, wltpWeightedCombined: 1.2, wltpPowerCombined: 19.3, wltpWeightedPowerCombined: 17.3 },
71
+ environmentEmissions: { co2: 121, wltpCo2: 28 },
72
+ });
73
+ assert.equal(googleFuelConsumption(phev), '1.2l/100km');
74
+ assert.equal(googleFuelConsumptionDischarged(phev), '6.3l/100km');
75
+ assert.equal(googleEnergyConsumption(phev), '17.3kWh/100km');
76
+ assert.equal(googleCo2(phev), '28g/km');
77
+ }],
78
+ ['plug-in without weighted values falls back to the legacy combined ones, never the pure-electric value', () => {
79
+ const phev = car('selector_fuel_hybridElectricalPetrol', {
80
+ hybridPlugin: true,
81
+ battery: { wltpRange: 59 },
82
+ consumption: { consumptionCombined: 1.4, wltpPowerCombined: 19.3 },
83
+ environmentEmissions: { co2: 32 },
84
+ });
85
+ assert.equal(googleFuelConsumption(phev), '1.4l/100km');
86
+ assert.equal(googleFuelConsumptionDischarged(phev), undefined);
87
+ assert.equal(googleEnergyConsumption(phev), undefined);
88
+ assert.equal(googleCo2(phev), '32g/km');
89
+ }],
90
+ ['non-plug-in hybrids and combustion cars send the WLTP values (legacy as fallback) and no electricity', () => {
91
+ const hev = car('selector_fuel_hybridElectricalPetrol', {
92
+ consumption: { consumptionCombined: 5.1, wltpCombined: 5.3, wltpWeightedCombined: 2.3, wltpWeightedPowerCombined: 21.5 },
93
+ environmentEmissions: { co2: 116, wltpCo2: 119 },
94
+ });
95
+ assert.equal(googleFuelConsumption(hev), '5.3l/100km');
96
+ assert.equal(googleFuelConsumptionDischarged(hev), undefined);
97
+ assert.equal(googleEnergyConsumption(hev), undefined);
98
+ assert.equal(googleCo2(hev), '119g/km');
99
+ // D3: Mercedes GLC 200 sent 146.7 g (legacy) while CBD / VDP / EnVKV hold the dealer's WLTP 138 g
100
+ const glc = car('selector_fuel_diesel', { consumption: { consumptionCombined: 5.6, wltpCombined: 5.2 }, environmentEmissions: { co2: 146.7, wltpCo2: 138 } });
101
+ assert.equal(googleCo2(glc), '138g/km');
102
+ assert.equal(googleFuelConsumption(glc), '5.2l/100km');
103
+ assert.equal(googleCo2(car('selector_fuel_diesel', { environmentEmissions: { co2: 150 } })), '150g/km');
104
+ }],
105
+ ['a plug-in flag without an electric range keeps the stored values (ADV-124 flags every hybrid)', () => {
106
+ const flagged = car('selector_fuel_hybridElectricalPetrol', {
107
+ hybridPlugin: true,
108
+ consumption: { consumptionCombined: 5.1, wltpCombined: 5.3, wltpWeightedCombined: 2.3, wltpPowerCombined: 21.5, wltpWeightedPowerCombined: 21.5 },
109
+ environmentEmissions: { co2: 116, wltpCo2: 116, wltpCo2Discharged: 195 },
110
+ });
111
+ assert.equal(googleFuelConsumption(flagged), '5.1l/100km');
112
+ assert.equal(googleFuelConsumptionDischarged(flagged), '5.1l/100km');
113
+ assert.equal(googleEnergyConsumption(flagged), '21.5kWh/100km');
114
+ assert.equal(googleCo2(flagged), '116g/km');
115
+ }],
116
+ ['BEV energy consumption is the combined value', () => {
117
+ assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { wltpPowerCombined: 16.5 } })), '16.5kWh/100km');
118
+ assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { consumptionPowerCombined: 18 } })), '18kWh/100km');
119
+ }],
120
+ ];
121
+
122
+ let failed = 0;
123
+ for (const [name, fn] of cases) {
124
+ try {
125
+ fn();
126
+ console.log(`ok ${name}`);
127
+ } catch (e) {
128
+ failed++;
129
+ console.log(`FAIL ${name}\n ${(e as Error).message}`);
130
+ }
131
+ }
132
+ if (failed) process.exit(1);