Реестр ОТТС не ответил: {err}. Это сбой источника, а не ответ «такой машины нет».
;
if (!r) return
{v.confirmed ? v.confirmed.model : carTitle(v)}
api(`/api/vin/${vin}`).then(setV).catch(() => {})} />
{!v.alias && {v.untranslated || `${v.brand} ${v.sys}`} — без перевода
{STAFF && " (русское название задаётся в админке → Модели)"}
}
Двигатель
{[v.engine, v.displacement].filter(Boolean).join(" · ")}
Коробка
{v.transmission || "—"}
Год по VIN
{v.vin_year || "—"}
Кузов
{[v.body, v.fuel].filter(Boolean).join(" · ")}
Размер колёс — по нему подбираются тормоза
Спросите клиента:
{v.rims.map((r) => (
setQ({ rim: rim === r.rim ? "" : r.rim, mjsid: "" })}>{r.rim}
))}
VIN размер колёс не определяет. {v.rims.map((r) => `${r.rim}: ${r.tyres.join(", ")}`).join(" · ")}
По VIN поставщик отдаёт {count(v.trims.length, "комплектацию", "комплектации", "комплектаций")} за {v.years.join(", ")} гг. — до одной не сужает.
{rim && rimMatchVinYear.length === 1 && <> Для {v.vin_year} года и {rim} подходит одна — выберите её.>}
{rim && rimMatchVinYear.length > 1 && <> Для {v.vin_year} года и {rim} подходят {rimMatchVinYear.length}: для тормозов разницы обычно нет, для салона и электрики может понадобиться уточнение.>}
{yearKnown ? `Комплектации ${v.vin_year} года` : "Все комплектации (год из VIN ни с одной не совпал)"}{rim ? ` · ${rim} выделены` : ""}
{sorted(vinYear).map(TrimRow)}
{!showAll && other.length > 0 && (
setShowAll(true)}>Другие годы выпуска ({other.length})
)}
{showAll && sorted(other).map(TrimRow)}
{!selected && {rim ? "Выберите комплектацию" : "Сначала размер колёс, потом комплектация"} }
go(`/vin/${vin}/parts?mjsid=${mjsid}${rim ? `&rim=${rim}` : ""}`)}>Перейти к подбору деталей
);
}
// ------------------------------------------------------------ копирование номера
async function copyText(text) {
try { await navigator.clipboard.writeText(text); return true; } catch (e) {}
try { // запасной путь: старые браузеры / не-https
const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok;
} catch (e) { return false; }
}
function PartNumber({ p, big, small, onRevealed }) {
// Номера в ответе нет, пока он не раскрыт — только идентификатор и хвост для узнавания.
const [num, setNum] = useState(p.partNumber || null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
useEffect(() => { setNum(p.partNumber || null); }, [p.pn_id, p.partNumber]);
const reveal = async () => {
setBusy(true); setErr("");
try {
// ключ и группу шлём для статистики спроса: без них видно «раскрыли номер», но не «что искали»
const r = await api("/api/reveal", { body: { pn_id: p.pn_id, source: p.src || "card",
key: p.key, group: p.grp || null } });
setNum(r.part_number);
if (onRevealed) onRevealed(r); // замены открываются тем же нажатием, списание одно
await copyText(r.part_number);
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
if (!num) return (
<>
0} aria-controls={lid}
aria-activedescendant={hi >= 0 ? `${lid}-${hi}` : undefined}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} onKeyDown={onKey} />
{open && shown.length > 0 && (
{shown.map((it, i) => (
{ e.preventDefault(); pick(it); }} onMouseEnter={() => setHi(i)}>
{it.name_ru || it.name}
{it.comment_ru && {it.comment_ru} }
{it.partNumber || `···${it.pn_tail || ""}`}
{liveFeature(it).text}
))}
{items.length > shown.length && ещё {items.length - shown.length} — нажмите «Найти», чтобы увидеть все }
)}
{open && (state === "wait" || state === "loading") && !shown.length &&
ищу детали этой машины…
}
{open && state === "ready" && !shown.length &&
У этой машины по такому запросу деталей не нашлось.
}
{open && state === "nodict" &&
Такого слова в словаре нет. Нажмите «Найти» — запрос запишется, словарь пополним.
}
{open && state === "error" &&
Поставщик не ответил — нажмите «Найти».
}
);
}
// ------------------------------------------------------------ SHICHEDAO
function VehicleScd({ v, setCtx }) {
useEffect(() => { setCtx(`${v.cars[0].title_ru || v.cars[0].title} · SHICHEDAO`); }, [v]);
return (
{ e.stopPropagation(); onClick(); }} />;
}
function ScdDiagram({ vid, route, setCtx }) {
const { main, sub, img, lock } = route.q;
const [d, setD] = useState(null), [err, setErr] = useState(""), [sel, setSel] = useState(null), [full, setFull] = useState(false);
const [nat, setNat] = useState(null);
useEffect(() => {
api(`/api/scd/v/${vid}/diagram?${new URLSearchParams({ main, sub, img, lock: lock || 0 })}`)
.then((x) => { setD(x); setCtx(`${x.vehicle.title} · схема`); }).catch((x) => setErr(x.message));
}, [vid, img]);
const W = (d && d.width) || (nat && nat.w), H = (d && d.height) || (nat && nat.h);
const withCoords = d ? d.parts.filter((p) => p.coords.length).length : 0;
const pick = (pos) => { setSel(pos); const el = document.getElementById(`pp-${pos}`); if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" }); };
const renderImg = (big) => (
!big && setFull(true)}>
setNat({ w: e.target.naturalWidth, h: e.target.naturalHeight })} />
{W && H && d.parts.map((p) => p.coords.map((c, k) => (
pick(p.pos)} />)))}
);
return (
{err &&
{err}
}
{!d && !err &&
Загружаю схему…
}
{d && (
{d.name_ru || d.name} {d.name}
{withCoords ? `кликабельных позиций: ${withCoords}` : "координат нет"} · SHICHEDAO
{d.src ? renderImg(false) :
Картинки схемы нет
}
Нажмите на номер на схеме — деталь подсветится в списке. Нажмите на картинку вне номеров — во весь экран.
Детали на схеме · {d.parts.length}
{d.parts.map((p, i) => (
setSel(p.pos)} style={{ cursor: "pointer" }}>
{p.pos || "–"}
{p.partNumber}
{p.name_ru || перевода нет } {p.name}
{p.memo &&
{p.memo_ru || p.memo}
}
{p.badge === "analog" ? "НЕСКОЛЬКО ВАРИАНТОВ" : BADGE[p.badge].text}
{p.qty && кол-во: {p.qty} }{p.years && {p.years} }
{!p.coords.length && нет на схеме }
))}
{d.parts.some((p) => p.partNumber) &&
copyRevealed(d.parts)}>Скопировать раскрытые номера }
)}
{full && d && (
setFull(false)}>
setFull(false)}>Закрыть
e.stopPropagation()}>{renderImg(true)}
)}
);
}
// ------------------------------------------------------------ экран машины (главный): карточка · группы · поиск · результаты
function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) {} }
const PAGE = 20;
function Crumbs({ items }) {
return {items.filter(Boolean).map((x, i, a) => (
{x.href ? {x.t} : {x.t} }{i < a.length - 1 && / } ))} ;
}
function Fact({ k, v, mono }) {
if (v == null || v === "") return null;
return ;
}
function Pager({ page, pages, onPage }) {
if (pages <= 1) return null;
return (
onPage(page - 1)}>←
{Array.from({ length: pages }, (_, i) => (
onPage(i)}>{i + 1} ))}
onPage(page + 1)}>→
);
}
function TrimPicker({ v, trim, onPick, onClose }) {
const pool = v.trims.filter((t) => t.vin_year).length ? v.trims.filter((t) => t.vin_year) : v.trims;
const [sel, setSel] = useState(() => Object.fromEntries(v.features.map((f) => [f.key, trim.feat[f.key]])));
const fits = (s) => pool.filter((t) => v.features.every((f) => !s[f.key] || t.feat[f.key] === s[f.key]));
const match = fits(sel);
// выбрал значение, несовместимое с остальными (R19 + передний привод) → остальные подстраиваются под первую подходящую
const choose = (key, value) => {
const next = { ...sel, [key]: sel[key] === value ? null : value };
if (!next[key] || fits(next).length) { setSel(next); return; }
const t = pool.find((x) => x.feat[key] === value);
setSel(Object.fromEntries(v.features.map((f) => [f.key, t ? t.feat[f.key] : null])));
};
return (
e.stopPropagation()}>
Уточнить комплектацию
Только признаки, которыми комплектации {v.vin_year} года реально различаются. Номера тормозов зависят от колёс и привода.
{v.features.map((f) => (
{f.label}
{f.values.map((x) => (
choose(f.key, x.value)}>{x.value} ))}
))}
{!v.features.length &&
Комплектации этого года ничем не различаются.
}
подходит комплектаций: {match.length}
Отмена
onPick(match[0])}>Применить
);
}
function ResultsList({ res, onScheme, page, setPage, vin }) {
const groups = useMemo(() => (res ? mergeParts(res.parts) : []), [res]);
if (!res) return null;
if (res.mode === "no_dict") return (
Слова «{res.q}» нет в словаре — поставщику искать нечего. Запрос записан, словарь пополним.
{res.suggestions.length > 0 &&
Похожие: {res.suggestions.join(", ")}
}
);
const pages = Math.ceil(groups.length / PAGE);
const shown = groups.slice(page * PAGE, page * PAGE + PAGE);
return (
{res.mode === "group" ? `Группа «${res.q}»` : res.mode === "oe" ? "Поиск по номеру" : res.mode === "pick"
? `Выбрано из списка · «${res.q}»${res.parts.length > 1 ? ` · ${count(res.parts.length, "вариант", "варианта", "вариантов")} этой позиции` : ""}` : `«${res.q}»`}{res.mode !== "pick" ? ` · найдено ${count(res.parts.length, "деталь", "детали", "деталей")}` : ""}
{res.reps && res.reps.length > 1 && сверено по {count(res.reps.length, "комплектации", "комплектациям", "комплектациям")} ({res.reps.map((r) => r.combo).join(" / ")}) }
{res.parts.length === 0 &&
Поставщик ничего не нашёл.
}
{shown.map((list, i) => (
{list.length > 1
?
: list.map((p) => )}
))}
{ setPage(i); window.scrollTo({ top: 0, behavior: "smooth" }); }} />
);
}
// Машина, у которой поставщик не отдаёт деталей ВООБЩЕ. Говорим это один раз и на уровне
// машины: гонять человека по двенадцати пустым группам — худший способ сообщить новость.
function Mismatch({ v }) {
if (!v || !v.mismatch || !v.mismatch.length) return null;
const m = v.mismatch[v.mismatch.length - 1];
return (
Каталог может не соответствовать машине. {m.who} сообщил, что это «{m.model}»
(источник: {m.source}). Имя машины мы не меняли — каталог остался прежним. Сверяйте детали
по самой машине.
);
}
// Каталога по самой машине нет, но есть точно такая же с рабочим каталогом. Показываем её,
// не делая вид, что это каталог клиентского VIN: разница ровно в этом предложении.
function Substitute({ sub }) {
if (!sub) return null;
const what = [sub.model, sub.year, sub.engine].filter(Boolean).join(", ");
return (
Каталог такой же машины{what ? ` (${what})` : ""}. Поставщик не привязал детали
к вашему VIN, поэтому показываем машину той же группы, мотора и коробки
{sub.exact ? " и того же года" : " и соседнего года выпуска"}. Номера сверяйте
по снятой детали.
);
}
function NoParts({ v }) {
if (!v || !v.no_parts || v.substitute) return null;
return (
По этой машине у поставщика нет каталога деталей. Проверены две группы — пусто в обеих,
значит пусто и в остальных. VIN разобран верно, но детали к нему не привязаны.
Что делать: искать по машине-донору — той же платформы, с китайским VIN.
Ручной выбор марки и модели скоро появится здесь же.
);
}
function CarScreen({ vin, route, setCtx, setLastSearch }) {
const provider = route.q.provider || "";
const [v, setV] = useState(null), [err, setErr] = useState("");
const [groups, setGroups] = useState([]), [grp, setGrp] = useState("");
const [q, setQ] = useState(""), [res, setRes] = useState(null), [busy, setBusy] = useState(""), [page, setPage] = useState(0);
const [picker, setPicker] = useState(false), [mjsid, setMjsid] = useState(null);
const [carMore, setCarMore] = useState(false); // китайское название и поколение — по запросу
useEffect(() => { api(`/api/vin/${vin}${provider ? `?provider=${provider}` : ""}`).then(setV).catch((x) => setErr(x.message)); api("/api/groups").then(setGroups).catch(() => {}); }, [vin, provider]);
useEffect(() => {
if (!v) return;
if (!v.found) { go(`/vin/${vin}?provider=${v.provider}`); return; } // не найден → экран с причиной и выбором модели
if (v.provider === "mattrio") setMjsid(ssGet(`vc_trim_${vin}`) || v.trims[0].mjsid);
setCtx(v.provider === "shichedao" ? (v.cars[0].title_ru || v.cars[0].title) : `${carTitle(v)} · ${v.displacement || ""} · ${v.vin_year || ""}`);
}, [v]);
if (err) return ;
if (!v || !v.found || (v.provider === "mattrio" && !mjsid)) return загрузка…
;
const scd = v.provider === "shichedao";
const car = scd ? v.cars[0] : null;
const trim = scd ? null : (v.trims.find((t) => t.mjsid === mjsid) || v.trims[0]);
const run = async (kind, value) => {
setBusy(kind === "group" ? `Собираю группу «${value}»…` : "Ищу…"); setErr(""); setPage(0);
try {
let r;
if (scd) r = await api(`/api/scd/v/${car.id}/search`, { body: { q: value } });
else if (kind === "group") r = await api("/api/group", { body: { vin, mjsid, group: value } });
else r = await api("/api/search", { body: { vin, mjsid, q: value } });
setRes(r); if (!scd) setLastSearch({ vin, mjsid, q: value, res: r });
} catch (x) { setErr(x.message); }
setBusy("");
};
const pickLive = (it, items) => {
// деталь из списка → полная карточка. Показываем ВСЕ варианты этой позиции (разные заводы, разная цена),
// иначе теряем альтернативы: у F083502080SB и F183502080 одна позиция, но разные производители.
setGrp(""); setPage(0);
const same = (items || []).filter((x) => x.key === it.key && (x.name || "") === (it.name || "")
&& (!it.full.ref || !x.full.ref || x.full.ref === it.full.ref));
const list = [it, ...same.filter((x) => x !== it)].map((x) => x.full);
const r = { mode: "pick", q: it.word, keys: [it.key], suggestions: [], parts: list };
setRes(r); if (!scd) setLastSearch({ vin, mjsid, q: it.word, res: r });
api("/api/live/pick", { body: { word: it.word, pn_id: it.pn_id, key: it.key } }).catch(() => {});
};
const pickGroup = (g) => {
setGrp(g);
if (!g) return;
if (scd) { setRes(null); return; } // SHICHEDAO платный — показываем слова группы, каждое = 1 вызов
setQ(""); run("group", g);
};
const onScheme = (p) => go(`/vin/${vin}/scheme?mjsid=${mjsid}&image=${encodeURIComponent(p.image)}&prefix=${encodeURIComponent(p.prefix || v.prefix || "")}&focus=${encodeURIComponent(p.pn_id)}`);
const path = scd ? (car.path || "").split(">").map((x) => x.trim()).filter(Boolean) : null;
const crumbs = scd
? [{ t: "Главная", href: "#/" }, ...path.slice(0, 3).map((t) => ({ t }))]
// поколение в крошки не ставим: это внутренняя китайская метка поставщика (探索06 2023-)
: [{ t: "Главная", href: "#/" }, { t: v.brand_ru || v.brand }, { t: v.alias || `${v.untranslated || v.sys} — без перевода` }];
const grpWords = (groups.find((g) => g.group === grp) || {}).words || [];
return (
{scd ? "SHICHEDAO" : "MATTRIO"} · VIN
{route.q.fb &&
Ответил {scd ? "SHICHEDAO" : "MATTRIO"}: {PROV[route.q.fb] || route.q.fb} этот VIN не разобрал — сработал запасной путь.
}
{vin}
{scd ? <>
{car.title_ru || car.title}
{car.title}
{v.cars.length > 1 && VIN подходит к {v.cars.length} вариантам каталога, показан первый.
}
Каталог по узлам (схемы)
> : <>
{v.confirmed ? v.confirmed.model : carTitle(v)}
{v.models_many && возможные модели }
api(`/api/vin/${vin}${provider ? `?provider=${provider}` : ""}`).then(setV).catch(() => {})} />
a.indexOf(x) === i).join(" · ")} />
{v.alias
?
: }
setCarMore(!carMore)}>{carMore ? "скрыть" : "подробнее"}
{carMore && <>
>}
Комплектация
{v.features.length > 0 &&
различаются: {v.features.map((f) => f.label.toLowerCase()).join(", ")}
}
>}
{/* Вердикт может родиться в том же запросе, что и первый клик по группе: тогда
подстановка приходит в ОТВЕТЕ, а карточка машины про неё ещё не знает. */}
Группа запчастей
pickGroup(e.target.value)}>
Группа запчастей…
{groups.map((g) => {g.group} )}
{scd && grp && (
У SHICHEDAO каждый поиск платный — выберите деталь группы:
{grpWords.map((w) => { setQ(w); run("q", w); }}>{w} )}
)}
{busy && {busy}
}
{err && {err}
}
{!res && !busy && (
{groups.map((g) => (
pickGroup(g.group)}>
{g.group} {g.words.slice(0, 5).join(", ")}{g.words.length > 5 ? "…" : ""}
))}
)}
{scd ? (res && (res.mode === "no_dict"
?
Слова «{res.q}» нет в словаре. Запрос записан.
:
))
:
}
{picker &&
setPicker(false)}
onPick={(t) => { ssSet(`vc_trim_${vin}`, t.mjsid); setMjsid(t.mjsid); setPicker(false); setRes(null); setGrp(""); }} />}
);
}
// ------------------------------------------------------------ 3. подбор
// Условия нет — на карточке молчим: заголовок группы уже сказал «по VIN не определяется».
// Плашки тут быть не должно, её принимают за кнопку.
function Condition({ p }) {
if (!p.condition) return null;
return {p.condition} ;
}
const BADGE = {
exact: { cls: "exact", text: "НОМЕР ЭТОЙ МАШИНЫ", hint: "API явно подтвердил номер для этой комплектации (containFlag = 0)" },
analog: { cls: "analog", text: "АНАЛОГ ПО ПОЗИЦИИ — ПРОВЕРЬТЕ", hint: "Номер этой машине не принадлежит: система подставила деталь той же позиции (containFlag = 1). Перед заказом сверьте." },
unknown: { cls: "unknown", text: "применимость не подтверждена поставщиком", hint: "API не сообщил, родной ли это номер для комплектации (поле containFlag пустое). Точность не гарантирована." },
};
function mergeParts(parts) {
// одинаковый номер с одинаковым примечанием склеиваем (API шлёт дубли с заменой и без)
const m = new Map();
for (const p of parts) {
const k = `${p.key}|${p.pn_id}|${p.comment || ""}`;
if (!m.has(k)) { m.set(k, { ...p, substitutes: p.substitute ? [p.substitute] : [], refs: p.ref ? [p.ref] : [] }); continue; }
const x = m.get(k);
if (p.substitute && !x.substitutes.includes(p.substitute)) x.substitutes.push(p.substitute);
if (p.ref && !x.refs.includes(p.ref)) x.refs.push(p.ref);
if (x.badge !== p.badge) x.badge = x.badge === "analog" || p.badge === "analog" ? "analog" : "unknown";
}
// группы: один ключ + одно название, разные номера → варианты
const groups = new Map();
for (const p of m.values()) {
const g = `${p.key}|${p.stdPartName}`;
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(p);
}
return [...groups.values()];
}
// Примечание одной строкой: перевод и наши пояснения нередко говорят одно и то же
// («Корпус чёрный» шло и заголовком, и пунктом списка). Оставляем непустое и неповторяющееся.
// На карточке показываем ТОЛЬКО то, что разобрано правилами. Машинный перевод примечания
// бывает бессмысленным («7FP/世钟» → «7FP/мировой колокол»), и выдавать его за смысл нельзя:
// оригинал и перевод лежат под «подробнее», там видно, что это машина, а не факт.
function noteLine(p) {
const norm = (s) => String(s || "").toLowerCase().replace(/[.\s]+$/, "").trim();
const parts = [];
for (const s of p.comment_hints || []) {
if (!s) continue;
if (!parts.some((x) => norm(x) === norm(s))) parts.push(s);
}
return parts.join(" · ");
}
function PartBody({ p, compact, more }) {
return (
<>
{/* Поиск поставщика нечёткий: оригинал рядом с переводом, чтобы было видно, если вернулось не то */}
{p.stdPartName_ru
? {p.stdPartName_ru}
: {p.stdPartName} без перевода }
{p.mfr && исполнение {p.mfr.name_ru || p.mfr.name}{p.mfr.code ? ` (${p.mfr.code})` : ""} }
{p.stdPartName_src === "llm" && машинный перевод }
{more && p.stdPartName_ru && (
в каталоге поставщика: {p.stdPartName}
)}
{more && p.srcPartName && p.srcPartName !== p.stdPartName && p.srcPartName_ru !== p.stdPartName_ru && (
заводское: {p.srcPartName} {p.srcPartName_ru ? ` — ${p.srcPartName_ru}` : ""}
)}
{(noteLine(p) || (more && p.comment)) && (
{noteLine(p) &&
{noteLine(p)} }
{more && p.comment && (
{p.comment}
{p.comment_ru && <> — {p.comment_ru}>}
{p.comment_src === "llm" && · машинный перевод }
)}
)}
{p.substitutes && p.substitutes.length > 0 && (
Замена в базе:
{p.substitutes.map((s) =>
{s} )}
)}
{!compact && p.reqPartNumber && p.reqPartNumber !== p.partNumber && (
Искали номер {p.reqPartNumber}
)}
>
);
}
// Миниатюра грузится лениво и отдельным запросом: список должен появиться сразу, не дожидаясь
// картинок. Часть схем поставщик не отдаёт вовсе — тогда картинки просто нет, а кнопка остаётся:
// битый значок хуже, чем его отсутствие.
function Thumb({ p, onScheme }) {
const [bad, setBad] = useState(false);
return (
onScheme(p)} title="Открыть схему узла">
{!bad && (
setBad(true)}
src={`/api/thumb?image=${encodeURIComponent(p.image)}&prefix=${encodeURIComponent(p.prefix || "")}`} />
)}
Схема{p.refs && p.refs.length ? ` · поз. ${p.refs.join(", ")}` : ""}
);
}
function PriceBox({ p, onScheme, more, setMore }) {
// сервис подбора артикулов: юани и китайский прячем под «подробнее», данные собираем как прежде
const price = parseFloat(p.partPrice);
return (
кол-во в позиции: {p.qty || "—"}
{p.image &&
}
setMore(!more)}>
{more ? "скрыть" : "подробнее"}
{more && (
{price > 0 ? <>заводская цена:
{price.toFixed(0)} ¥ > : "цены в прайсе нет"}
{p.partCode &&
код детали: {p.partCode}
}
{p.ref &&
позиция на схеме: {p.ref}
}
)}
);
}
function PartCard({ p, onScheme, vin, hideBadge }) {
const [open, setOpen] = useState(null); // что открылось нажатием: номер и замены
const [more, setMore] = useState(false); // «подробнее» одно на карточку: и китайский, и юани
const pp = open ? { ...p, partNumber: open.part_number, substitutes: open.substitutes || [] } : p;
const b = BADGE[p.badge];
return (
{p.own === false &&
На вашей комплектации этот номер не встретился. Годится ли он вам, поставщик не сообщает.
}
{p.badge === "analog" &&
{b.hint}
}
);
}
function variantReason(list) {
// почему номеров несколько: артикул назначает автопроизводитель, это РАЗНЫЕ исполнения, не взаимозамена
if (list.some((p) => p.date_split)) return { head: "зависят от даты сборки машины", tail: "Точная дата сборки по VIN не приходит — только год. Проверьте по ПТС, табличке в проёме двери или по резьбе колёсного болта." };
const mfrs = [...new Set(list.map((p) => p.mfr && (p.mfr.name_ru || p.mfr.name)).filter(Boolean))];
if (mfrs.length > 1) return { head: `разные исполнения: ${mfrs.join(" и ")}`, tail: "Это не взаимозаменяемые аналоги: номер назначает автопроизводитель, исполнения отличаются. Уточните по машине, какое стоит." };
return { head: "по VIN не определяется, какое из них стоит", tail: "Поставщик даёт несколько номеров на одну позицию и не указывает, чем они различаются. Сверяйте по снятой детали." };
}
function VariantRow({ p, onScheme, hideBadge }) {
const [open, setOpen] = useState(null);
const [more, setMore] = useState(false);
const pp = open ? { ...p, partNumber: open.part_number, substitutes: open.substitutes || [] } : p;
const b = BADGE[p.badge];
return (
);
}
function Variants({ list, onScheme, vin }) {
const reason = variantReason(list);
// Одна служебная пометка на виду: если применимость не подтверждена у ВСЕХ вариантов,
// говорим это один раз в заголовке, а не на каждой карточке.
const allUnknown = list.every((p) => p.badge === "unknown");
return (
{count(list.length, "вариант", "варианта", "вариантов")} «{list[0].stdPartName_ru || list[0].stdPartName}» — {reason.head}
{allUnknown &&
применимость ни одного из них поставщик не подтвердил
}
{list.map((p) => (
))}
{reason.tail}
);
}
function Parts({ vin, route, setCtx, lastSearch, setLastSearch }) {
const mjsid = route.q.mjsid, rim = route.q.rim;
const [v, setV] = useState(null);
const [q, setQ] = useState(lastSearch && lastSearch.vin === vin && lastSearch.mjsid === mjsid ? lastSearch.q : "");
const [res, setRes] = useState(lastSearch && lastSearch.vin === vin && lastSearch.mjsid === mjsid ? lastSearch.res : null);
const [busy, setBusy] = useState(false), [err, setErr] = useState("");
useEffect(() => { api(`/api/vin/${vin}?provider=mattrio`).then(setV).catch((x) => setErr(x.message)); }, [vin]);
const trim = v && v.trims && v.trims.find((t) => t.mjsid === mjsid);
useEffect(() => { if (v) setCtx(`${carTitle(v)} · ${v.displacement || ""} · ${v.vin_year || ""}${trim && trim.frontTyre ? " · " + trim.frontTyre : ""}`); }, [v, trim]);
const run = async (text) => {
const qq = (text != null ? text : q).trim();
if (!qq) return;
setQ(qq); setBusy(true); setErr("");
try {
const r = await api("/api/search", { body: { vin, mjsid, q: qq } });
setRes(r); setLastSearch({ vin, mjsid, q: qq, res: r });
} catch (x) { setErr(x.message); }
setBusy(false);
};
const groups = useMemo(() => (res ? mergeParts(res.parts) : []), [res]);
const onScheme = (p) => {
go(`/vin/${vin}/scheme?mjsid=${mjsid}&image=${encodeURIComponent(p.image)}&prefix=${encodeURIComponent(p.prefix || (v && v.prefix) || "")}&focus=${encodeURIComponent(p.pn_id)}${rim ? `&rim=${rim}` : ""}`);
};
if (!mjsid) return ;
return (
{trim ? <>Комплектация:
{trim.salesName_ru || trim.salesName} · {trim.drive_ru} ·
{trim.frontTyre} > :
{mjsid} }
{" · "}
сменить
в работе Каталог по узлам
{/* Группа поставщика держит несколько российских моделей (揽月, 瑞虎8_PRO). Значит, каталог
привязан к группе, а не к конкретной машине, и отличия комплектации он может не знать. */}
{v && v.models_many && (
Каталог может не совпадать с машиной: у этой группы поставщика
несколько российских моделей ({v.models.join(", ")}). Коробку, привод и кузов сверяйте
по самой машине.
)}
Поставщик ищет только по-китайски и только по названию: русское слово переводится по словарю. По номеру детали каталог не ищет — даже по своему собственному.
{err &&
{err}
}
{res && res.mode === "no_dict" && (
Слова «{res.q}» нет в словаре — поставщику искать нечего. {res.suggestions.length > 0 ? "Возможно, вы имели в виду:" : "Добавить слово может Вадим в админке → Словарь."}
{res.suggestions.length > 0 && (
{res.suggestions.map((s) => (
run(s)}>{s} ))}
)}
)}
{res && res.substitute &&
}
{res && res.no_parts &&
}
{res && res.mode !== "no_dict" && (
<>
{res.mode === "oe" ? "Поиск по номеру" : res.mode === "zh" ? "Поиск по-китайски" : `Запрос к поставщику: ${res.keys.join(", ")}`}
найдено {count(res.parts.length, "деталь", "детали", "деталей")}
номер этой машины
аналог по позиции — проверьте
привязка не указана
{res.parts.length === 0 && (
Поставщик ничего не нашёл.
{res.per_key.map((k) => `${k.key}: ${k.code === "402" ? "нет результата" : k.code}`).join(" · ")}
{res.mode === "oe" && " · Номер отсутствует в базе поставщика или не относится к этой комплектации."}
{!res.no_parts && (
Возможно, у поставщика нет именно этой группы — проверьте соседнюю.
)}
)}
{/* «Варианты» — только развилка по дате сборки. Одинаковое stdPartName у разных деталей
(передний и задний сайлентблок рычага) — это разные детали, а не выбор. */}
{groups.map((list) => list.length > 1 && list.some((p) => p.date_split)
?
: list.map((p) =>
))}
>
)}
);
}
// ------------------------------------------------------------ 4. схема
function Scheme({ vin, route, setCtx, lastSearch }) {
const { mjsid, image, prefix, focus, rim } = route.q;
const [s, setS] = useState(null), [err, setErr] = useState(""), [full, setFull] = useState(false);
useEffect(() => {
setS(null); setErr("");
api(`/api/scheme?${new URLSearchParams({ image, prefix: prefix || "", vin, mjsid })}`).then(setS).catch((x) => setErr(x.message));
}, [image]);
useEffect(() => { setCtx("Схема узла"); }, []);
useEffect(() => {
const f = (e) => { if (e.key === "Escape") setFull(false); };
if (full) { addEventListener("keydown", f); return () => removeEventListener("keydown", f); }
}, [full]);
// пока getPartByImage отвечает 403 — список из результатов поиска с той же схемой
const fromSearch = useMemo(() => {
if (!lastSearch || lastSearch.vin !== vin) return [];
const seen = new Set();
return lastSearch.res.parts.filter((p) => p.image === image && !seen.has(p.pn_id + p.ref) && seen.add(p.pn_id + p.ref));
}, [lastSearch, image]);
const list = s && s.parts_status === "ok" ? s.parts : fromSearch;
const back = `#/vin/${vin}/parts?mjsid=${mjsid}${rim ? `&rim=${rim}` : ""}`;
return (
{err &&
{err}
}
{!s && !err &&
Загружаю схему у поставщика…
}
{s && (
{s.width && `${s.width}×${s.height}`} · нажмите, чтобы открыть во весь экран
в работе Точки на схеме
setFull(true)} aria-label="Открыть схему во весь экран">
Схема сохранена у нас — ссылки поставщика временные. Кликабельных позиций пока нет: метод деталей схемы отвечает ошибкой ({s.parts_code || "нет ответа"}), вопрос у поставщика. Номера позиций — в списке справа.
Детали на схеме
{s.parts_status !== "ok" && (
Полный список деталей узла поставщик пока не отдаёт. Показаны найденные поиском детали с этой схемы.
)}
{!list.length &&
Нет данных
}
{list.map((p, i) => (
{p.ref || "–"}
{p.stdPartName_ru || перевода нет } {p.stdPartName}
{p.comment_ru &&
{p.comment_ru}
}
{BADGE[p.badge].text}
{parseFloat(p.partPrice) > 0 ? `${parseFloat(p.partPrice).toFixed(0)} ¥` : ""}
))}
{list.some((p) => p.partNumber) && (
copyRevealed(list)}>Скопировать раскрытые номера
)}
)}
{full && s && (
setFull(false)}>
setFull(false)}>Закрыть
)}
);
}
// ------------------------------------------------------------ 5. админка
const ACTIONS = { vin: "VIN", search: "поиск", scheme: "схема", login: "вход", login_fail: "неверный ПИН" };
function AdminLog({ users }) {
const [f, setF] = useState({ who_f: "", errors: 0 });
const [data, setData] = useState(null), [offset, setOffset] = useState(0), [raw, setRaw] = useState(null);
const qs = new URLSearchParams({ who_f: f.who_f, errors: f.errors, limit: 100, offset });
useEffect(() => { api(`/api/admin/log?${qs}`).then(setData); }, [f, offset]);
const openRaw = async (id) => setRaw(await api(`/api/admin/raw/${id}`));
const set = (patch) => { setOffset(0); setF({ ...f, ...patch }); };
return (
Журнал запросов {data && · {data.total} }
set({ who_f: "" })}>Все
{Object.entries(users).map(([k, n]) => set({ who_f: k })}>{n} )}
set({ errors: f.errors ? 0 : 1 })}>Только ошибки
Время Кто Действие Запрос Ответ Кэш Квота мс Сырое
{data && data.rows.map((r) => {
const ids = (r.detail && r.detail.raw_ids) || (r.raw_id ? [r.raw_id] : []);
return (
{fmtTime(r.ts)}
{users[r.who] || r.who}
{ACTIONS[r.action] || r.action}
{r.query}{r.detail && r.detail.found != null && · {r.detail.found} шт }
{r.code || (r.error ? "ошибка" : "ок")}
{r.cache_hit ? "да" : ""}
{r.frequency != null ? r.frequency : "—"}
{r.duration_ms != null ? r.duration_ms : ""}
{ids.map((id) => { e.preventDefault(); openRaw(id); }} style={{ marginRight: 6 }}>#{id} )}
);
})}
Выгрузить в CSV
{offset > 0 &&
setOffset(Math.max(0, offset - 100))}>← новее }
{data && offset + 100 < data.total &&
setOffset(offset + 100)}>старее → }
{raw && (
setRaw(null)}>
e.stopPropagation()}>
Сырой ответ #{raw.id} · {raw.method} · {raw.who} · http {raw.http} · code {raw.code} · квота {raw.frequency != null ? raw.frequency : "—"}
setRaw(null)}>Закрыть
Параметры
{JSON.stringify(raw.params, null, 2)}
Ответ как есть (userId вырезан)
{typeof raw.body === "string" ? raw.body : JSON.stringify(raw.body, null, 2)}
)}
);
}
const TABLE_DEF = {
dict: { title: "Словарь поиска (рус → кит)", cols: [["ru", "Русское слово"], ["zh", "Китайский ключ"], ["grp", "Группа"]], pk: "id",
extra: (r) => r.hits == null ? не проверено : r.hits === 0 ? 0 деталей : {r.hits} шт },
tr: { title: "Переводы (кит → рус)", cols: [["zh", "Китайский"], ["ru", "Русский"]], pk: "zh",
extra: (r) => {r.source === "llm" ? "машинный" : "ручной"} },
wmi: { title: "Префиксы VIN местной сборки", cols: [["wmi", "Префикс (3 знака)"], ["plant", "Завод"], ["brands", "Марки"], ["note", "Что сказать менеджеру"], ["kind", "local / china"],
["scd_manufacturer", "SHICHEDAO: завод"], ["scd_brand", "бренд"], ["scd_filter", "фильтр моделей"]], pk: "wmi" },
route: { title: "Маршрут по префиксу VIN (самый длинный совпавший; остальное — MATTRIO)", cols: [["prefix", "Префикс"], ["provider", "mattrio / shichedao"], ["note", "Что это"]], pk: "prefix" },
donor: { title: "Префикс VIN местной сборки → модель и китайский VIN-донор (самый длинный совпавший префикс)",
cols: [["prefix", "Префикс VIN"], ["brand", "Марка"], ["model", "Модель"], ["donor_vin", "Китайский VIN той же модели"], ["note", "Откуда известно"]], pk: "prefix" },
alias: { title: "Русские названия моделей (префикс — когда одно имя значит разные машины)",
cols: [["brand", "Марка (кит.)"], ["sys", "Модель у поставщика"], ["ru_name", "Название для менеджера"], ["wmi", "Префикс VIN"]], pk: "id" },
confirm: { title: "Подтверждённые машины: VIN → модель (показывается без пометки «предположение»)",
cols: [["vin", "VIN"], ["model", "Модель"], ["note", "Примечание"]], pk: "vin" },
};
function AdminTable({ name }) {
const def = TABLE_DEF[name];
const [rows, setRows] = useState(null), [edit, setEdit] = useState(null), [flt, setFlt] = useState(""), [err, setErr] = useState("");
const load = () => api(`/api/admin/table/${name}`).then(setRows);
useEffect(() => { setRows(null); setEdit(null); load(); }, [name]);
const save = async () => { setErr(""); try { await api(`/api/admin/table/${name}`, { body: edit }); setEdit(null); load(); } catch (x) { setErr(x.message); } };
const del = async (r) => { if (confirm("Удалить строку?")) { await api(`/api/admin/table/${name}/${encodeURIComponent(r[def.pk])}`, { method: "DELETE" }); load(); } };
const shown = (rows || []).filter((r) => !flt || def.cols.some(([c]) => String(r[c] || "").toLowerCase().includes(flt.toLowerCase())));
return (
{edit && (
{def.cols.map(([c, t]) => (
setEdit({ ...edit, [c]: e.target.value })} />
))}
Сохранить
setEdit(null)}>Отмена
{err && {err} }
)}
);
}
function FallbackStats() {
const [rows, setRows] = useState(null);
useEffect(() => { api("/api/admin/fallback").then(setRows).catch(() => setRows([])); }, []);
return (
Запасной путь
Основной поставщик ответил «не найдено» — сервис спросил второго. Если префикс постоянно уходит ко второму и тот находит — маршрут стоит поменять насовсем.
Префикс Основной → запасной Раз Разных VIN Запасной нашёл
{rows && rows.map((r, i) => (
{r.prefix} {PROV[r.src] || r.src} → {PROV[r.dst] || r.dst}
{r.n} {r.vins} {r.found} из {r.n} ))}
{rows && !rows.length && Пока не срабатывал }
);
}
function DictMiss() {
const [rows, setRows] = useState(null), [add, setAdd] = useState(null), [err, setErr] = useState("");
const load = () => api("/api/admin/dict_miss").then(setRows);
useEffect(() => { load(); }, []);
const save = async () => {
setErr("");
try {
await api("/api/admin/table/dict", { body: { ru: add.ru, zh: add.zh, grp: add.grp } });
await api(`/api/admin/dict_miss/${encodeURIComponent(add.q)}`, { method: "DELETE" });
DICT_CACHE = null; setAdd(null); load();
} catch (x) { setErr(x.message); }
};
const del = async (r) => { await api(`/api/admin/dict_miss/${encodeURIComponent(r.q)}`, { method: "DELETE" }); load(); };
return (
);
}
function DictCheck() {
const [vins, setVins] = useState([]), [vin, setVin] = useState(""), [mjsid, setMjsid] = useState(""), [v, setV] = useState(null);
const [busy, setBusy] = useState(false), [res, setRes] = useState(null), [err, setErr] = useState("");
useEffect(() => { api("/api/recent").then((r) => setVins(r.filter((x) => x.found))); }, []);
useEffect(() => { if (vin) api(`/api/vin/${vin}`).then(setV); }, [vin]);
const run = async () => { setBusy(true); setErr(""); try { setRes(await api("/api/admin/dict/check", { body: { vin, mjsid } })); } catch (x) { setErr(x.message); } setBusy(false); };
return (
Проверка словаря
Прогоняет все китайские ключи словаря по одной машине и отмечает, сколько деталей нашёл каждый. Квоту не тратит (поиск бесплатный), идёт несколько минут.
{ setVin(e.target.value); setMjsid(""); }}>
VIN… {vins.map((x) => {x.vin} {x.title} )}
setMjsid(e.target.value)}>
комплектация… {v && v.trims.map((t) => {t.year} · {t.salesName_ru || t.salesName} · {t.frontTyre} )}
{busy ? "Проверяю…" : "Проверить"}
{err &&
{err}
}
{res &&
Проверено ключей: {res.checked}. Без результата: {res.zero.length ? res.zero.join(", ") : "нет"}.
}
);
}
function Admin({ me, setCtx, onQuota }) {
const [s, setS] = useState(null), [tab, setTab] = useState("log"), [forget, setForget] = useState("");
useEffect(() => { setCtx("админка"); api("/api/admin/summary").then(setS); onQuota(); }, []);
if (!me.admin) return Только для Вадима.
;
if (!s) return загрузка…
;
const doForget = async () => {
if (!forget || !confirm(`Стереть ${forget} из памяти? Следующий ввод этого VIN спишет 1 запрос квоты.`)) return;
await api(`/api/admin/vin/${forget.trim().toUpperCase()}`, { method: "DELETE" }); setForget("");
};
return (
Остаток квоты
{s.quota.left != null ? s.quota.left : "—"}
по последнему ответу поставщика{s.quota.updated_at ? `, ${fmtTime(s.quota.updated_at)}` : ""}
VIN определено
{s.vins_cached}
платных запросов из сервиса: {s.vin_calls}
Действий в журнале
{s.requests}
из кэша {s.cache_hits} · вызовов API {s.api_calls}
{s.scd && s.scd.enabled && (
SHICHEDAO: вызовы
{s.scd.calls}
сэкономлено кэшем {s.scd.saved}
{s.scd.calls + s.scd.saved > 0 ? ` · ${Math.round(s.scd.saved / (s.scd.calls + s.scd.saved) * 100)}%` : ""}
{s.scd.last_quota_raw &&
последний отказ «пакет исчерпан»: сырой ответ #{s.scd.last_quota_raw}
}
)}
Ошибок
{s.errors}
средний ответ API {s.avg_ms != null ? `${(s.avg_ms / 1000).toFixed(1)} с` : "—"}
{[["log", "Журнал"], ["dict", "Словарь"], ["tr", "Переводы"], ["alias", "Модели"], ["wmi", "Префиксы"], ["route", "Маршруты"], ["donor", "Доноры"], ["confirm", "Подтверждения"], ["miss", "Не нашлось в словаре"], ["check", "Проверка словаря"], ["vin", "Память VIN"]].map(([k, t]) =>
setTab(k)}>{t} )}
{tab === "log" &&
}
{tab === "route" &&
}
{["dict", "tr", "alias", "wmi", "route", "donor", "confirm"].includes(tab) &&
}
{tab === "check" &&
}
{tab === "miss" &&
}
{tab === "vin" && (
)}
);
}
// ------------------------------------------------------------ тариф и оплата
const RUB = (n) => `${n}`.replace(/\B(?=(\d{3})+(?!\d))/g, " ") + " ₽";
const ORDER_KEY = "vc_last_order";
function PayResult({ orderId, onDone }) {
const [st, setSt] = useState(null), [err, setErr] = useState("");
useEffect(() => {
// возврат из кассы сам по себе ничего не значит: деньги подтверждает вебхук,
// и между «вернулся» и «зачислено» бывает пауза. Поэтому спрашиваем заказ.
let stop = false, tries = 0;
const ask = () => api(`/api/billing/order/${orderId}`).then((d) => {
if (stop) return;
setSt(d);
if (d.status === "succeeded") { localStorage.removeItem(ORDER_KEY); onDone(); return; }
if (d.status === "pending" && ++tries < 10) setTimeout(ask, 3000);
}).catch((e) => setErr(e.message));
ask();
return () => { stop = true; };
}, [orderId]);
if (err) return Не удалось узнать судьбу платежа: {err}
;
if (!st) return Проверяем платёж…
;
if (st.status === "succeeded")
return Оплачено. {RUB(st.amount_rub)} зачислены, лимиты уже обновлены.
;
if (st.status === "canceled")
return Платёж отменён. Деньги не списаны, можно начать заново.
;
return Ждём подтверждения кассы. Обычно это минута.
Лимиты обновятся сами, страницу можно закрыть.
;
}
function Billing({ setCtx, onPaid }) {
const [d, setD] = useState(null), [cab, setCab] = useState(null);
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(""), [err, setErr] = useState("");
const [order, setOrder] = useState(() => localStorage.getItem(ORDER_KEY) || "");
useEffect(() => {
setCtx("тариф и оплата");
api("/api/billing/plans").then(setD).catch((e) => setErr(e.message));
api("/api/cabinet").then((c) => { setCab(c); if (c.email) setEmail(c.email); });
}, []);
if (err) return ;
if (!d) return ;
const u = (cab && cab.usage) || {};
const ok = /^[^@\s]+@[^@\s.]+\.[^@\s]{2,}$/.test(email.trim());
const pay = async (kind, item, qty) => {
setBusy(`${kind}:${item}`); setErr("");
try {
const r = await api("/api/billing/pay", { body: { kind, item, qty, email: email.trim() } });
localStorage.setItem(ORDER_KEY, r.order_id);
location.href = r.confirmation_url;
} catch (x) { setErr(x.message); setBusy(""); }
};
return (
Тариф и оплата
{order &&
{ setOrder(""); onPaid();
api("/api/cabinet").then(setCab); }} />}
{u.requests_limit >= 0 && (
Остаток на {u.period}
{Math.max(0, u.requests_limit - u.requests_used)}
из {u.requests_limit} номеров
{Math.max(0, u.vin_limit - u.vin_used)}
из {u.vin_limit} машин
тариф «{u.plan_name}»
)}
{!d.enabled && Оплата скоро появится. Тарифы ниже — те, что будут
действовать; пока не подключили кассу, доступ продлеваем вручную — напишите нам.
}
{d.enabled && (
)}
Тарифы
{d.plans.filter((p) => p.price > 0).map((p) => (
{p.name}
{RUB(p.price)} / мес
{p.requests} {plural(p.requests, "раскрытие", "раскрытия", "раскрытий")} номеров
{p.vin} {plural(p.vin, "машина", "машины", "машин")} по VIN
pay("plan", p.id, 1)}>
{busy === `plan:${p.id}` ? "Открываем кассу…" : "Оплатить"}
))}
Докупка сверх тарифа
{d.packs.map((p) => (
{p.name}
{RUB(p.price)} / {p.unit} {plural(p.unit, "штука", "штуки", "штук")}
pay("pack", p.id, 1)}>
{busy === `pack:${p.id}` ? "Открываем кассу…" : "Оплатить"}
))}
{d.public_pages && Условия — в публичной оферте .
}
);
}
// ------------------------------------------------------------ приложение
function App() {
const [me, setMe] = useState(undefined);
const [ctx, setCtx] = useState("");
const [lastSearch, setLastSearch] = useState(null);
const route = useRoute();
const refreshMe = useCallback(() => api("/api/me").then(setMe).catch(() => setMe(null)), []);
useEffect(() => { refreshMe(); const f = () => setMe(null); addEventListener("vc-logout", f); return () => removeEventListener("vc-logout", f); }, []);
useEffect(() => { setCtx(""); }, [route.parts.join("/")]);
STAFF = !!(me && me.staff);
if (me === undefined) return null;
const linkMatch = location.hash.match(/^#\/in\/([\w-]+)(?:\/([\w-]+))?/) || [];
if (!me && linkMatch[1]) return ;
if (!me) return ;
const logout = async () => { if (confirm("Выйти?")) { await api("/api/logout", { body: {} }); setMe(null); } };
const [a, vin, sub] = route.parts;
let page;
if (a === "vin" && vin && sub === "parts") page = ;
else if (a === "vin" && vin && sub === "scheme") page = ;
else if (a === "vin" && vin) page = ;
else if (a === "admin") page = ;
else if (a === "cabinet") page = ;
else if (a === "scd-models" && vin) page = ;
else if (a === "scd-brands" && vin) page = ;
else if (a === "car" && vin) page = ;
else if (a === "scd" && vin && sub === "d") page = ;
else if (a === "scd" && vin) page = ;
else page = ;
return (<> {page}>);
}
ReactDOM.createRoot(document.getElementById("root")).render( );