feat(catalog): wire Fiat (p5fiat) browse — families→models two-step + de routing
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Fiat catalog browse returned 0 models because the generic single-endpoint P5
model-list flow can't express Fiat's two-level hierarchy and the drill defaulted
to the tr account (Fiat is licensed only on de-708171).

- fetchVehicleList: dispatch fiatp_parts/fiatt_parts to new fetchFiatVehicleList,
  which expands modelOverview (34 families) → models?modelFamily=N (model codes +
  year ranges) into flat catalog vehicles whose catalogPath is the maingroups
  endpoint. Verified live: 34 families → 123 models, end-to-end drill to parts+image.
- fetchMainGroups / fetchP5Restrictions: account-aware (resolveAccount → de + DE
  proxy for Fiat; tr unchanged for every other P5 brand) so browse maingroups no
  longer hit the tr demo/empty page.
- web: case-insensitive "/maingroup" gate so Fiat (lowercase /mdl/maingroups,
  already a maingroups endpoint) skips the empty restriction selector and loads
  categories directly.
- formatFiatYear: "(2016,2020)" → "2016-2020"; +unit tests.

Subgroups/parts/images already resolve account→de for Fiat (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:52:14 +03:00
parent 67513b356a
commit 20cd4fda01
4 changed files with 170 additions and 12 deletions

View File

@@ -325,3 +325,24 @@ describe("PL24Service.parseVehicleResponse — p5mitsubishi (model from 'Araç',
expect(r.year).toBe(2006);
});
});
describe("PL24Service.formatFiatYear — p5fiat model year ranges", () => {
// Fiat /extern/vehicle/models records carry values.year as "(2016,2020)".
const fy = svc as unknown as { formatFiatYear(raw: unknown): string | undefined };
it("turns '(2016,2020)' into '2016-2020'", () => {
expect(fy.formatFiatYear("(2016,2020)")).toBe("2016-2020");
});
it("handles a single / open-ended year", () => {
expect(fy.formatFiatYear("(1999,)")).toBe("1999");
expect(fy.formatFiatYear("(2007)")).toBe("2007");
});
it("returns undefined for empty / non-string input", () => {
expect(fy.formatFiatYear("")).toBeUndefined();
expect(fy.formatFiatYear("()")).toBeUndefined();
expect(fy.formatFiatYear(undefined)).toBeUndefined();
expect(fy.formatFiatYear(null)).toBeUndefined();
});
});

View File

@@ -629,16 +629,15 @@ export class PL24Service {
}> {
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
// Account-aware (de + DE proxy for Fiat). resolveAccount(undefined, …) → "de"
// only for fiatp_parts/fiatt_parts; all other P5 brands stay on tr (no proxy).
const account = await this.resolveAccount(undefined, serviceName);
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const localizedPath = restrictionPath.replace(/lang=\w+/, `lang=${this.language}`);
const url = `${this.baseUrl}${localizedPath}`;
const response = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(this.timeout),
});
const response = await this.fetchWithRetry(url, headers, serviceName, account);
if (!response.ok) {
this.logger.warn(`P5 restrictions fetch failed: HTTP ${response.status}`);
@@ -711,9 +710,20 @@ export class PL24Service {
}));
}
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
return this.fetchMainGroupsByPath(mainGroupsPath, headers);
// Account-aware: tr for most P5 brands (no proxy), de + DE proxy for Fiat.
// resolveAccount(undefined, …) → "de" only for fiatp_parts/fiatt_parts.
const account = await this.resolveAccount(undefined, serviceName);
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const localizedPath = mainGroupsPath.replace(/lang=\w+/, `lang=${this.language}`);
const response = await this.fetchWithRetry(
`${this.baseUrl}${localizedPath}`,
headers,
serviceName,
account,
);
const data = (await response.json()) as Record<string, any>;
return this.parseMainGroupsResponse(data);
} catch (error) {
const err = error as Error;
this.logger.error(`Fetch main groups error: ${err.message}`);
@@ -1982,6 +1992,13 @@ export class PL24Service {
return this.fordLegacyService.fetchVehicleListForVolvo(serviceName);
}
// Fiat (p5fiat) has a two-level model hierarchy (families → model codes) that the
// generic single-endpoint P5 flow below cannot express, and is licensed only on the
// de-708171 account. Handle it via a dedicated expander.
if (serviceName === "fiatp_parts" || serviceName === "fiatt_parts") {
return this.fetchFiatVehicleList(serviceName);
}
try {
// Try de account; fall back to main token (demo mode) if that also fails
let headers: Record<string, string>;
@@ -2101,6 +2118,122 @@ export class PL24Service {
}
}
/**
* Fiat (p5fiat) browse model list.
*
* Unlike other P5 backends, Fiat exposes a two-level hierarchy:
* modelOverview → model FAMILIES (124 SPIDER, 500L, LINEA, TIPO …)
* models?modelFamily=N → the actual model codes (+ year range)
* Each model's link.path is the maingroups endpoint consumed by the generic P5
* drill (getCategoryTree → fetchMainGroups → subgroups → bomDetails parts).
*
* Requires the de-708171 account (Fiat parts are licensed only there) and the
* DataImpulse DE proxy — both applied via fetchWithRetry(account="de").
*/
private async fetchFiatVehicleList(serviceName: string): Promise<
Array<{
vehicleId: string;
model: string;
year?: string;
engine?: string;
bodyType?: string;
transmission?: string;
market?: string;
catalogPath?: string;
metadata?: Record<string, unknown>;
}>
> {
const account = "de" as const;
try {
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const base = getServiceApiPath(serviceName); // "/p5fiat"
// 1) Model families
const ovUrl = `${this.baseUrl}${base}/extern/vehicle/modelOverview?lang=${this.language}&serviceName=${serviceName}`;
const ovResp = await this.fetchWithRetry(ovUrl, headers, serviceName, account);
const ovData = (await ovResp.json()) as Record<string, any>;
const families: any[] = (ovData.data?.records || ovData.records || []).filter(
(f: any) => !f.unavailable,
);
if (families.length === 0) {
this.logger.warn(`Fiat ${serviceName}: modelOverview returned 0 families`);
return [];
}
// 2) Expand each family → model codes (bounded concurrency to keep the one-time seed fast)
type FiatVehicle = {
vehicleId: string;
model: string;
year?: string;
catalogPath?: string;
metadata?: Record<string, unknown>;
};
const out: FiatVehicle[] = [];
const CONCURRENCY = 6;
for (let i = 0; i < families.length; i += CONCURRENCY) {
const batch = families.slice(i, i + CONCURRENCY);
const batchResults = await Promise.all(
batch.map(async (fam: any): Promise<FiatVehicle[]> => {
const famName = String(fam.values?.description || fam.description || fam.id).trim();
const modelsPath = fam.link?.path as string | undefined;
const modelsUrl = modelsPath
? modelsPath.startsWith("http")
? modelsPath
: `${this.baseUrl}${modelsPath}`
: `${this.baseUrl}${base}/extern/vehicle/models?lang=${this.language}&serviceName=${serviceName}&modelFamily=${fam.id}`;
try {
const mResp = await this.fetchWithRetry(modelsUrl, headers, serviceName, account);
const mData = (await mResp.json()) as Record<string, any>;
const models: any[] = (mData.data?.records || mData.records || []).filter(
(m: any) => !m.unavailable && m.link?.path,
);
return models.map((m: any): FiatVehicle => {
const modelCode = String(m.values?.modelCode || m.id || "");
const desc = String(m.values?.description || m.description || famName)
.replace(/\\-/g, "-") // PL24 escapes hyphens as "\-"
.replace(/\s+/g, " ")
.trim();
const year = this.formatFiatYear(m.values?.year);
return {
vehicleId: `${fam.id}:${modelCode}`,
model: year ? `${desc} (${year})` : desc,
year: year || undefined,
catalogPath: m.link.path as string,
metadata: { modelFamily: String(fam.id), modelCode, family: famName },
};
});
} catch (err) {
this.logger.warn(
`Fiat ${serviceName}: models fetch failed for family ${fam.id}: ${(err as Error).message}`,
);
return [];
}
}),
);
for (const r of batchResults) out.push(...r);
}
this.logger.log(`Fiat ${serviceName}: ${families.length} families → ${out.length} models`);
return out;
} catch (err) {
this.logger.warn(`fetchFiatVehicleList failed for ${serviceName}: ${(err as Error).message}`);
return [];
}
}
/** Fiat year values arrive as "(2016,2020)" → "2016-2020"; single/open ranges handled. */
private formatFiatYear(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
const trimmed = raw.replace(/[()]/g, "").trim();
if (!trimmed) return undefined;
const parts = trimmed
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return parts.length >= 2 ? `${parts[0]}-${parts[parts.length - 1]}` : parts[0] || undefined;
}
private parseVehicleListResponse(
data: Record<string, any>,
serviceName: string,