@bytebrand/export-cars 1.26.33 → 1.26.35

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.
@@ -147,7 +147,7 @@ Null policy "EXCLUDE" = row not written + counted in the run report under the re
147
147
  Delta (deferred, only with API access): `products[].id = item_id`, price `{"amount": round(currentSalesPrice*100), "currency":"EUR"}`.
148
148
 
149
149
  Filters before mapping (all reasons counted in the run report):
150
- `imagesCount > 0` (existing `filterCar`), price valid, optional price cap (Q9), dealer `active && !blacklist` via `CarService.getOwner` (cached per dealer, failure → EXCLUDE `dealer_unknown`), title/brand/image present.
150
+ `imagesCount > 0` (existing `filterCar`), price valid, €80k price cap (same as Google/Meta, Q9 closed 14.10; `price_cap`), dealer `active && !blacklist` via `CarService.getOwner` (cached per dealer, failure → EXCLUDE `dealer_unknown`), title/brand/image present.
151
151
 
152
152
  ---
153
153
 
@@ -177,7 +177,7 @@ Filters before mapping (all reasons counted in the run report):
177
177
  | Q6 | Description: reuse dealer summary templates (same as Google) — do they contain finance claims? Or plain generated German text only? | PM/Legal | description |
178
178
  | Q7 | `ads_metadata` keys configured in Ads Manager (if any) | Marketing | optional |
179
179
  | Q8 | `product_category` wanted? | Marketing | optional |
180
- | Q9 | Apply the 80k price cap of Google/Meta? | Marketing | filter |
180
+ | Q9 | Apply the 80k price cap of Google/Meta? — **closed 14.10: yes**, same cap and resolver (1.26.34) | Marketing | done |
181
181
  | Q10 | Import method available in the account: hosted URL (needs fetch IP/auth info) or SFTP (credentials into secret store); API access (delta, upload status)? | Marketing | transport |
182
182
  | Q11 | OAI-AdsBot / OAI-SearchBot reachability of `www.auto.de` VDP and `images.auto.de` behind Cloudflare (js_challenge rules from ADV-193). Test only, no CF change without approval. | Eng + approval | go-live |
183
183
  | Q12 | Image: ADV-336 clean profile is used (no overlay). Approval of plain photos + `ext_lr_main` question open in ADV-336 | ADV-336 | image_url |
@@ -222,7 +222,7 @@ retention 15 days; reinstatement keeps the id), run report, `RUNBOOK.md`, 23 tes
222
222
  Open questions decided in code for now (each reversible by a param or a one-line change):
223
223
  Q3 `seller_name` = `auto.de` (param); Q4 reserved/sold flag → out_of_stock, and never advertised when not sent before;
224
224
  Q5 unknown condition → `used` only with a past first registration; Q6 description from structured data only;
225
- Q9 no price cap unless `maxCurrentSalesPrice` is set; Q7/Q8 `ads_metadata` / `product_category` not sent.
225
+ Q9 (closed 14.10) €80k cap as Google/Meta; Q7/Q8 `ads_metadata` / `product_category` not sent.
226
226
 
227
227
  Found while running the compiled job (`node dist/main.js` with a stdin job): **export-cars reports every failed
228
228
  run as success.** nest-commander's default `serviceErrorHandler` only prints the error, and `main.ts` then
@@ -53,7 +53,7 @@ only. By user decision it has no schedule and no failure e-mail — nothing to c
53
53
  | `openAiAdsSellerName` | `auto.de` | `seller_name` (open question Q3) |
54
54
  | `openAiAdsRetentionDays` | 15 | days an item stays as out_of_stock after it was last in stock |
55
55
  | `openAiAdsImageConcurrency` | 16 | parallel image HEAD checks against images.auto.de |
56
- | `maxCurrentSalesPrice` | none | optional price cap (no 80k default here, Q9) |
56
+ | `maxCurrentSalesPrice` | 80000 | €80k selling-price cap, same as Google/Meta (decision 14.10); a valid positive value overrides, empty/invalid = 80000 |
57
57
  | `feedGuardMinSourceItems` | 1000 | DEV: set ~50 |
58
58
  | `feedGuardMinPublishedItems` | 500 | DEV: set ~20 |
59
59
  | `feedGuardMaxDropPercent` | 20 | vs the last good run |
@@ -94,6 +94,15 @@ running after a failure as before.
94
94
  Only `openai_ads` is protected: the feed guard keeps the last good file, and the job exits with code 1
95
95
  (`exitError` + exit 1 in `openai-ads.exporter.ts`). It has no chain, so this stops nothing else.
96
96
 
97
+ ## €80k price cap (`price_cap`, since 1.26.34)
98
+
99
+ Decision 14.10: the OpenAI feed uses the same selling-price cap as Google/Meta. `OpenAiAdsConfig` resolves
100
+ `maxCurrentSalesPrice` with the shared `resolveMaxFeedSalesPrice` (default `MAX_FEED_SALES_PRICE_EUR` = 80,000), and the
101
+ shared gate `CarService.filterCar` applies it: price must be finite, > 0 and ≤ cap (80,000.00 included, 80,000.01 not).
102
+ Cars above the cap are counted as `excluded.price_cap`. A car that was advertised and then crosses the cap stays in the
103
+ file as out_of_stock (same id, last advertised data) via the ledger — it does not just disappear.
104
+ ⚠️ DEV google/fb/facebookshop events have carried a 20000 value in the past; check the param of any new event.
105
+
97
106
  ## No finance claims (`finance_claim`, since 1.26.33)
98
107
 
99
108
  OpenAI's ad policy lists "Auto loans and leasing" outside the US as "Restricted to Select Approved Advertisers"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/export-cars",
3
- "version": "1.26.33",
3
+ "version": "1.26.35",
4
4
  "description": "",
5
5
  "author": "bytebrand",
6
6
  "license": "UNLICENSED",
@@ -11,7 +11,7 @@
11
11
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12
12
  "lint": "eslint -c .eslintrc.js --ext .ts src/",
13
13
  "start:dev": "nest start --watch",
14
- "test": "ts-node --transpile-only test/google-feed-envkv.test.ts && ts-node --transpile-only test/google-feed-item-row.test.ts && ts-node --transpile-only test/publication-gate.test.ts && ts-node --transpile-only test/images-regression.test.ts && ts-node --transpile-only test/clean-images.test.ts && ts-node --transpile-only test/image-url-validator.test.ts && ts-node --transpile-only test/openai-image-fields.test.ts && ts-node --transpile-only test/openai-ads-mapping.test.ts && ts-node --transpile-only test/openai-ads-feed-run.test.ts && ts-node --transpile-only test/openai-ads-finance.test.ts && ts-node --transpile-only test/meinstadt-mapping.test.ts && ts-node --transpile-only test/meinstadt-feed-run.test.ts && ts-node --transpile-only test/autouncle-mapping.test.ts && ts-node --transpile-only test/autouncle-feed-run.test.ts"
14
+ "test": "ts-node --transpile-only test/google-feed-envkv.test.ts && ts-node --transpile-only test/google-feed-item-row.test.ts && ts-node --transpile-only test/publication-gate.test.ts && ts-node --transpile-only test/images-regression.test.ts && ts-node --transpile-only test/clean-images.test.ts && ts-node --transpile-only test/image-url-validator.test.ts && ts-node --transpile-only test/openai-image-fields.test.ts && ts-node --transpile-only test/openai-ads-mapping.test.ts && ts-node --transpile-only test/openai-ads-feed-run.test.ts && ts-node --transpile-only test/openai-ads-finance.test.ts && ts-node --transpile-only test/openai-ads-price-cap.test.ts && ts-node --transpile-only test/meinstadt-mapping.test.ts && ts-node --transpile-only test/meinstadt-feed-run.test.ts && ts-node --transpile-only test/autouncle-mapping.test.ts && ts-node --transpile-only test/autouncle-feed-run.test.ts"
15
15
  },
16
16
  "dependencies": {
17
17
  "@bytebrand/autode-request": "^1.19.2",
@@ -53,7 +53,8 @@ export class Config {
53
53
  criteoMarketingImagesConfigId: 12,
54
54
  localConvertedDirectory: 'resources/csv_dev',
55
55
  remoteDirectory: 'import_dev',
56
- imageUrl: 'https://images.autode-dev.de/cache_test',
56
+ // image-generator writes marketing images (criteo, fb) to /generated_images on DEV too — same as PROD
57
+ imageUrl: 'https://images.autode-dev.de/generated_images',
57
58
  baseImageUrl: 'https://images.autode-dev.de',
58
59
  },
59
60
  [Environment.PRODUCTION]: {
@@ -1,6 +1,7 @@
1
1
  import { toNumber } from 'lodash';
2
2
 
3
3
  import { ExportType, FileType, ImageType } from '../enums';
4
+ import { resolveMaxFeedSalesPrice } from '../constants/feed-price.constants';
4
5
  import { Params } from '../interfaces/params.interface';
5
6
  import { FeedGuardOptions } from '../services/feed-guard';
6
7
  import { Config } from './config';
@@ -50,10 +51,9 @@ export class OpenAiAdsConfig extends Config {
50
51
  this.sellerName = text(params.openAiAdsSellerName) || OPENAI_ADS_DEFAULTS.sellerName;
51
52
  this.retentionDays = positiveNumber(params.openAiAdsRetentionDays, OPENAI_ADS_DEFAULTS.retentionDays);
52
53
  this.imageConcurrency = positiveNumber(params.openAiAdsImageConcurrency, OPENAI_ADS_DEFAULTS.imageConcurrency);
53
- // Shared price cap stays opt-in here (open question to Marketing): only an explicit
54
- // maxCurrentSalesPrice param applies it, no 80k default as for Google/Meta.
55
- const cap = positiveNumber(params.maxCurrentSalesPrice, 0);
56
- this.maxCurrentSalesPrice = cap || undefined;
54
+ // Same €80k selling-price cap as Google/Meta (decision 14.10): a valid positive
55
+ // maxCurrentSalesPrice param wins, else MAX_FEED_SALES_PRICE_EUR. Applied by CarService.filterCar.
56
+ this.maxCurrentSalesPrice = resolveMaxFeedSalesPrice(params.maxCurrentSalesPrice);
57
57
  this.feedGuard = {
58
58
  stateDir: this.stateDir,
59
59
  minSourceItems: positiveNumber(params.feedGuardMinSourceItems, OPENAI_ADS_DEFAULTS.minSourceItems),
@@ -1,4 +1,5 @@
1
1
  import { Injectable } from '@nestjs/common';
2
+ import { get, toNumber } from 'lodash';
2
3
  import { FsChunkInterface } from '@bytebrand/chunkable';
3
4
  import { LoggerService } from '@bytebrand/cronicle-helper';
4
5
  import { TranslatorExtended } from '@bytebrand/schema-tools';
@@ -11,6 +12,20 @@ import { DealerStatus, OpenAiAdsFeedRun, SourceCar } from '../services/openai-ad
11
12
  import { resolveOpenAiImageFields } from '../transformers/openai-image-fields';
12
13
  import { OpenAiAdsConfig } from '../configs/openai-ads.config';
13
14
  import { Exporter } from '../interfaces/exporter.interface';
15
+ import { OpenAiAdsExclusion } from '../interfaces/openai-ads.interface';
16
+
17
+ /**
18
+ * CarService.filterCar decides (images, €80k cap, Cronicle rules); this only names the reason for the
19
+ * run report: a rejected car with a valid price above the cap counts as price_cap, else filtered.
20
+ */
21
+ export const classifyPartnerFilter = (passed: boolean, source: SourceCar, cap: number | undefined): OpenAiAdsExclusion | null => {
22
+ if (passed) {
23
+ return null;
24
+ }
25
+ const price = toNumber(get(source, 'car.price.currentSalesPrice', null));
26
+ const hasImages = (source?.car?.metaData?.imagesCount ?? 0) > 0;
27
+ return cap && hasImages && Number.isFinite(price) && price > cap ? OpenAiAdsExclusion.priceCap : OpenAiAdsExclusion.filtered;
28
+ };
14
29
 
15
30
  /**
16
31
  * ExportType.OPENAI_ADS (ADV-335). Wires the Cronicle/Nest services into OpenAiAdsFeedRun, which
@@ -35,7 +50,9 @@ export class OpenAiAdsExporter implements Exporter {
35
50
  const run = new OpenAiAdsFeedRun(
36
51
  {
37
52
  forEachChunk: fn => this.loaderService.process((chunk: FsChunkInterface) => fn(chunk.data as SourceCar[])),
38
- filterCar: source => this.carService.filterCar(source, this.rulesService.rules),
53
+ filterCar: source => classifyPartnerFilter(
54
+ this.carService.filterCar(source, this.rulesService.rules), source, config.maxCurrentSalesPrice,
55
+ ),
39
56
  dealerStatus: ownerId => this.dealerStatus(ownerId),
40
57
  resolveImages: ({ car, images }) =>
41
58
  resolveOpenAiImageFields(this.imagesService.getCleanImages(car._id, images), validator),
@@ -46,6 +46,8 @@ export enum OpenAiAdsExclusion {
46
46
  noId = 'no_id',
47
47
  duplicateId = 'duplicate_id',
48
48
  filtered = 'filtered',
49
+ // price.currentSalesPrice above the feed cap (€80k default, maxCurrentSalesPrice param)
50
+ priceCap = 'price_cap',
49
51
  invalidPrice = 'invalid_price',
50
52
  noTitle = 'no_title',
51
53
  noBrand = 'no_brand',
@@ -30,8 +30,9 @@ export type DealerStatus = 'active' | 'inactive' | 'unknown';
30
30
  export interface OpenAiAdsRunDeps {
31
31
  // iterates the base export snapshot chunk by chunk (LoaderService.process)
32
32
  forEachChunk: (fn: (cars: SourceCar[]) => Promise<void>) => Promise<unknown>;
33
- // existing shared partner filter (images count, optional price cap, Cronicle rules)
34
- filterCar: (source: SourceCar) => boolean;
33
+ // existing shared partner filter (images count, €80k price cap, Cronicle rules): null = passes,
34
+ // else the exclusion reason (price_cap or filtered)
35
+ filterCar: (source: SourceCar) => OpenAiAdsExclusion | null;
35
36
  dealerStatus: (ownerId: string) => Promise<DealerStatus>;
36
37
  // ADV-336 clean images, validated
37
38
  resolveImages: (source: SourceCar) => Promise<OpenAiImageSelection>;
@@ -146,8 +147,9 @@ export class OpenAiAdsFeedRun {
146
147
  continue;
147
148
  }
148
149
  seen.add(id);
149
- if (!this.deps.filterCar(source)) {
150
- increment(report.excluded, OpenAiAdsExclusion.filtered);
150
+ const filtered = this.deps.filterCar(source);
151
+ if (filtered) {
152
+ increment(report.excluded, filtered);
151
153
  continue;
152
154
  }
153
155
  const mapped = mapOpenAiAdsVehicle(source.car, mapping);
@@ -9,6 +9,7 @@ import { join } from 'path';
9
9
  import { FeedGuard, FeedGuardError, FeedGuardOptions } from '../src/services/feed-guard';
10
10
  import { DealerStatus, OpenAiAdsFeedRun, SourceCar } from '../src/services/openai-ads-feed-run';
11
11
  import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
12
+ import { OpenAiAdsExclusion } from '../src/interfaces/openai-ads.interface';
12
13
  import { buildImagesService } from './fixtures/images-service.harness';
13
14
  import { NOW, car, csvObjects, images, translate } from './fixtures/openai-ads.fixture';
14
15
 
@@ -69,7 +70,10 @@ const run = async (world: World, at: Date = NOW, extra: Partial<FeedGuardOptions
69
70
  const feed = new OpenAiAdsFeedRun(
70
71
  {
71
72
  forEachChunk,
72
- filterCar: ({ car: c }) => (c?.metaData?.imagesCount ?? 0) > 0,
73
+ filterCar: ({ car: c }) => {
74
+ if (!((c?.metaData?.imagesCount ?? 0) > 0)) return OpenAiAdsExclusion.filtered;
75
+ return Number(c?.price?.currentSalesPrice) > 80_000 ? OpenAiAdsExclusion.priceCap : null;
76
+ },
73
77
  dealerStatus: async ownerId => world.dealers[ownerId] ?? 'active',
74
78
  resolveImages: async ({ car: c, images: list }) => {
75
79
  const clean = imagesService.getCleanImages(c._id, list);
@@ -160,6 +164,21 @@ const cases: Array<[string, () => Promise<void>]> = [
160
164
  assert.equal(back['car-1'].availability, 'in_stock');
161
165
  assert.equal(Object.keys(back).length, 5);
162
166
  }],
167
+ ['price crosses the €80k cap: advertised car switches to out_of_stock (same id), 80,000.00 stays', async () => {
168
+ const w = await newWorld();
169
+ await writeSource(w, [src('car-0', { 'price.currentSalesPrice': 79_990 }), src('car-1', { 'price.currentSalesPrice': 79_000 }), ...fleet(5).slice(2)]);
170
+ await run(w);
171
+ const next = new Date(NOW.getTime() + DAY);
172
+ await writeSource(w, [src('car-0', { 'price.currentSalesPrice': 80_000.01 }), src('car-1', { 'price.currentSalesPrice': 80_000 }), ...fleet(5).slice(2)], 2, next);
173
+ const report = await run(w, next);
174
+ const rows = byId(await published(w));
175
+ assert.equal(rows['car-0'].availability, 'out_of_stock');
176
+ assert.equal(rows['car-0'].price, '79990.00 EUR', 'last advertised data, not the new price');
177
+ assert.equal(rows['car-1'].availability, 'in_stock');
178
+ assert.equal(rows['car-1'].price, '80000.00 EUR');
179
+ assert.equal(report.excluded.price_cap, 1);
180
+ assert.deepEqual(report.outOfStock, { not_in_snapshot: 1 });
181
+ }],
163
182
  ['out_of_stock rows stop after the retention window', async () => {
164
183
  const w = await newWorld();
165
184
  await writeSource(w, fleet(5));
@@ -0,0 +1,69 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ // Decision 14.10: the OpenAI feed uses the same €80k selling-price cap as Google/Meta.
3
+ import * as assert from 'node:assert/strict';
4
+
5
+ import { OpenAiAdsConfig } from '../src/configs/openai-ads.config';
6
+ import { MAX_FEED_SALES_PRICE_EUR, resolveMaxFeedSalesPrice } from '../src/constants/feed-price.constants';
7
+ import { CarService } from '../src/services/car.service';
8
+ import { classifyPartnerFilter } from '../src/exporters/openai-ads.exporter';
9
+ import { OpenAiAdsExclusion as X } from '../src/interfaces/openai-ads.interface';
10
+ import { car } from './fixtures/openai-ads.fixture';
11
+
12
+ const openAiConfig = (maxCurrentSalesPrice?: string) =>
13
+ new OpenAiAdsConfig({ environment: 'develop', exportType: 'openai_ads', maxCurrentSalesPrice } as any);
14
+
15
+ // The real shared gate, with the OpenAI config (no Cronicle rules).
16
+ const filterCar = (config: OpenAiAdsConfig, price: unknown, imagesCount = 3) => {
17
+ const service = new CarService(null as any, null as any, { config } as any);
18
+ return service.filterCar({ car: car('id', { 'price.currentSalesPrice': price, 'metaData.imagesCount': imagesCount }) }, []);
19
+ };
20
+
21
+ const cases: Array<[string, () => void]> = [
22
+ ['config: default cap is the shared 80,000 (same resolver as Google/Meta)', () => {
23
+ assert.equal(MAX_FEED_SALES_PRICE_EUR, 80_000);
24
+ for (const raw of [undefined, '', ' ', 'abc', '0', '-5']) {
25
+ assert.equal(openAiConfig(raw).maxCurrentSalesPrice, 80_000, String(raw));
26
+ // google/fb/facebook-shop configs call the same resolver
27
+ assert.equal(openAiConfig(raw).maxCurrentSalesPrice, resolveMaxFeedSalesPrice(raw));
28
+ }
29
+ }],
30
+ ['config: a valid positive param wins (50000)', () => {
31
+ assert.equal(openAiConfig('50000').maxCurrentSalesPrice, 50_000);
32
+ }],
33
+ ['gate (CarService.filterCar): 80,000.00 included, 80,000.01 excluded', () => {
34
+ const config = openAiConfig();
35
+ assert.equal(filterCar(config, 80_000), true);
36
+ assert.equal(filterCar(config, '80000.00'), true);
37
+ assert.equal(filterCar(config, 79_999.99), true);
38
+ assert.equal(filterCar(config, 80_000.01), false);
39
+ assert.equal(filterCar(config, 222_148), false);
40
+ for (const bad of [0, -1, null, 'abc']) {
41
+ assert.equal(filterCar(config, bad), false, String(bad));
42
+ }
43
+ }],
44
+ ['gate with param 50000: 50,000 in, 50,000.01 out', () => {
45
+ const config = openAiConfig('50000');
46
+ assert.equal(filterCar(config, 50_000), true);
47
+ assert.equal(filterCar(config, 50_000.01), false);
48
+ }],
49
+ ['report reason: above the cap = price_cap; no photos or invalid price = filtered; passed = null', () => {
50
+ const source = (price: unknown, imagesCount = 3) => ({ car: car('id', { 'price.currentSalesPrice': price, 'metaData.imagesCount': imagesCount }), images: [] });
51
+ assert.equal(classifyPartnerFilter(false, source(80_000.01), 80_000), X.priceCap);
52
+ assert.equal(classifyPartnerFilter(false, source(95_000, 0), 80_000), X.filtered);
53
+ assert.equal(classifyPartnerFilter(false, source(0), 80_000), X.filtered);
54
+ assert.equal(classifyPartnerFilter(false, source(17_990), 80_000), X.filtered);
55
+ assert.equal(classifyPartnerFilter(true, source(17_990), 80_000), null);
56
+ }],
57
+ ];
58
+
59
+ let failed = 0;
60
+ for (const [name, fn] of cases) {
61
+ try {
62
+ fn();
63
+ console.log(`ok ${name}`);
64
+ } catch (e) {
65
+ failed += 1;
66
+ console.error(`FAIL ${name}\n ${e.message}`);
67
+ }
68
+ }
69
+ if (failed) process.exit(1);