@bytebrand/export-cars 1.26.28 → 1.26.29

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 (90) hide show
  1. package/README.md +100 -0
  2. package/dist/configs/index.d.ts +1 -0
  3. package/dist/configs/index.js +1 -0
  4. package/dist/configs/index.js.map +1 -1
  5. package/dist/configs/openai-ads.config.d.ts +22 -0
  6. package/dist/configs/openai-ads.config.js +56 -0
  7. package/dist/configs/openai-ads.config.js.map +1 -0
  8. package/dist/enums/export-type.enum.d.ts +2 -1
  9. package/dist/enums/export-type.enum.js +1 -0
  10. package/dist/enums/export-type.enum.js.map +1 -1
  11. package/dist/enums/image-type.enum.d.ts +2 -1
  12. package/dist/enums/image-type.enum.js +1 -0
  13. package/dist/enums/image-type.enum.js.map +1 -1
  14. package/dist/export.schema.d.ts +6 -2
  15. package/dist/export.schema.js +4 -0
  16. package/dist/export.schema.js.map +1 -1
  17. package/dist/exporters/index.d.ts +1 -0
  18. package/dist/exporters/index.js +1 -0
  19. package/dist/exporters/index.js.map +1 -1
  20. package/dist/exporters/openai-ads.exporter.d.ts +13 -0
  21. package/dist/exporters/openai-ads.exporter.js +91 -0
  22. package/dist/exporters/openai-ads.exporter.js.map +1 -0
  23. package/dist/formats/openai-ads-csv.builder.d.ts +15 -0
  24. package/dist/formats/openai-ads-csv.builder.js +48 -0
  25. package/dist/formats/openai-ads-csv.builder.js.map +1 -0
  26. package/dist/interfaces/openai-ads.interface.d.ts +35 -0
  27. package/dist/interfaces/openai-ads.interface.js +37 -0
  28. package/dist/interfaces/openai-ads.interface.js.map +1 -0
  29. package/dist/interfaces/params.interface.d.ts +10 -0
  30. package/dist/services/feed-guard.d.ts +44 -0
  31. package/dist/services/feed-guard.js +156 -0
  32. package/dist/services/feed-guard.js.map +1 -0
  33. package/dist/services/image-url-validator.service.d.ts +45 -0
  34. package/dist/services/image-url-validator.service.js +141 -0
  35. package/dist/services/image-url-validator.service.js.map +1 -0
  36. package/dist/services/images.service.d.ts +12 -0
  37. package/dist/services/images.service.js +46 -1
  38. package/dist/services/images.service.js.map +1 -1
  39. package/dist/services/index.d.ts +1 -0
  40. package/dist/services/index.js +1 -0
  41. package/dist/services/index.js.map +1 -1
  42. package/dist/services/openai-ads-feed-run.d.ts +74 -0
  43. package/dist/services/openai-ads-feed-run.js +151 -0
  44. package/dist/services/openai-ads-feed-run.js.map +1 -0
  45. package/dist/services/openai-ads-ledger.d.ts +19 -0
  46. package/dist/services/openai-ads-ledger.js +72 -0
  47. package/dist/services/openai-ads-ledger.js.map +1 -0
  48. package/dist/services/publication-gate.js +8 -8
  49. package/dist/transformers/openai-ads.transformer.d.ts +28 -0
  50. package/dist/transformers/openai-ads.transformer.js +144 -0
  51. package/dist/transformers/openai-ads.transformer.js.map +1 -0
  52. package/dist/transformers/openai-image-fields.d.ts +16 -0
  53. package/dist/transformers/openai-image-fields.js +34 -0
  54. package/dist/transformers/openai-image-fields.js.map +1 -0
  55. package/dist/tsconfig.build.tsbuildinfo +1 -1
  56. package/docs/openai-ads/AUDIT.md +235 -0
  57. package/docs/openai-ads/RUNBOOK.md +94 -0
  58. package/package.json +64 -64
  59. package/schema/feed-items.json +134 -134
  60. package/src/configs/config.ts +99 -99
  61. package/src/configs/index.ts +1 -0
  62. package/src/configs/openai-ads.config.ts +70 -0
  63. package/src/enums/export-type.enum.ts +2 -0
  64. package/src/enums/image-type.enum.ts +5 -0
  65. package/src/export.schema.ts +6 -0
  66. package/src/exporters/index.ts +1 -0
  67. package/src/exporters/openai-ads.exporter.ts +93 -0
  68. package/src/formats/openai-ads-csv.builder.ts +55 -0
  69. package/src/interfaces/openai-ads.interface.ts +64 -0
  70. package/src/interfaces/params.interface.ts +13 -0
  71. package/src/services/feed-guard.ts +219 -0
  72. package/src/services/image-url-validator.service.ts +181 -0
  73. package/src/services/images.service.ts +72 -0
  74. package/src/services/index.ts +1 -0
  75. package/src/services/openai-ads-feed-run.ts +226 -0
  76. package/src/services/openai-ads-ledger.ts +116 -0
  77. package/src/services/publication-gate.ts +119 -119
  78. package/src/transformers/openai-ads.transformer.ts +211 -0
  79. package/src/transformers/openai-image-fields.ts +60 -0
  80. package/test/clean-images.test.ts +86 -0
  81. package/test/fixtures/images-service.harness.ts +32 -0
  82. package/test/fixtures/images.golden.json +218 -0
  83. package/test/fixtures/imbo-images.fixture.ts +77 -0
  84. package/test/fixtures/openai-ads.fixture.ts +89 -0
  85. package/test/image-url-validator.test.ts +110 -0
  86. package/test/images-regression.test.ts +23 -0
  87. package/test/openai-ads-feed-run.test.ts +278 -0
  88. package/test/openai-ads-mapping.test.ts +136 -0
  89. package/test/openai-image-fields.test.ts +99 -0
  90. package/test/publication-gate.test.ts +86 -86
@@ -15,6 +15,20 @@ import { isNotEmptyArray } from '../utils';
15
15
  import { Config } from '../configs/config';
16
16
  import { ImageSize, ImageType } from '../enums';
17
17
 
18
+ export interface CleanImage {
19
+ url: string;
20
+ imageIdentifier: string;
21
+ imageCode: string;
22
+ // 3/4 exterior shot that may serve as the main image (image_url)
23
+ heroCandidate: boolean;
24
+ }
25
+
26
+ // Hero candidates in priority order: the 3/4 front views (ext_f_right first, matching IMAGES_SORT_ORDER).
27
+ export const CLEAN_HERO_IMAGE_CODES = ['ext_f_right', 'ext_f_left'];
28
+ // Explicit JPEG variant — the extension-less `large` URL negotiates WebP from the Accept header
29
+ // (no Vary), so a crawler could get a different format than the one we validated.
30
+ export const CLEAN_IMAGE_VARIANT = 'large-cached.jpeg';
31
+
18
32
  @Injectable()
19
33
  export class ImagesService {
20
34
  private readonly IMAGES_SORT_ORDER: { [imageCode: string]: number } = {
@@ -90,6 +104,9 @@ export class ImagesService {
90
104
  async getImages(car: DataSchemes.Car, images: ImboImageResponseInterface[], imageType: ImageType, ignoreDefImages: boolean): Promise<Array<string>> {
91
105
  if (imageType === ImageType.default) {
92
106
  return this.getDefaultImages(car._id, images, ignoreDefImages);
107
+ } else if (imageType === ImageType.clean) {
108
+ // never padded with placeholders, whatever ignoreDefImages says
109
+ return this.getCleanImageUrls(car._id, images);
93
110
  } else {
94
111
  return await this.getMarketingImages(car._id, images, ignoreDefImages);
95
112
  }
@@ -175,6 +192,61 @@ export class ImagesService {
175
192
  });
176
193
  }
177
194
 
195
+ /**
196
+ * Clean channel profile (ADV-336): the car's own photos only, as explicit JPEG URLs, in a
197
+ * deterministic order — 3/4 exterior hero first, then the remaining exterior views, then the
198
+ * interior views (IMAGES_SORT_ORDER). Feature close-ups (`_feat_`), `conext_*` and codes the
199
+ * sort order doesn't know (simple_*, promo, ...) are dropped; so are repeated imageIdentifiers.
200
+ * Returns [] when the car has no 3/4 exterior shot — callers skip the car, never substitute a
201
+ * placeholder. Does not validate the URLs (see ImageUrlValidator) and never mutates `images`.
202
+ */
203
+ getCleanImages(carId: string, images: ImboImageResponseInterface[]): CleanImage[] {
204
+ if (!isNotEmptyArray(images)) {
205
+ return [];
206
+ }
207
+
208
+ const seen = new Set<string>();
209
+ const eligible = images.filter((image) => {
210
+ const imageCode = this.getImageCode(image);
211
+ if (!image?.imageIdentifier || !imageCode || seen.has(image.imageIdentifier) || !this.isCleanImageCode(imageCode)) {
212
+ return false;
213
+ }
214
+ seen.add(image.imageIdentifier);
215
+ return true;
216
+ });
217
+ const sorted = this.sortImages(eligible);
218
+
219
+ const heroIndex = CLEAN_HERO_IMAGE_CODES
220
+ .map(code => sorted.findIndex(image => this.getImageCode(image) === code))
221
+ .find(index => index >= 0);
222
+ if (heroIndex === undefined) {
223
+ return [];
224
+ }
225
+
226
+ const ordered = [sorted[heroIndex], ...sorted.filter((_, index) => index !== heroIndex)];
227
+ return ordered.map((image) => {
228
+ const imageCode = this.getImageCode(image);
229
+ return {
230
+ url: `${this.configService.config.baseImageUrl}/carimage/${carId}/${image.imageIdentifier}/${CLEAN_IMAGE_VARIANT}`,
231
+ imageIdentifier: image.imageIdentifier,
232
+ imageCode,
233
+ heroCandidate: CLEAN_HERO_IMAGE_CODES.includes(imageCode),
234
+ };
235
+ });
236
+ }
237
+
238
+ getCleanImageUrls(carId: string, images: ImboImageResponseInterface[]): string[] {
239
+ return this.getCleanImages(carId, images).map(image => image.url);
240
+ }
241
+
242
+ private isCleanImageCode(imageCode: string): boolean {
243
+ return /^(ext|int)_/.test(imageCode) && !imageCode.includes('_feat_') && imageCode in this.IMAGES_SORT_ORDER;
244
+ }
245
+
246
+ private getImageCode(image: ImboImageResponseInterface): string | undefined {
247
+ return image?.imageCodePredicted?.split('-')[0];
248
+ }
249
+
178
250
  async getMarketingImages(carId: string, images: ImboImageResponseInterface[], ignoreDefImages: boolean): Promise<string[]> {
179
251
  const imageUrl = this.configService.config.marketingImageUrl;
180
252
 
@@ -7,3 +7,4 @@ export * from './images.service';
7
7
  export * from './rules.service';
8
8
  export * from './user.service';
9
9
  export * from './car.service';
10
+ export * from './image-url-validator.service';
@@ -0,0 +1,226 @@
1
+ import { promises as fs } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { ImboImageResponseInterface } from '@bytebrand/chunkable';
4
+ import { DataSchemes } from '@bytebrand/car-schema';
5
+
6
+ import { OpenAiAdsCsvBuilder } from '../formats/openai-ads-csv.builder';
7
+ import {
8
+ OpenAiAdsExclusion,
9
+ OpenAiAdsOutOfStockReason,
10
+ OpenAiAdsRow,
11
+ } from '../interfaces/openai-ads.interface';
12
+ import {
13
+ OpenAiAdsMappingContext,
14
+ mapOpenAiAdsVehicle,
15
+ withOpenAiImages,
16
+ } from '../transformers/openai-ads.transformer';
17
+ import { OpenAiImageSelection } from '../transformers/openai-image-fields';
18
+ import { FeedGuard, FeedGuardError, LastGood, inspectSourceSnapshot } from './feed-guard';
19
+ import { OpenAiAdsLedger } from './openai-ads-ledger';
20
+
21
+ export interface SourceCar {
22
+ car: DataSchemes.Car;
23
+ images: ImboImageResponseInterface[];
24
+ }
25
+
26
+ export type DealerStatus = 'active' | 'inactive' | 'unknown';
27
+
28
+ // Everything the run needs from the outside world — the Nest exporter wires the real services,
29
+ // tests pass fakes.
30
+ export interface OpenAiAdsRunDeps {
31
+ // iterates the base export snapshot chunk by chunk (LoaderService.process)
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;
35
+ dealerStatus: (ownerId: string) => Promise<DealerStatus>;
36
+ // ADV-336 clean images, validated
37
+ resolveImages: (source: SourceCar) => Promise<OpenAiImageSelection>;
38
+ imageStats: () => Record<string, number>;
39
+ guard: FeedGuard;
40
+ }
41
+
42
+ export interface OpenAiAdsRunOptions {
43
+ runId?: string;
44
+ dryRun: boolean;
45
+ sourceDirectory: string;
46
+ sourceBaseFileName: string;
47
+ outputFile: string;
48
+ stateDir: string;
49
+ retentionDays: number;
50
+ mapping: Omit<OpenAiAdsMappingContext, 'now'>;
51
+ now?: () => Date;
52
+ }
53
+
54
+ export interface OpenAiAdsRunReport {
55
+ runId?: string;
56
+ mode: 'dry-run' | 'live';
57
+ startedAt: string;
58
+ finishedAt?: string;
59
+ status: 'published' | 'blocked' | 'failed';
60
+ guard?: { code: string; message: string };
61
+ outputFile: string;
62
+ source: { files: number; newestChunkAt: string | null; cars: number };
63
+ rows: { inStock: number; outOfStock: number; total: number };
64
+ outOfStock: Record<string, number>;
65
+ excluded: Record<string, number>;
66
+ reservedNeverSent: number;
67
+ ledger: { before: number; after: number; expired: number };
68
+ price: { min: number | null; max: number | null };
69
+ images: Record<string, number>;
70
+ lastGood: LastGood | null;
71
+ lastGoodAgeHours: number | null;
72
+ stale: boolean;
73
+ }
74
+
75
+ const increment = (counter: Record<string, number>, key: string) => {
76
+ counter[key] = (counter[key] ?? 0) + 1;
77
+ };
78
+
79
+ /**
80
+ * One OpenAI Ads feed run (ADV-335): source check -> map + validate -> temp CSV -> count check ->
81
+ * atomic publish -> ledger + report. Nothing outside `outputFile`'s directory and `stateDir` is
82
+ * written; transport (hosted URL / SFTP) is a separate step that only ever reads the published file.
83
+ */
84
+ export class OpenAiAdsFeedRun {
85
+ constructor(private readonly deps: OpenAiAdsRunDeps, private readonly options: OpenAiAdsRunOptions) { }
86
+
87
+ private now(): Date {
88
+ return this.options.now ? this.options.now() : new Date();
89
+ }
90
+
91
+ async run(): Promise<OpenAiAdsRunReport> {
92
+ const { guard } = this.deps;
93
+ const { outputFile, stateDir, runId } = this.options;
94
+ const startedAt = this.now();
95
+ const lastGood = await guard.readLastGood();
96
+ const report: OpenAiAdsRunReport = {
97
+ runId,
98
+ mode: this.options.dryRun ? 'dry-run' : 'live',
99
+ startedAt: startedAt.toISOString(),
100
+ status: 'failed',
101
+ outputFile,
102
+ source: { files: 0, newestChunkAt: null, cars: 0 },
103
+ rows: { inStock: 0, outOfStock: 0, total: 0 },
104
+ outOfStock: {},
105
+ excluded: {},
106
+ reservedNeverSent: 0,
107
+ ledger: { before: 0, after: 0, expired: 0 },
108
+ price: { min: null, max: null },
109
+ images: {},
110
+ lastGood,
111
+ lastGoodAgeHours: guard.staleHours(lastGood, startedAt),
112
+ stale: guard.isStale(lastGood, startedAt),
113
+ };
114
+
115
+ await guard.acquireLock(runId ?? 'local', startedAt);
116
+ const tmpFile = `${outputFile}.tmp`;
117
+ try {
118
+ const snapshot = await inspectSourceSnapshot(this.options.sourceDirectory, this.options.sourceBaseFileName);
119
+ report.source.files = snapshot.files;
120
+ report.source.newestChunkAt = snapshot.newestMtime?.toISOString() ?? null;
121
+ guard.checkSource(snapshot, startedAt);
122
+
123
+ const ledgerFile = join(stateDir, 'ledger.json');
124
+ const ledger = await OpenAiAdsLedger.load(ledgerFile);
125
+ report.ledger.before = ledger.size;
126
+
127
+ await fs.mkdir(dirname(outputFile), { recursive: true });
128
+ const builder = new OpenAiAdsCsvBuilder(tmpFile);
129
+ await builder.buildStart();
130
+
131
+ const seen = new Set<string>();
132
+ const inStockIds = new Set<string>();
133
+ const freshOutOfStock = new Map<string, OpenAiAdsRow>();
134
+ const freshReasons = new Map<string, OpenAiAdsOutOfStockReason>();
135
+ const mapping: OpenAiAdsMappingContext = { ...this.options.mapping, now: startedAt };
136
+
137
+ await this.deps.forEachChunk(async (cars) => {
138
+ for (const source of cars) {
139
+ report.source.cars += 1;
140
+ const id = String(source?.car?._id ?? '');
141
+ if (id && seen.has(id)) {
142
+ increment(report.excluded, OpenAiAdsExclusion.duplicateId);
143
+ continue;
144
+ }
145
+ seen.add(id);
146
+ if (!this.deps.filterCar(source)) {
147
+ increment(report.excluded, OpenAiAdsExclusion.filtered);
148
+ continue;
149
+ }
150
+ const mapped = mapOpenAiAdsVehicle(source.car, mapping);
151
+ if (mapped.ok === false) {
152
+ increment(report.excluded, mapped.reason);
153
+ continue;
154
+ }
155
+ // only cars that would be advertised are worth a dealer/image check
156
+ if (mapped.outOfStockReason && !ledger.has(mapped.row.item_id)) {
157
+ report.reservedNeverSent += 1;
158
+ continue;
159
+ }
160
+ const dealer = await this.deps.dealerStatus(String(source.car?.metaData?.ownerId ?? ''));
161
+ if (dealer !== 'active' && !mapped.outOfStockReason) {
162
+ increment(report.excluded, dealer === 'inactive' ? OpenAiAdsExclusion.dealerInactive : OpenAiAdsExclusion.dealerUnknown);
163
+ continue;
164
+ }
165
+ const images = await this.deps.resolveImages(source);
166
+ if (!images.fields) {
167
+ if (!mapped.outOfStockReason) {
168
+ increment(report.excluded, OpenAiAdsExclusion.noImage);
169
+ }
170
+ continue;
171
+ }
172
+ const row = withOpenAiImages(mapped.row, images.fields);
173
+ if (mapped.outOfStockReason) {
174
+ freshOutOfStock.set(row.item_id, row);
175
+ freshReasons.set(row.item_id, mapped.outOfStockReason);
176
+ continue;
177
+ }
178
+ await builder.addItem(row);
179
+ inStockIds.add(row.item_id);
180
+ ledger.markInStock(row, startedAt);
181
+ report.rows.inStock += 1;
182
+ const amount = Number(row.price.split(' ')[0]);
183
+ report.price.min = report.price.min === null ? amount : Math.min(report.price.min, amount);
184
+ report.price.max = report.price.max === null ? amount : Math.max(report.price.max, amount);
185
+ }
186
+ });
187
+
188
+ const { rows: outOfStockRows, expired } = ledger.collectOutOfStock(
189
+ inStockIds, freshOutOfStock, startedAt, this.options.retentionDays,
190
+ );
191
+ for (const { row, fresh } of outOfStockRows) {
192
+ await builder.addItem(row);
193
+ increment(report.outOfStock, fresh ? freshReasons.get(row.item_id) : OpenAiAdsOutOfStockReason.notInSnapshot);
194
+ }
195
+ report.rows.outOfStock = outOfStockRows.length;
196
+ report.rows.total = await builder.buildEnd();
197
+ report.ledger.expired = expired;
198
+ report.images = this.deps.imageStats();
199
+
200
+ const counts = { sourceItems: report.source.cars, publishedItems: report.rows.inStock };
201
+ guard.checkCounts(counts, lastGood);
202
+
203
+ report.lastGood = await guard.publish(tmpFile, outputFile, counts, this.now(), runId);
204
+ await ledger.save(ledgerFile, this.now());
205
+ report.ledger.after = ledger.size;
206
+ report.status = 'published';
207
+ report.lastGoodAgeHours = 0;
208
+ report.stale = false;
209
+ return report;
210
+ } catch (error) {
211
+ if (error instanceof FeedGuardError) {
212
+ report.status = 'blocked';
213
+ report.guard = { code: error.code, message: error.message };
214
+ }
215
+ // keep the rejected file for inspection; the published file stays untouched
216
+ await fs.rename(tmpFile, `${outputFile}.rejected`).catch(() => undefined);
217
+ throw error;
218
+ } finally {
219
+ report.finishedAt = this.now().toISOString();
220
+ await fs.mkdir(stateDir, { recursive: true });
221
+ await fs.writeFile(join(stateDir, 'last-run-report.json'), JSON.stringify(report, null, 2), 'utf8');
222
+ console.log(`OpenAI ads feed report: ${JSON.stringify(report)}`);
223
+ await guard.releaseLock();
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,116 @@
1
+ import { promises as fs } from 'fs';
2
+ import { dirname } from 'path';
3
+
4
+ import { OpenAiAdsRow } from '../interfaces/openai-ads.interface';
5
+ import { asOutOfStock } from '../transformers/openai-ads.transformer';
6
+
7
+ const LEDGER_VERSION = 1;
8
+ const DAY_MS = 24 * 60 * 60 * 1000;
9
+
10
+ interface LedgerEntry {
11
+ // last run in which the item was sent in_stock (ISO)
12
+ lastInStockAt: string;
13
+ // the row as last sent in_stock, without additional_image_urls (kept small)
14
+ row: OpenAiAdsRow;
15
+ }
16
+
17
+ interface LedgerFile {
18
+ version: number;
19
+ updatedAt: string;
20
+ items: Record<string, LedgerEntry>;
21
+ }
22
+
23
+ export interface OutOfStockRow {
24
+ row: OpenAiAdsRow;
25
+ // true when the row data comes from this run's snapshot (car still online but reserved/sold)
26
+ fresh: boolean;
27
+ }
28
+
29
+ /**
30
+ * Items already sent to OpenAI as in_stock (ADV-335). The base export only contains online cars,
31
+ * so a sold, offline or deleted car simply disappears from the source — but a missing row is not a
32
+ * documented removal. Every item that was sent in_stock and is not in_stock in the current run is
33
+ * therefore sent again as out_of_stock (same item_id) until `retentionDays` after it was last in
34
+ * stock; OpenAI expires items that are not refreshed for two weeks, so the default retention
35
+ * (15 days) covers that window. A car that comes back is sent in_stock again with the same id.
36
+ *
37
+ * The ledger is changed in memory during a run and written only by the exporter after the file
38
+ * was published, so a blocked run never "forgets" items.
39
+ */
40
+ export class OpenAiAdsLedger {
41
+ private constructor(private readonly items: Record<string, LedgerEntry>) { }
42
+
43
+ static empty(): OpenAiAdsLedger {
44
+ return new OpenAiAdsLedger({});
45
+ }
46
+
47
+ static async load(file: string): Promise<OpenAiAdsLedger> {
48
+ let raw: string;
49
+ try {
50
+ raw = await fs.readFile(file, 'utf8');
51
+ } catch (error) {
52
+ if (error?.code === 'ENOENT') {
53
+ return OpenAiAdsLedger.empty();
54
+ }
55
+ throw error;
56
+ }
57
+ // A corrupt ledger must stop the run: starting empty would silently drop every out_of_stock row.
58
+ const parsed = JSON.parse(raw) as LedgerFile;
59
+ if (parsed?.version !== LEDGER_VERSION || typeof parsed.items !== 'object') {
60
+ throw new Error(`OpenAI ads ledger ${file} has an unknown format (version ${parsed?.version})`);
61
+ }
62
+ return new OpenAiAdsLedger(parsed.items);
63
+ }
64
+
65
+ get size(): number {
66
+ return Object.keys(this.items).length;
67
+ }
68
+
69
+ has(itemId: string): boolean {
70
+ return itemId in this.items;
71
+ }
72
+
73
+ markInStock(row: OpenAiAdsRow, now: Date): void {
74
+ this.items[row.item_id] = {
75
+ lastInStockAt: now.toISOString(),
76
+ row: { ...row, additional_image_urls: '' },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * out_of_stock rows for every ledger item that is not in `inStockIds`. `freshRows` holds this
82
+ * run's data for cars that are online but reserved/sold; other items reuse the last sent row.
83
+ * Items past the retention window are dropped from the ledger and not sent any more.
84
+ */
85
+ collectOutOfStock(
86
+ inStockIds: Set<string>,
87
+ freshRows: Map<string, OpenAiAdsRow>,
88
+ now: Date,
89
+ retentionDays: number,
90
+ ): { rows: OutOfStockRow[]; expired: number } {
91
+ const rows: OutOfStockRow[] = [];
92
+ let expired = 0;
93
+ for (const [itemId, entry] of Object.entries(this.items)) {
94
+ if (inStockIds.has(itemId)) {
95
+ continue;
96
+ }
97
+ if (now.getTime() - Date.parse(entry.lastInStockAt) > retentionDays * DAY_MS) {
98
+ delete this.items[itemId];
99
+ expired += 1;
100
+ continue;
101
+ }
102
+ const fresh = freshRows.get(itemId);
103
+ rows.push({ row: asOutOfStock(fresh ?? entry.row), fresh: !!fresh });
104
+ }
105
+ return { rows, expired };
106
+ }
107
+
108
+ // Write-then-rename, so a crash never leaves a half-written ledger behind.
109
+ async save(file: string, now: Date): Promise<void> {
110
+ const content: LedgerFile = { version: LEDGER_VERSION, updatedAt: now.toISOString(), items: this.items };
111
+ await fs.mkdir(dirname(file), { recursive: true });
112
+ const tmp = `${file}.tmp`;
113
+ await fs.writeFile(tmp, JSON.stringify(content), 'utf8');
114
+ await fs.rename(tmp, file);
115
+ }
116
+ }