feat(pl24): PSA+Volvo → P5, reaktif drill freni, 1 saatlik kesinti için Telegram uyarısı #263
@@ -21,6 +21,7 @@ import { RolesGuard } from "./common/guards/roles.guard";
|
||||
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
||||
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
||||
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
||||
import { TelegramModule } from "./common/telegram.module";
|
||||
import configuration from "./config/configuration";
|
||||
import { validate } from "./config/env.validation";
|
||||
import { ContactModule } from "./contact/contact.module";
|
||||
@@ -79,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
]),
|
||||
DatabaseModule,
|
||||
RedisModule,
|
||||
TelegramModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
EmailModule,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
userBrands,
|
||||
userSubscriptions,
|
||||
} from "../database/schema/core";
|
||||
import { isPl24LeafNode } from "../integrations/pl24/pl24-tree";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import {
|
||||
type PL24DecodedCategory,
|
||||
@@ -1249,18 +1250,10 @@ export class CatalogService {
|
||||
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
|
||||
}
|
||||
|
||||
/** Shared PL24 leaf classifier — see integrations/pl24/pl24-tree. */
|
||||
private isLeafPath(linkPath: string): boolean {
|
||||
const lower = linkPath.toLowerCase();
|
||||
return (
|
||||
lower.includes("/bom/") ||
|
||||
lower.includes("/bomdetails") ||
|
||||
lower.includes("/partinfo/") ||
|
||||
// PL24 P5 leaf items endpoints — chemicals, servicepart, accessories,
|
||||
// any /extern/<kind>/(vin|mdl)_items combination. These return parts,
|
||||
// not subgroups, so they must short-circuit drill-down.
|
||||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lower) ||
|
||||
lower.includes("image-board.action") || // PSA illustration leaf
|
||||
lower.includes("json-vin-bom-detail.action")
|
||||
isPl24LeafNode({ linkPath }) || linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1367,13 +1360,9 @@ export class CatalogService {
|
||||
|
||||
return cats.map((c) => {
|
||||
const dbChildCount = childCountMap.get(c.id) || 0;
|
||||
const isLeaf =
|
||||
c.linkPath?.includes("/bom/") ||
|
||||
c.linkPath?.includes("/bomdetails") ||
|
||||
c.linkPath?.includes("/partinfo/") ||
|
||||
c.linkPath?.includes("/servicepart/vin_items") ||
|
||||
c.linkPath?.includes("image-board.action") || // PSA illustration leaf
|
||||
(!c.linkPath && dbChildCount === 0);
|
||||
const isLeaf = c.linkPath
|
||||
? isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid })
|
||||
: dbChildCount === 0;
|
||||
return {
|
||||
...c,
|
||||
schemaImageUrl: picMap.get(c.id) || null,
|
||||
|
||||
@@ -13,10 +13,7 @@ export class CategoriesController {
|
||||
}
|
||||
|
||||
@Get("tree/:vehicleId")
|
||||
async getCategoryTree(
|
||||
@Param("vehicleId") vehicleId: string,
|
||||
@Query("source") source?: string,
|
||||
) {
|
||||
async getCategoryTree(@Param("vehicleId") vehicleId: string, @Query("source") source?: string) {
|
||||
return this.categoriesService.getCategoryTree(vehicleId, source);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catal
|
||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
||||
import { isPl24GroupNode, isPl24LeafNode } from "../integrations/pl24/pl24-tree";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
@@ -896,17 +897,13 @@ export class CategoriesService {
|
||||
// subgroups. Drilling into them used to insert per-part endpoints as
|
||||
// ghost child categories — keep the regex wide so any /extern/{kind}/
|
||||
// (vin|mdl)_items endpoint is recognised, not just /servicepart/.
|
||||
const lp = linkPath.toLowerCase();
|
||||
// One shared classifier (integrations/pl24/pl24-tree) — the inline lists here,
|
||||
// in catalog.service and in the prefetch worker used to disagree, which is how
|
||||
// p5psa/p5volvo camelCase `bomDetails` leaves and `illusTable` group levels
|
||||
// ended up on the wrong side (silent empty panels / unfetched parts).
|
||||
if (
|
||||
lp.includes("/bom/") ||
|
||||
lp.includes("/bomdetails") ||
|
||||
lp.includes("/partinfo/") ||
|
||||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
|
||||
// PSA / Hyundai / Opel / Volvo image-board pages and the Ford VIN
|
||||
// vin-image-board.action equivalent. Drilling into them yields BOM rows,
|
||||
// not sub-groups — let getCategoryWithParts handle those as parts.
|
||||
lp.includes("image-board.action") ||
|
||||
lp.includes("json-vin-bom-detail.action")
|
||||
isPl24LeafNode({ linkPath }) ||
|
||||
linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
@@ -1316,9 +1313,13 @@ export class CategoriesService {
|
||||
// AND lowercase (groupReferenceTable, groupTable, groupsTable). The old
|
||||
// case-sensitive includes("Group") missed the lowercase ones (~1157 nodes),
|
||||
// so they skipped this group-drill branch and fell to the parts path.
|
||||
// A PL24 node is a parent when the shared classifier says it is not a parts
|
||||
// leaf. The old `linkWid.includes("group")` test missed p5psa `illusTable`
|
||||
// and p5volvo/p5subaru `illustrationsTable`, so those levels were fetched as
|
||||
// parts, parsed to zero rows and rendered as an empty panel with no error.
|
||||
if (
|
||||
category.source === "pl24" &&
|
||||
category.linkWid?.toLowerCase().includes("group") &&
|
||||
isPl24GroupNode({ linkPath: category.linkPath, linkWid: category.linkWid }) &&
|
||||
category.vehicleId
|
||||
) {
|
||||
const groupChildren = await this.getChildren(categoryId);
|
||||
@@ -2173,15 +2174,8 @@ export class CategoriesService {
|
||||
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
|
||||
})()
|
||||
: (() => {
|
||||
const lp = c.linkPath?.toLowerCase() ?? "";
|
||||
return (
|
||||
lp.includes("/bom/") ||
|
||||
lp.includes("/bomdetails") ||
|
||||
lp.includes("/partinfo/") ||
|
||||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
|
||||
lp.includes("image-board.action") ||
|
||||
(!c.linkPath && dbChildCount === 0)
|
||||
);
|
||||
if (!c.linkPath) return dbChildCount === 0;
|
||||
return isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid });
|
||||
})();
|
||||
return {
|
||||
...c,
|
||||
|
||||
10
apps/api/src/common/telegram.module.ts
Normal file
10
apps/api/src/common/telegram.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { TelegramService } from "./telegram.service";
|
||||
|
||||
/** Global so any module can raise an operational alert without extra wiring. */
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [TelegramService],
|
||||
exports: [TelegramService],
|
||||
})
|
||||
export class TelegramModule {}
|
||||
56
apps/api/src/common/telegram.service.ts
Normal file
56
apps/api/src/common/telegram.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Minimal Telegram alerting for operational failures that need a human now
|
||||
* (currently: PL24 auth down for an hour — i.e. the account may be banned
|
||||
* again). Fire-and-forget: a failed alert must never affect a request.
|
||||
*
|
||||
* Uses the same bot as the Süper Panel — set TELEGRAM_BOT_TOKEN and
|
||||
* TELEGRAM_CHAT_ID. Unconfigured = silently disabled (local/dev).
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramService {
|
||||
private readonly logger = new Logger(TelegramService.name);
|
||||
|
||||
private get token(): string {
|
||||
return process.env.TELEGRAM_BOT_TOKEN ?? "";
|
||||
}
|
||||
|
||||
private get chatId(): string {
|
||||
return process.env.TELEGRAM_CHAT_ID ?? "";
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(this.token && this.chatId);
|
||||
}
|
||||
|
||||
/** Send a message. Returns false when disabled or the API refused. */
|
||||
async send(text: string, opts: { silent?: boolean } = {}): Promise<boolean> {
|
||||
if (!this.isConfigured()) {
|
||||
this.logger.warn(`Telegram not configured — alert dropped: ${text.slice(0, 120)}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
disable_notification: opts.silent ?? false,
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`Telegram sendMessage failed: HTTP ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Telegram sendMessage error: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { PL24AuthService } from "./pl24-auth.service";
|
||||
|
||||
type RedisStub = {
|
||||
store: Map<string, unknown>;
|
||||
get: (k: string) => Promise<string | null>;
|
||||
set: (k: string, v: string, ttl?: number) => Promise<void>;
|
||||
exists: (k: string) => Promise<boolean>;
|
||||
getJson: (k: string) => Promise<unknown>;
|
||||
setJson: (k: string, v: unknown) => Promise<void>;
|
||||
del: (k: string) => Promise<void>;
|
||||
@@ -20,6 +23,11 @@ const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
|
||||
const counters = new Map<string, number>();
|
||||
return {
|
||||
store,
|
||||
get: async (k: string) => (store.has(k) ? String(store.get(k)) : null),
|
||||
set: async (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
exists: async (k: string) => store.has(k),
|
||||
getJson: async (k: string) => store.get(k) ?? null,
|
||||
setJson: async (k: string, v: unknown) => {
|
||||
store.set(k, v);
|
||||
@@ -27,7 +35,14 @@ const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
|
||||
del: async (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
setNx: async () => !opts.lockTaken,
|
||||
// Gerçek SET NX semantiği: var olan anahtarı ikinci kez yazmaz. Alarm
|
||||
// tekrarının bastırılması buna dayanıyor.
|
||||
setNx: async (k: string, v: string) => {
|
||||
if (opts.lockTaken && k.includes("login-lock")) return false;
|
||||
if (store.has(k)) return false;
|
||||
store.set(k, v);
|
||||
return true;
|
||||
},
|
||||
incr: async (k: string) => {
|
||||
const n = (counters.get(k) ?? 0) + 1;
|
||||
counters.set(k, n);
|
||||
@@ -63,10 +78,19 @@ const makeService = (cfgOverrides: Record<string, string> = {}, redis = makeRedi
|
||||
};
|
||||
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
|
||||
const budget = makeBudget();
|
||||
const telegram = {
|
||||
sent: [] as string[],
|
||||
isConfigured: () => true,
|
||||
send: async function (t: string) {
|
||||
this.sent.push(t);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return {
|
||||
svc: new PL24AuthService(configService, redis as never, budget as never),
|
||||
svc: new PL24AuthService(configService, redis as never, budget as never, telegram as never),
|
||||
redis,
|
||||
budget,
|
||||
telegram,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -384,3 +408,54 @@ describe("PL24AuthService — login devre kesici", () => {
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL24AuthService — 1 saatlik kesinti alarmı (Telegram)", () => {
|
||||
const loginFails = () => ({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
headers: { get: () => null, getSetCookie: () => [] },
|
||||
json: async () => ({ type: "urn:login:account-not-active", detail: "account not active" }),
|
||||
});
|
||||
|
||||
it("ilk saat içinde alarm göndermez", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
|
||||
const { svc, telegram } = makeService();
|
||||
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
||||
expect(telegram.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("kesinti 1 saati geçince tek sefer alarm gönderir", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
|
||||
const redis = makeRedis();
|
||||
// Kesinti 70 dakika önce başlamış gibi davran (paylaşılan Redis saati).
|
||||
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 70 * 60_000));
|
||||
const { svc, telegram } = makeService({}, redis);
|
||||
|
||||
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
||||
await new Promise((r) => setTimeout(r, 10)); // void alarm promise'i
|
||||
|
||||
expect(telegram.sent).toHaveLength(1);
|
||||
expect(telegram.sent[0]).toContain("PL24 giriş yapılamıyor");
|
||||
expect(telegram.sent[0]).toContain("70 dakika");
|
||||
|
||||
// Kesici açık olsa bile ikinci kez sızlanmaz (alerted anahtarı).
|
||||
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(telegram.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("giriş düzelince kurtarma mesajı gönderir ve saati sıfırlar", async () => {
|
||||
const redis = makeRedis();
|
||||
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 90 * 60_000));
|
||||
redis.store.set("pl24:auth:alerted:de", "1");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk()));
|
||||
const { svc, telegram } = makeService({}, redis);
|
||||
|
||||
await svc.getSessionCookieForAccount("de");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(telegram.sent.some((t) => t.includes("PL24 girişi düzeldi"))).toBe(true);
|
||||
expect(redis.store.has("pl24:auth:fail-since:de")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { TelegramService } from "../../common/telegram.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { PL24BudgetService } from "./pl24-budget.service";
|
||||
import { PL24_DEFAULTS, PL24_ENDPOINTS, PL24_USER_AGENT } from "./pl24.constants";
|
||||
@@ -82,6 +83,13 @@ export class PL24AuthService implements OnModuleInit {
|
||||
};
|
||||
/** Hard ceiling on logins per account per hour (PL24 counts sessions, not requests). */
|
||||
private static readonly MAX_LOGINS_PER_HOUR = 6;
|
||||
/**
|
||||
* How long PL24 auth may stay broken before a human is paged on Telegram.
|
||||
* Both previous bans were only noticed days later, from DB row counts.
|
||||
*/
|
||||
private static readonly ALERT_AFTER_MS = 60 * 60_000;
|
||||
/** Do not re-page more often than this while the outage continues. */
|
||||
private static readonly ALERT_REPEAT_MS = 6 * 60 * 60_000;
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────────
|
||||
private readonly baseUrl: string;
|
||||
@@ -98,6 +106,7 @@ export class PL24AuthService implements OnModuleInit {
|
||||
private configService: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly budget: PL24BudgetService,
|
||||
private readonly telegram: TelegramService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||
this.companyCode = this.configService.get<string>("pl24.companyCode", "");
|
||||
@@ -350,6 +359,7 @@ export class PL24AuthService implements OnModuleInit {
|
||||
if (!result.sessionToken) {
|
||||
this.breakerFail(account, result.permanent);
|
||||
const detail = result.error || "Token alinamadi";
|
||||
void this.noteLoginFailure(account, detail);
|
||||
this.logger.error(`PL24 login (${account}) failed: ${detail}`);
|
||||
throw new UnauthorizedException(`PL24 (${account}) giris hatasi: ${detail}`);
|
||||
}
|
||||
@@ -362,6 +372,7 @@ export class PL24AuthService implements OnModuleInit {
|
||||
this.sessions[account] = session;
|
||||
await this.persistSession(account, session);
|
||||
this.breakerOk(account);
|
||||
void this.noteLoginSuccess(account);
|
||||
this.logger.log(`PL24 login (${account}) successful — session established`);
|
||||
return session;
|
||||
}
|
||||
@@ -659,6 +670,15 @@ export class PL24AuthService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a request will really use (tr → de under PL24_TR_DISABLED).
|
||||
* Telemetry must log this, not the raw argument, or `proxy_logs` claims the
|
||||
* dead account served traffic.
|
||||
*/
|
||||
activeAccount(account: PL24Account): PL24Account {
|
||||
return this.effectiveAccount(account);
|
||||
}
|
||||
|
||||
/** Return ProxyAgent for account 'de', null for 'tr'. */
|
||||
async getProxyAgent4Account(account: PL24Account): Promise<any | null> {
|
||||
if (this.effectiveAccount(account) !== "de") return null;
|
||||
@@ -703,6 +723,9 @@ export class PL24AuthService implements OnModuleInit {
|
||||
private breakerGate(account: PL24Account): void {
|
||||
const b = this.loginBreaker[account];
|
||||
if (Date.now() < b.openUntil) {
|
||||
// The breaker suppresses login attempts, so without this the outage clock
|
||||
// would stop ticking exactly when the account is most likely dead.
|
||||
void this.noteLoginFailure(account, `login breaker open (${b.fails} failures)`);
|
||||
throw new UnauthorizedException(
|
||||
`PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` +
|
||||
`retrying after ${new Date(b.openUntil).toISOString()}`,
|
||||
@@ -727,6 +750,87 @@ export class PL24AuthService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outage tracking → Telegram ────────────────────────────────────────────
|
||||
|
||||
private failSinceKey(account: PL24Account): string {
|
||||
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:fail-since:${account}`;
|
||||
}
|
||||
|
||||
private alertedKey(account: PL24Account): string {
|
||||
return `${PL24_DEFAULTS.CACHE_PREFIX}auth:alerted:${account}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a login failure and page a human once the account has been unusable
|
||||
* for an hour. Shared via Redis so api+worker (and restarts) agree on when the
|
||||
* outage started — a crash-looping process must not reset the clock.
|
||||
*/
|
||||
private async noteLoginFailure(account: PL24Account, reason: string): Promise<void> {
|
||||
try {
|
||||
const key = this.failSinceKey(account);
|
||||
const existing = await this.redis.get(key);
|
||||
const since = existing ? Number(existing) : Date.now();
|
||||
if (!existing) await this.redis.set(key, String(since), 3 * 86_400);
|
||||
|
||||
const downForMs = Date.now() - since;
|
||||
if (downForMs < PL24AuthService.ALERT_AFTER_MS) return;
|
||||
if (
|
||||
!(await this.redis.setNx(
|
||||
this.alertedKey(account),
|
||||
"1",
|
||||
Math.floor(PL24AuthService.ALERT_REPEAT_MS / 1000),
|
||||
))
|
||||
) {
|
||||
return; // already paged recently
|
||||
}
|
||||
|
||||
const minutes = Math.round(downForMs / 60_000);
|
||||
const company = account === "de" ? this.companyCode2 : this.companyCode;
|
||||
await this.telegram.send(
|
||||
[
|
||||
"🔴 <b>PL24 giriş yapılamıyor</b>",
|
||||
"",
|
||||
`Hesap: <code>${company || account}</code>`,
|
||||
`Süre: <b>${minutes} dakikadır</b> başarısız`,
|
||||
`Son hata: <code>${reason.slice(0, 200)}</code>`,
|
||||
"",
|
||||
"Hesap banlanmış olabilir (önceki iki ban: tr 2026-07-24, de 2026-09-04).",
|
||||
"Katalog decode'ları PL24 olmadan pcat/emex'e düşüyor.",
|
||||
].join("\n"),
|
||||
);
|
||||
this.logger.error(`PL24 ${account}: auth down for ${minutes}m — Telegram alert sent`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`PL24 outage alert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the outage clock and, if we had paged, say it recovered. */
|
||||
private async noteLoginSuccess(account: PL24Account): Promise<void> {
|
||||
try {
|
||||
const key = this.failSinceKey(account);
|
||||
const since = await this.redis.get(key);
|
||||
if (!since) return;
|
||||
await this.redis.del(key);
|
||||
const wasAlerted = await this.redis.exists(this.alertedKey(account));
|
||||
await this.redis.del(this.alertedKey(account));
|
||||
if (wasAlerted) {
|
||||
const minutes = Math.round((Date.now() - Number(since)) / 60_000);
|
||||
const company = account === "de" ? this.companyCode2 : this.companyCode;
|
||||
await this.telegram.send(
|
||||
[
|
||||
"🟢 <b>PL24 girişi düzeldi</b>",
|
||||
"",
|
||||
`Hesap: <code>${company || account}</code>`,
|
||||
`Kesinti: ~${minutes} dakika`,
|
||||
].join("\n"),
|
||||
{ silent: true },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// never block auth on alerting
|
||||
}
|
||||
}
|
||||
|
||||
private breakerOk(account: PL24Account): void {
|
||||
const b = this.loginBreaker[account];
|
||||
if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) {
|
||||
|
||||
112
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
112
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isPl24GroupNode, isPl24LeafNode } from "./pl24-tree";
|
||||
|
||||
// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek
|
||||
// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi.
|
||||
|
||||
describe("isPl24LeafNode — canlı P5 şekilleri", () => {
|
||||
it("VW (p5vwag): maingroups/subgroups grup, bom/vin yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "mainGroupsTable",
|
||||
linkPath: "/p5vwag/extern/groups/vin_maingroups?vin=X",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "subGroupsIllusTable",
|
||||
linkPath: "/p5vwag/extern/groups/vin_subgroups_illus?maingroup=4",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "bomlist",
|
||||
linkPath: "/p5vwag/extern/bom/vin?illustration=407-000",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("PSA (p5psa): scope + mainGroups + illusTable grup, bomDetails yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "scopeTable",
|
||||
linkPath: "/p5psa/extern/group/vin/scope?modelCode=1PD2",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "mainGroupTable",
|
||||
linkPath: "/p5psa/extern/group/vin/mainGroups?scope=_FCT0001",
|
||||
}),
|
||||
).toBe(true);
|
||||
// Eski `includes("group")` kuralının kaçırdığı seviye — sessiz boş panelin kaynağı.
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "illusTable",
|
||||
linkPath: "/p5psa/extern/group/vin/illus?mainGroup=_FCT0001_FCT0512",
|
||||
}),
|
||||
).toBe(true);
|
||||
// camelCase bomDetails — eski `includes("/bomdetails")` kaçırıyordu.
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "bomlist",
|
||||
linkPath: "/p5psa/extern/details/vin/bomDetails?illustration=D2F001A48A",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Volvo (p5volvo): illustrationsTable grup, bom yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "illustrationsTable",
|
||||
linkPath: "/p5volvo/extern/groups/vin/illustration?group=0b00c8af80205942",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5volvo/extern/bom/vin?..." })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "partinfo",
|
||||
linkPath: "/p5volvo/extern/partinfo/vin?partno=36050493",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Subaru (p5subaru): subgroups/illustrations grup, bom/vin?figNum yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "illustrationsTable",
|
||||
linkPath: "/p5subaru/extern/groups/vin/illustrations?subGroup=001",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5subaru/extern/bom/vin?figNum=01" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("wid yoksa yol kalıbına düşer (eski DB satırları)", () => {
|
||||
expect(isPl24LeafNode({ linkPath: "/p5vwag/extern/bom/vin?illustration=1" })).toBe(true);
|
||||
expect(
|
||||
isPl24LeafNode({ linkPath: "/psa/peugeot_parts/vin-image-board.action?illCode=1" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hasSubgroups=true yol tahminini ezer", () => {
|
||||
expect(isPl24LeafNode({ linkPath: "/p5x/extern/unknown", hasSubgroups: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("servicepart öğe listesi yapraktır", () => {
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "servicePartsItemsTable",
|
||||
linkPath: "/p5vwag/extern/servicepart/vin_items?x=1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
65
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
65
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* One source of truth for "is this PL24 node a parts leaf or a group to drill?".
|
||||
*
|
||||
* WHY THIS FILE EXISTS (plv2.md, findings p5core-02 / psa-05 / consumers_jobs-01):
|
||||
* the same question was answered by four independent heuristics —
|
||||
* `categories.service` (twice), `catalog.service` and the prefetch worker — and
|
||||
* they disagreed. Two failure modes shipped repeatedly:
|
||||
*
|
||||
* 1. `linkWid.includes("group")` as the "is a group" test. Live P5 PSA's second
|
||||
* level has wid `illusTable`, Volvo's and Subaru's `illustrationsTable` —
|
||||
* none contain "group", so those nodes fell through to the parts fetcher,
|
||||
* which asked an *illustration list* for parts, got records with no partno,
|
||||
* and rendered a silent empty panel (`parts: []`, no error). This is the same
|
||||
* class of bug as the 2026-06 `isPsaParent` incident.
|
||||
* 2. Case-sensitive `includes("/bomdetails")` while p5psa and p5volvo spell the
|
||||
* endpoint `/details/vin/bomDetails` — so those leaves were queued as groups,
|
||||
* `getChildren` returned [] and their parts were never prefetched (live today
|
||||
* for Mitsubishi 99.9% / Fiat 80% / Renault 70% of bomDetails leaves).
|
||||
*
|
||||
* The reliable cross-brand marker is the response's own `link.wid`: `bomlist`
|
||||
* (and the service-parts/partinfo variants) means parts, anything else means
|
||||
* drill. Path matching stays as a fallback for stored rows without a wid.
|
||||
*/
|
||||
|
||||
/** `link.wid` values that identify a parts (BOM) node across every P5 backend. */
|
||||
const LEAF_WIDS = new Set(["bomlist", "bomoverviewlist", "servicepartsitemstable", "partinfo"]);
|
||||
|
||||
/** `link.wid` values that identify a drillable group node. */
|
||||
const GROUP_WID_PATTERN =
|
||||
/(group|scope|illus|illustration|catalog|model|vpages|msppages|chemicals|category|categories)/i;
|
||||
|
||||
/**
|
||||
* Path fragments that only ever appear on a parts endpoint. `image-board` has no
|
||||
* leading slash on purpose: the legacy PSA leaf is `vin-image-board.action`.
|
||||
*/
|
||||
const LEAF_PATH_PATTERN =
|
||||
/\/(bom|bomdetails|partinfo|vin_items|mdl_items|vin_bomdetails)\b|\/bom\/|\/details\/vin\/bomdetails|\/servicepart\/vin_items|image-board/i;
|
||||
|
||||
/** True when this node yields parts (never children). */
|
||||
export function isPl24LeafNode(opts: {
|
||||
linkPath?: string | null;
|
||||
linkWid?: string | null;
|
||||
hasSubgroups?: boolean | null;
|
||||
}): boolean {
|
||||
const wid = opts.linkWid?.toLowerCase().trim();
|
||||
if (wid) {
|
||||
if (LEAF_WIDS.has(wid)) return true;
|
||||
if (GROUP_WID_PATTERN.test(wid)) return false;
|
||||
}
|
||||
// Explicit DB hint wins over path guessing when there is no usable wid.
|
||||
if (opts.hasSubgroups === true) return false;
|
||||
const lp = opts.linkPath?.toLowerCase() ?? "";
|
||||
if (!lp) return false;
|
||||
return LEAF_PATH_PATTERN.test(lp);
|
||||
}
|
||||
|
||||
/** True when this node should be drilled for children. */
|
||||
export function isPl24GroupNode(opts: {
|
||||
linkPath?: string | null;
|
||||
linkWid?: string | null;
|
||||
hasSubgroups?: boolean | null;
|
||||
}): boolean {
|
||||
if (!opts.linkPath && !opts.linkWid) return false;
|
||||
return !isPl24LeafNode(opts);
|
||||
}
|
||||
@@ -56,6 +56,58 @@ const LEGACY_ARCH_SOURCE_TAG: Record<string, string> = {
|
||||
LEGACY_HYUNDAI_KIA: "hyundai-kia",
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a vinfoBasic label into a lookup key.
|
||||
*
|
||||
* JS `toLowerCase()` maps Turkish "İ" to "i" + U+0307 (combining dot), so PSA
|
||||
* labels like "AKTARMA SİSTEMLERİ" / "GÖVDE TİPİ" produced keys no lookup could
|
||||
* ever match and the transmission/body fields silently stayed null. Lower-case
|
||||
* with the Turkish locale, then strip combining marks and fold "ı" → "i" so a
|
||||
* single spelling matches both "Model yılı" and "MODEL YILI".
|
||||
*/
|
||||
export function normalizeLabel(label: string): string {
|
||||
return label
|
||||
.toLocaleLowerCase("tr")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ı/g, "i")
|
||||
.replace(/[\s/]+/g, "_")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* PSA model year: "AM 2005" → 2005. Index labels ("01 MAJÖR ENDEKS",
|
||||
* 'MAJÖR ENDEKS "0C"') are NOT years — returning one there is how a Citroën
|
||||
* ended up as a 2001 model. PSA VINs also don't encode the model year in
|
||||
* position 10, so the caller must fall back to DAM or leave it null.
|
||||
*/
|
||||
export function parsePsaModelYear(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
if (/endeks/i.test(value)) return null;
|
||||
const m = value.match(/\b(?:AM\s*)?((?:19|20)\d{2})\b/i);
|
||||
if (m) return Number(m[1]);
|
||||
const short = value.match(/^\s*AM\s*(\d{2})\s*$/i);
|
||||
if (short) {
|
||||
const n = Number(short[1]);
|
||||
return n >= 70 ? 1900 + n : 2000 + n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PSA "DAM" (e.g. "10479CJ") = days since 1976-01-01 + plant code → build date.
|
||||
* PSA model years roll in July, so a build after June belongs to the next one.
|
||||
*/
|
||||
export function damToModelYear(dam: string | null | undefined): number | null {
|
||||
if (!dam) return null;
|
||||
const m = dam.match(/^(\d{4,5})/);
|
||||
if (!m) return null;
|
||||
const date = new Date(Date.UTC(1976, 0, 1) + Number(m[1]) * 86_400_000);
|
||||
const year = date.getUTCFullYear();
|
||||
if (year < 1980 || year > 2100) return null;
|
||||
return date.getUTCMonth() >= 6 ? year + 1 : year;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PL24Service {
|
||||
private readonly logger = new Logger(PL24Service.name);
|
||||
@@ -1003,13 +1055,14 @@ export class PL24Service {
|
||||
}
|
||||
await this.budget.consume("catalog");
|
||||
const startedAt = Date.now();
|
||||
const activeAccount = this.authService.activeAccount(account);
|
||||
try {
|
||||
const response = await fetch(url, opts);
|
||||
this.budget.record({
|
||||
kind: "catalog",
|
||||
url,
|
||||
proxied,
|
||||
account,
|
||||
account: activeAccount,
|
||||
statusCode: response.status,
|
||||
success: response.ok,
|
||||
startedAt,
|
||||
@@ -1020,7 +1073,7 @@ export class PL24Service {
|
||||
kind: "catalog",
|
||||
url,
|
||||
proxied,
|
||||
account,
|
||||
account: activeAccount,
|
||||
success: false,
|
||||
startedAt,
|
||||
error,
|
||||
@@ -1109,7 +1162,7 @@ export class PL24Service {
|
||||
const label = (v.key !== undefined ? v.key : v.description) || "";
|
||||
const value = (v.key !== undefined ? v.description : v.value) || "";
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase().replace(/[\s\/]+/g, "_");
|
||||
const key = normalizeLabel(label);
|
||||
if (!(key in vehicleData)) {
|
||||
// Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5
|
||||
// backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"),
|
||||
@@ -1156,26 +1209,40 @@ export class PL24Service {
|
||||
const engineCode = lookup("motor_kodu", "engine_code");
|
||||
// Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a
|
||||
// code in parens): JLR "Motor Tipi", Toyota "ENGINE 1", MAN "Yedek motor", Suzuki "Motor No.".
|
||||
const engineLabel = lookup("motor_tipi", "engine_1", "yedek_motor", "motor_no.");
|
||||
// PSA labels the full designation "MOTOR" ("TÜRBO DİZEL DV6TED4…"), Volvo
|
||||
// "Motor" ("D4162T"), Subaru "Engine" — none of which the VAG-shaped list had.
|
||||
const engineLabel = lookup(
|
||||
"motor_tipi",
|
||||
"engine_1",
|
||||
"yedek_motor",
|
||||
"motor_no.",
|
||||
"motor",
|
||||
"engine",
|
||||
);
|
||||
|
||||
// Transmission: VAG "Şanzıman kodu"; other OEMs use their own labels — JLR "Vites Kutusu",
|
||||
// Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman",
|
||||
// Suzuki "Şanzıman numarası".
|
||||
// normalizeLabel folds ı→i and strips diacritics, so "Şanzıman kodu" and
|
||||
// "ŞANZIMAN KODU" both arrive as "sanziman_kodu". PSA uses "AKTARMA
|
||||
// SİSTEMLERİ" ("5 MEKANİK VİTES KUTUSU"), Subaru "Mission".
|
||||
const transmissionCode = lookup(
|
||||
"şanzıman_kodu",
|
||||
"sanzıman_kodu",
|
||||
"sanziman_kodu",
|
||||
"transmission_code",
|
||||
"vites_kutusu",
|
||||
"atm,mtm",
|
||||
"şanzıman",
|
||||
"şanzıman_numarası",
|
||||
"aktarma_sistemleri",
|
||||
"sanziman",
|
||||
"sanziman_numarasi",
|
||||
"mission",
|
||||
);
|
||||
|
||||
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
|
||||
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
|
||||
const bodyType =
|
||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
|
||||
lookup("karoseri", "body", "body_type") ||
|
||||
// PSA "GÖVDE TİPİ" ("4 KAPILI SEDAN"), Volvo "Kaporta Stili" ("Sedan").
|
||||
lookup("karoseri", "body", "body_type", "govde_tipi", "kaporta_stili") ||
|
||||
null;
|
||||
|
||||
// Engine description from prNr D3* (Motor nitelikleri)
|
||||
@@ -1198,7 +1265,7 @@ export class PL24Service {
|
||||
// the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix.
|
||||
const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi";
|
||||
const baseModel = isMitsubishi
|
||||
? (lookup("araç") || (data.description as string) || lookup("model") || "")
|
||||
? (lookup("arac") || (data.description as string) || lookup("model") || "")
|
||||
.replace(/\s*\([^)]*\)\s*$/, "")
|
||||
.trim()
|
||||
: lookup("model_bilgisi", "model")?.trim() ||
|
||||
@@ -1221,17 +1288,20 @@ export class PL24Service {
|
||||
// production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009").
|
||||
// Falling through to the VIN year-char (extractModelYear) is the last resort and is often
|
||||
// wrong for Mercedes (10th-char "1" → 2001 for a 2015 car), so read the dates first.
|
||||
// PSA writes "AM 2005" (or an index label that is NOT a year) and its VINs
|
||||
// do not encode the model year in position 10, so parse the PSA forms
|
||||
// first and fall back to the DAM build date before ever touching the VIN.
|
||||
year:
|
||||
parsePsaModelYear(lookup("model_yili", "year")) ||
|
||||
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
|
||||
Number.parseInt(
|
||||
(lookup("my", "üretim_tarihi", "uretim_tarihi", "teslimat_tarihi") || "").match(
|
||||
/(19|20)\d{2}/,
|
||||
)?.[0] || "",
|
||||
(lookup("my", "uretim_tarihi", "teslimat_tarihi") || "").match(/(19|20)\d{2}/)?.[0] || "",
|
||||
10,
|
||||
) ||
|
||||
damToModelYear(lookup("dam")) ||
|
||||
extractModelYear(vin) ||
|
||||
0,
|
||||
series: lookup("seri", "satis_tipi", "sales_type"),
|
||||
series: lookup("seri", "satis_tipi", "sales_type", "turu"),
|
||||
bodyType,
|
||||
engineCode:
|
||||
engineCode ||
|
||||
@@ -1245,7 +1315,7 @@ export class PL24Service {
|
||||
colorCode:
|
||||
lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") ||
|
||||
lookup("tavan_rengi", "roof_color"),
|
||||
productionDate: lookup("üretim_tarihi", "date_of_production"),
|
||||
productionDate: lookup("uretim_tarihi", "date_of_production", "teslimat_tarihi"),
|
||||
raw: data,
|
||||
catalogInfo: {
|
||||
serviceName,
|
||||
@@ -1410,9 +1480,14 @@ export class PL24Service {
|
||||
const values = (record.values as Record<string, string>) || {};
|
||||
const link = (record.link as Record<string, unknown>) || {};
|
||||
|
||||
// PSA (p5psa) shares one `record.id` (the illusPath) across many
|
||||
// illustrations — 36 live records had only 11 distinct ids — so the unique
|
||||
// key is `values.illustration` ("D2F 0 01A 48A", spaces stripped). Without
|
||||
// this the children collapse onto each other and most get dropped.
|
||||
const code =
|
||||
values.subgroup ||
|
||||
values.illustrationNumber ||
|
||||
values.illustration?.replace(/\s+/g, "") ||
|
||||
values.id ||
|
||||
values.code ||
|
||||
String(record.id || "");
|
||||
@@ -1490,11 +1565,14 @@ export class PL24Service {
|
||||
records = responseData.parts as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const partRecords = records.filter(
|
||||
(record) =>
|
||||
record.characteristic !== "sectionrow" &&
|
||||
(record.partno || (record.values as Record<string, unknown>)?.partno),
|
||||
);
|
||||
const partRecords = records.filter((record) => {
|
||||
if (record.characteristic === "sectionrow") return false;
|
||||
if (!(record.partno || (record.values as Record<string, unknown>)?.partno)) return false;
|
||||
// Volvo (p5volvo) prefixes each BOM with a header row that carries a
|
||||
// partno but no link and is flagged unavailable — a phantom part if kept.
|
||||
if (record.id === "null_null" || (record.unavailable === true && !record.link)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return partRecords.map((part) => {
|
||||
const values = (part.values as Record<string, string>) || {};
|
||||
@@ -1502,11 +1580,14 @@ export class PL24Service {
|
||||
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
||||
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
||||
|
||||
const qtyStr = values.qty || "";
|
||||
// qty (VAG/Subaru) · coef (PSA) · unit (Volvo) — same field, three names.
|
||||
const qtyStr = values.qty || values.coef || values.unit || "";
|
||||
const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
|
||||
|
||||
const remark = values.remark?.trim() || undefined;
|
||||
const modelCodes = values.modelDescription?.trim() || undefined;
|
||||
// PSA/Volvo/Subaru express applicability as `restriction`
|
||||
// ("+ DIESEL TURBO DV6TED4 WITHOUT FAP"); VAG uses modelDescription.
|
||||
const modelCodes = (values.modelDescription || values.restriction)?.trim() || undefined;
|
||||
|
||||
let superseded: { oldCode: string; newCode: string } | undefined;
|
||||
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
||||
|
||||
@@ -339,19 +339,61 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
|
||||
// PSA Group (Citroën, Peugeot)
|
||||
citroen_parts: {
|
||||
basePath: "/psa",
|
||||
apiPath: "/psa",
|
||||
architecture: "LEGACY_PSA",
|
||||
basePath: "/pl24-app/citroen_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
citroenDs_parts: {
|
||||
basePath: "/psa",
|
||||
apiPath: "/psa",
|
||||
architecture: "LEGACY_PSA",
|
||||
basePath: "/pl24-app/citroenDs_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
peugeot_parts: {
|
||||
basePath: "/psa",
|
||||
apiPath: "/psa",
|
||||
architecture: "LEGACY_PSA",
|
||||
basePath: "/pl24-app/peugeot_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Stellantis-era Opel/Vauxhall — the PSA-platform catalogue (Corsa F, Mokka B…).
|
||||
// GM-era W0L/W0V cars stay in the legacy opel_parts P4 catalogue below.
|
||||
psa_opel_parts: {
|
||||
basePath: "/pl24-app/psa_opel_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
psa_vauxhall_parts: {
|
||||
basePath: "/pl24-app/psa_vauxhall_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Subaru (own P5 backend)
|
||||
subaru_parts: {
|
||||
basePath: "/pl24-app/subaru_parts",
|
||||
apiPath: "/p5subaru",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Rest of the Fiat/Stellantis family — same /p5fiat backend as fiatp/fiatt
|
||||
abarth_parts: {
|
||||
basePath: "/pl24-app/abarth_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
alfa_parts: {
|
||||
basePath: "/pl24-app/alfa_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
jeep_parts: {
|
||||
basePath: "/pl24-app/jeep_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
lancia_parts: {
|
||||
basePath: "/pl24-app/lancia_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Ford Group
|
||||
@@ -404,14 +446,14 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
|
||||
// Volvo/Polestar
|
||||
volvo_parts: {
|
||||
basePath: "/volvo",
|
||||
apiPath: "/volvo",
|
||||
architecture: "LEGACY_VOLVO",
|
||||
basePath: "/pl24-app/volvo_parts",
|
||||
apiPath: "/p5volvo",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
polestar_parts: {
|
||||
basePath: "/volvo",
|
||||
apiPath: "/volvo",
|
||||
architecture: "LEGACY_VOLVO",
|
||||
basePath: "/pl24-app/polestar_parts",
|
||||
apiPath: "/p5volvo",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
|
||||
@@ -588,8 +630,8 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
|
||||
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
|
||||
ZFF: "fiatp_parts", // Abarth / Fiat Sport
|
||||
ZAR: "fiatp_parts", // Alfa Romeo
|
||||
ZLA: "fiatp_parts", // Lancia
|
||||
ZAR: "alfa_parts", // Alfa Romeo (kendi kataloğu, /p5fiat backend)
|
||||
ZLA: "lancia_parts", // Lancia (kendi kataloğu, /p5fiat backend)
|
||||
|
||||
// Fiat Commercial (fiatt_parts)
|
||||
ZFC: "fiatt_parts", // Fiat Commercial
|
||||
@@ -615,9 +657,16 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
|
||||
|
||||
// Opel / Vauxhall
|
||||
// Subaru (canlı WMI decode: JF1 → subaru_parts, error:false)
|
||||
JF1: "subaru_parts",
|
||||
JF2: "subaru_parts",
|
||||
// Jeep (Stellantis; /p5fiat backend)
|
||||
"1C4": "jeep_parts",
|
||||
"1J4": "jeep_parts",
|
||||
|
||||
W0L: "opel_parts", // Opel AG (Germany)
|
||||
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
||||
VXK: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
||||
VXK: "psa_opel_parts", // PSA-platform Opel (Corsa F, Mokka B) → Stellantis kataloğu /p5psa
|
||||
|
||||
// Citroën (PSA)
|
||||
VF7: "citroen_parts", // Citroën SA (France)
|
||||
@@ -846,6 +895,13 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
|
||||
vauxhall_parts: "Opel",
|
||||
// Volvo/Polestar
|
||||
volvo_parts: "Volvo",
|
||||
subaru_parts: "Subaru",
|
||||
psa_opel_parts: "Opel",
|
||||
psa_vauxhall_parts: "Opel",
|
||||
abarth_parts: "Abarth",
|
||||
alfa_parts: "Alfa Romeo",
|
||||
jeep_parts: "Jeep",
|
||||
lancia_parts: "Lancia",
|
||||
polestar_parts: "Polestar",
|
||||
// Fiat Group
|
||||
fiatp_parts: "Fiat",
|
||||
|
||||
@@ -314,3 +314,28 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── PL24 reaktif drill derinliği + backfill pacing (plv2 Faz 1 / adım 2b) ──
|
||||
// Ban'ı süren hacim, her yeni decode'da tüm ağacın gezilmesiydi (bir Passat =
|
||||
// 1.251 kategori). Fast lane artık PL24'te 1. seviyede durur; derin drill ya
|
||||
// kullanıcı tıklamasıyla ya da bütçeli backfill lane'inde olur.
|
||||
describe("PrefetchWorkerService — PL24 derinlik tavanı", () => {
|
||||
const load = async () => {
|
||||
const mod = await import("./prefetch-worker.service");
|
||||
return mod as unknown as {
|
||||
__testables?: { maxDepthFor(source: string, fast: boolean): number };
|
||||
};
|
||||
};
|
||||
|
||||
it("pl24 fast lane 1. seviyede durur, diğer kaynaklar tam derinlik kullanır", async () => {
|
||||
// maxDepthFor modül-özel; davranışı dolaylı doğrula: env varsayılanları
|
||||
process.env.PREFETCH_PL24_FAST_DEPTH = "";
|
||||
process.env.PREFETCH_MAX_DEPTH = "";
|
||||
const mod = await load();
|
||||
const fn = mod.__testables?.maxDepthFor;
|
||||
if (!fn) return; // testable export yoksa atla (davranış e2e'de doğrulanır)
|
||||
expect(fn("pl24", true)).toBe(1);
|
||||
expect(fn("pl24", false)).toBeGreaterThan(1);
|
||||
expect(fn("parts-catalogs", true)).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { and, asc, eq, gt, inArray, isNull, notExists, sql } from "drizzle-orm";
|
||||
import { CategoriesService } from "../categories/categories.service";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, vehicles } from "../database/schema/core";
|
||||
import { isPl24LeafNode } from "../integrations/pl24/pl24-tree";
|
||||
import { PostHogService } from "../posthog/posthog.service";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { QUEUE_NAMES, getBullConnection } from "./bull.config";
|
||||
@@ -170,6 +171,40 @@ const SOURCE_DAILY_MAX: Record<string, number> = {
|
||||
*/
|
||||
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 1_500;
|
||||
|
||||
/**
|
||||
* Per-job pacing for PL24 BACKFILL jobs (main lane only — a user waiting on a
|
||||
* fresh decode must never be slowed down). Both account bans followed days of
|
||||
* thousands of back-to-back PL24 calls; a paced, jittered stream looks nothing
|
||||
* like that. Set 0 to disable.
|
||||
*/
|
||||
const PL24_PACE_MS = Number(process.env.PREFETCH_PL24_DELAY_MS) || 8_000;
|
||||
|
||||
/**
|
||||
* How deep the REACTIVE (fast-lane) drill may go for PL24.
|
||||
*
|
||||
* A freshly decoded vehicle used to be walked to the bottom immediately: one
|
||||
* Passat produced 1,251 categories, one L200 2,323 — 1.4k-6.8k categories/day
|
||||
* from 3-17 decodes, which is exactly the volume that preceded both bans
|
||||
* (plv2.md §2.2). Depth 1 = top groups and their direct children; anything
|
||||
* deeper is fetched lazily when the user actually opens that node, or by the
|
||||
* budgeted backfill lane. Other sources keep MAX_DEPTH.
|
||||
*/
|
||||
const PL24_FAST_MAX_DEPTH = Number(process.env.PREFETCH_PL24_FAST_DEPTH) || 1;
|
||||
|
||||
/** Depth ceiling for this source+lane. */
|
||||
function maxDepthFor(source: string, fast: boolean): number {
|
||||
if (source === "pl24" && fast) return PL24_FAST_MAX_DEPTH;
|
||||
return MAX_DEPTH;
|
||||
}
|
||||
|
||||
/** Jittered pace so our request stream is not a metronome. */
|
||||
function jitter(ms: number): number {
|
||||
return Math.round(ms * (0.5 + Math.random()));
|
||||
}
|
||||
|
||||
/** Test-only surface for the pure helpers above. */
|
||||
export const __testables = { maxDepthFor, jitter };
|
||||
|
||||
// ── Phase-1 residue exclusion ──
|
||||
/**
|
||||
* Skip a zero-parts vehicle once this many backfill attempts have completed with
|
||||
@@ -330,6 +365,15 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {
|
||||
await new Promise((r) => setTimeout(r, PCAT_PACE_MS));
|
||||
}
|
||||
// PL24 backfill only: pace + jitter. The fast (user) lane is never delayed.
|
||||
if (
|
||||
data.source === "pl24" &&
|
||||
PL24_PACE_MS > 0 &&
|
||||
!(job.data as { fast?: boolean }).fast &&
|
||||
(job.name === "prefetch-children" || job.name === "prefetch-parts")
|
||||
) {
|
||||
await new Promise((r) => setTimeout(r, jitter(PL24_PACE_MS)));
|
||||
}
|
||||
|
||||
if (job.name === "backfill-scan") {
|
||||
return await this.processBackfillScan();
|
||||
@@ -522,8 +566,14 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
await checkCooldown(this.redis, source);
|
||||
checkTimeWindow(source);
|
||||
|
||||
if (depth >= MAX_DEPTH) {
|
||||
this.logger.warn(`[prefetch] Max depth reached for category=${categoryId}`);
|
||||
const depthCeiling = maxDepthFor(source, fast);
|
||||
if (depth >= depthCeiling) {
|
||||
// For the PL24 fast lane this is the normal stopping point, not a problem:
|
||||
// deeper nodes are drilled lazily on user click or by the backfill lane.
|
||||
const level = source === "pl24" && fast ? "log" : "warn";
|
||||
this.logger[level](
|
||||
`[prefetch] Depth ceiling ${depthCeiling} reached for category=${categoryId} (source=${source}, lane=${fast ? "fast" : "main"})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -916,13 +966,11 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
// flag (null, rare pre-migration rows) → treated as leaf, preserving the old
|
||||
// 1-level behaviour for those.
|
||||
if (source === "parts-catalogs") return hasSubgroups !== true;
|
||||
// PL24 leaf indicators
|
||||
return (
|
||||
linkPath.includes("/bom/") ||
|
||||
linkPath.includes("/bomdetails") ||
|
||||
linkPath.includes("/partinfo/") ||
|
||||
linkPath.includes("/servicepart/vin_items")
|
||||
);
|
||||
// PL24: one shared classifier (integrations/pl24/pl24-tree). The old inline
|
||||
// list was case-sensitive, so p5psa/p5volvo's camelCase `/details/vin/
|
||||
// bomDetails` was never recognised as a leaf and its parts were never
|
||||
// prefetched (still true today for Mitsubishi/Fiat/Renault).
|
||||
return isPl24LeafNode({ linkPath, hasSubgroups });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -144,6 +144,12 @@ services:
|
||||
- PREFETCH_MAX_DEPTH=${PREFETCH_MAX_DEPTH:-}
|
||||
- PREFETCH_RATE_PL24=${PREFETCH_RATE_PL24:-}
|
||||
- PREFETCH_DAILY_PL24=${PREFETCH_DAILY_PL24:-}
|
||||
- PREFETCH_PL24_FAST_DEPTH=${PREFETCH_PL24_FAST_DEPTH:-}
|
||||
- PREFETCH_PL24_DELAY_MS=${PREFETCH_PL24_DELAY_MS:-}
|
||||
- PL24_HTTP_DAILY_MAX=${PL24_HTTP_DAILY_MAX:-}
|
||||
- PL24_HTTP_USER_RESERVE=${PL24_HTTP_USER_RESERVE:-}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||
- PREFETCH_RATE_EMEX=${PREFETCH_RATE_EMEX:-}
|
||||
- PREFETCH_RATE_PCAT=${PREFETCH_RATE_PCAT:-}
|
||||
- PREFETCH_PCAT_DELAY_MS=${PREFETCH_PCAT_DELAY_MS:-}
|
||||
|
||||
Reference in New Issue
Block a user