feat: warehouse module — ОТГРУЗКИ.xlsx в дашборде менеджера

- backend: новый модуль drive.py (Google Drive download + 5-мин кэш)
- backend: /api/shipments — читает xlsx из Drive, парсит листы «ЗОВ ДД.ММ.ГГ»,
  возвращает позиции (Заказ/Дозаказ) сгруппированные по дате отгрузки с завода
- config: поле shipments_file_id (SHIPMENTS_FILE_ID env; дефолт = ID ОТГРУЗКИ.xlsx)
- frontend: секция «📦 Отгрузки» на главной менеджера (после активных проектов),
  загружается параллельно с замерами и pending; показывает последние 3 партии
- CSS: стили .ship-group / .ship-row / .ship-badge / .ship-check
- deps: добавлен openpyxl>=3.1.0

ВАЖНО после деплоя: добавить сервис-аккаунт как Viewer к ОТГРУЗКИ.xlsx в Drive
и прописать SHIPMENTS_FILE_ID в /opt/zov-tech/deploy/.env на сервере.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-16 07:21:23 +03:00
parent 34ef51c4c8
commit f5ee9e5b33
8 changed files with 452 additions and 24 deletions

View File

@ -26,6 +26,9 @@ class Config:
# Внутренний секрет для вызовов бота → бэкенда (без initData) # Внутренний секрет для вызовов бота → бэкенда (без initData)
internal_secret: str internal_secret: str
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода)
shipments_file_id: str
def _required(name: str) -> str: def _required(name: str) -> str:
val = os.getenv(name) val = os.getenv(name)
@ -50,4 +53,5 @@ def get_config() -> Config:
proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""), proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""),
proxy_list_file=os.getenv("PROXY_LIST_FILE", ""), proxy_list_file=os.getenv("PROXY_LIST_FILE", ""),
internal_secret=os.getenv("INTERNAL_SECRET", ""), internal_secret=os.getenv("INTERNAL_SECRET", ""),
shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E"),
) )

64
backend-py/app/drive.py Normal file
View File

@ -0,0 +1,64 @@
"""Загрузка файлов из Google Drive через service account.
Использует google-api-python-client (уже в requirements.txt).
Scope drive.readonly только чтение файлов, к которым у сервисного аккаунта есть доступ.
"""
from __future__ import annotations
import io
import threading
import time
from typing import Any
from googleapiclient.discovery import build # type: ignore
from googleapiclient.http import MediaIoBaseDownload # type: ignore
from google.oauth2.service_account import Credentials # type: ignore
from .config import get_config
_SCOPES = [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive.readonly",
]
_lock = threading.Lock()
_service: Any = None
# Простой in-memory кэш: {file_id: (bytes, timestamp)}
_cache: dict[str, tuple[bytes, float]] = {}
_CACHE_TTL = 300 # 5 минут
def _get_service() -> Any:
global _service
with _lock:
if _service is None:
cfg = get_config()
creds = Credentials.from_service_account_file(
cfg.google_credentials_path, scopes=_SCOPES
)
_service = build("drive", "v3", credentials=creds, cache_discovery=False)
return _service
def download_file_bytes(file_id: str) -> bytes:
"""Скачивает файл из Google Drive по его ID и возвращает байты.
Кэширует результат на 5 минут, чтобы не качать xlsx на каждый запрос дашборда.
"""
now = time.monotonic()
cached = _cache.get(file_id)
if cached and (now - cached[1]) < _CACHE_TTL:
return cached[0]
service = _get_service()
request = service.files().get_media(fileId=file_id)
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
_, done = downloader.next_chunk()
data = fh.getvalue()
_cache[file_id] = (data, now)
return data

View File

@ -16,7 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse
from .config import get_config from .config import get_config
from .auth import verify_init_data from .auth import verify_init_data
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive
from . import parsers from . import parsers
from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl
@ -359,6 +359,14 @@ async def api_daily_reminders(request: Request):
return JSONResponse(_handle_daily_reminders()) return JSONResponse(_handle_daily_reminders())
@app.post("/api/shipments")
async def api_shipments(request: Request):
"""Отгрузки из ОТГРУЗКИ.xlsx (Google Drive). Только для менеджера."""
body = await _safe_json(request)
import asyncio
return JSONResponse(await asyncio.to_thread(_handle_shipments, body))
def _handle_daily_reminders() -> dict[str, Any]: def _handle_daily_reminders() -> dict[str, Any]:
"""Находит клиентов с годовщиной договора сегодня по МСК. """Находит клиентов с годовщиной договора сегодня по МСК.
Дедуплицирует: один менеджер + один клиент = одно уведомление, Дедуплицирует: один менеджер + один клиент = одно уведомление,
@ -3123,6 +3131,155 @@ def _initial(name: str) -> str:
return ((name or "").strip()[:1] or "?").upper() return ((name or "").strip()[:1] or "?").upper()
def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]:
"""Читает ОТГРУЗКИ.xlsx из Google Drive и возвращает позиции, сгруппированные по дате отгрузки с завода.
Листы Excel называются "ЗОВ ДД.ММ.ГГ" дата отгрузки с завода.
Колонки: , Товар (Заказ/Дозаказ), договор , Срок, Кол мест,
Фурн-ра СПБ, Панели/техника СПБ, (empty), Продавец,
Сборщик, Примечание, Дата отгрузки, Кто забрал.
"""
import io
try:
import openpyxl
except ImportError:
return {"error": "openpyxl_not_installed"}
cfg = get_config()
# Auth — только менеджер
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
unsafe = body.get("initDataUnsafe") or {}
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
auth = {"user": unsafe["user"]}
else:
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id)
if not user or not sheets.has_role(user, "manager"):
return {"error": "only_manager"}
file_id = cfg.shipments_file_id
if not file_id:
return {"ok": True, "shipments": [], "note": "file_not_configured"}
try:
file_bytes = drive.download_file_bytes(file_id)
except Exception as e:
log.exception("shipments: не удалось скачать файл drive=%s", file_id)
return {"error": f"drive_error: {str(e)}"}
try:
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
except Exception as e:
log.exception("shipments: не удалось распарсить xlsx")
return {"error": f"xlsx_parse_error: {str(e)}"}
groups: list[dict[str, Any]] = []
for sheet_name in wb.sheetnames:
if not sheet_name.upper().startswith("ЗОВ "):
continue
date_part = sheet_name[4:].strip()
try:
if len(date_part) == 8:
factory_date = datetime.strptime(date_part, "%d.%m.%y").date()
elif len(date_part) == 10:
factory_date = datetime.strptime(date_part, "%d.%m.%Y").date()
else:
continue
except ValueError:
continue
ws = wb[sheet_name]
all_rows = list(ws.iter_rows(values_only=True))
if len(all_rows) < 2:
continue
# Заголовки — первая строка
raw_headers = all_rows[0]
headers = [str(h).strip() if h is not None else "" for h in raw_headers]
items: list[dict[str, Any]] = []
for row in all_rows[1:]:
# Пропускаем полностью пустые строки
if not any(v for v in row if v is not None and str(v).strip()):
continue
rd: dict[str, Any] = {}
for i, val in enumerate(row):
key = headers[i] if i < len(headers) else f"_col{i}"
rd[key] = val
tovar = str(rd.get("Товар") or "").strip()
if not tovar or tovar.lower() in ("none", "товар", ""):
continue
# Договор № — может быть в колонке с пустым заголовком (C) или "договор №"
contract = ""
for h in headers:
if "договор" in h.lower() or "дог" in h.lower():
contract = str(rd.get(h) or "").strip()
break
if not contract and len(headers) > 2:
contract = str(rd.get(headers[2]) or "").strip()
contract = "" if contract in ("None", "none") else contract
def _clean(v: Any) -> str:
s = str(v or "").strip()
return "" if s in ("None", "none") else s
items.append({
"num": _clean(rd.get("") or rd.get("")),
"tovar": tovar, # "Заказ" | "Дозаказ"
"contract": contract,
"deadline": _parse_xlsx_date(rd.get("Срок")),
"places": _clean(rd.get("Кол мест") or rd.get("Кол. мест") or rd.get("Кол.мест")),
"furn_spb": _clean(rd.get("Фурн-ра СПБ") or rd.get("Фурн СПБ")),
"panels_spb": _clean(rd.get("Панели/техника СПБ") or rd.get("Панели СПБ")),
"seller": _clean(rd.get("Продавец")),
"assembler": _clean(rd.get("Сборщик")),
"note": _clean(rd.get("Примечание")),
"delivery_date": _parse_xlsx_date(rd.get("Дата отгрузки")),
"picked_up_by": _clean(rd.get("Кто забрал")),
})
if items:
groups.append({
"factory_date": factory_date.strftime("%d.%m.%Y"),
"factory_date_iso": factory_date.isoformat(),
"sheet_name": sheet_name,
"count": len(items),
"count_zakazov": sum(1 for i in items if "Заказ" in i["tovar"] and "Доз" not in i["tovar"]),
"count_dozakazov": sum(1 for i in items if "Доз" in i["tovar"]),
"items": items,
})
groups.sort(key=lambda x: x["factory_date_iso"])
return {"ok": True, "shipments": groups}
def _parse_xlsx_date(val: Any) -> str:
"""Парсит дату из ячейки Excel — datetime, date или строка."""
if val is None:
return ""
from datetime import date as date_t
if isinstance(val, datetime):
return val.strftime("%d.%m.%Y")
if isinstance(val, date_t):
return val.strftime("%d.%m.%Y")
s = str(val).strip()
if not s or s.lower() in ("none", ""):
return ""
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%y"):
try:
return datetime.strptime(s, fmt).strftime("%d.%m.%Y")
except ValueError:
pass
return s # вернём как есть если не распознали
def _now_iso() -> str: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()

View File

@ -9,3 +9,4 @@ python-dotenv>=1.0.0
beautifulsoup4>=4.12.0 beautifulsoup4>=4.12.0
lxml>=5.2.0 lxml>=5.2.0
playwright>=1.45.0 playwright>=1.45.0
openpyxl>=3.1.0

View File

@ -28,3 +28,6 @@ GRACE_PERIOD_DAYS=14
# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный) # Внутренний секрет бот → бэкенд (годовщины договоров, не публичный)
INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода)
SHIPMENTS_FILE_ID=1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E

View File

@ -170,32 +170,34 @@ async function renderManagerHome(me) {
const projectsContainer = el(`<div id="projectsContainer"></div>`); const projectsContainer = el(`<div id="projectsContainer"></div>`);
app.appendChild(projectsContainer); app.appendChild(projectsContainer);
// Контейнер для отгрузок (под активными проектами)
const shipmentsContainer = el(`<div id="shipmentsContainer"></div>`);
app.appendChild(shipmentsContainer);
renderBottomNav("home", { unreadChats: 0 }); renderBottomNav("home", { unreadChats: 0 });
// Контейнер для карточек «Замер готов — что делать с подбором?» // Контейнер для карточек «Замер готов — что делать с подбором?»
const pendingContainer = el(`<div id="pendingContainer"></div>`); const pendingContainer = el(`<div id="pendingContainer"></div>`);
app.insertBefore(pendingContainer, todayContainer); app.insertBefore(pendingContainer, todayContainer);
// Параллельно грузим реальные данные // Параллельно грузим реальные данные (измерения + pending + отгрузки)
try { try {
const [resM, resP] = await Promise.all([ const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
fetch(`${BACKEND_URL}/api/measurements`, { const [resM, resP, resS] = await Promise.all([
method: "POST", fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }),
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }), fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }),
}), fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: JSON.stringify(authBody) }),
fetch(`${BACKEND_URL}/api/manager_pending`, {
method: "POST",
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
}),
]); ]);
const data = await resM.json(); const data = await resM.json();
const pendingData = await resP.json(); const pendingData = await resP.json();
const shipmentsData = await resS.json();
const measurements = (data.measurements || []); const measurements = (data.measurements || []);
const pending = (pendingData.pending || []); const pending = (pendingData.pending || []);
renderManagerPending(pendingContainer, pending); renderManagerPending(pendingContainer, pending);
renderManagerToday(todayContainer, measurements, firstName, greetingEl); renderManagerToday(todayContainer, measurements, firstName, greetingEl);
renderManagerProjects(projectsContainer, measurements); renderManagerProjects(projectsContainer, measurements);
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || []);
} catch (e) { } catch (e) {
todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`; todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`;
} }
@ -479,6 +481,71 @@ function renderManagerProjects(container, measurements) {
container.appendChild(list); container.appendChild(list);
} }
/* ----------------- Менеджер: секция отгрузок (ОТГРУЗКИ.xlsx) ----------------- */
function renderManagerShipments(container, groups) {
container.innerHTML = "";
if (!groups || !groups.length) return;
// Показываем последние 3 партии (ближайшие по дате отгрузки с завода)
const visible = groups.slice(-3);
const totalItems = visible.reduce((s, g) => s + g.count, 0);
container.appendChild(el(`
<div class="section-head" style="margin-top:24px;">
<span class="label">📦 Отгрузки <span class="count">· ${totalItems} поз.</span></span>
</div>
`));
for (const group of visible) {
const zakazBadge = group.count_zakazov
? `<span class="ship-badge order">Заказов&nbsp;${group.count_zakazov}</span>` : "";
const dozBadge = group.count_dozakazov
? `<span class="ship-badge resupply">Дозаказов&nbsp;${group.count_dozakazov}</span>` : "";
const groupEl = el(`
<section class="ship-group">
<div class="ship-group-head">
<span class="ship-factory-date">${escHtml(group.factory_date)}</span>
<span class="ship-badges">${zakazBadge}${dozBadge}</span>
</div>
<div class="ship-rows"></div>
</section>
`);
const rowsEl = groupEl.querySelector(".ship-rows");
for (const item of group.items) {
const typeClass = item.tovar.startsWith("Доз") ? "dozakaz" : "zakaz";
const typeMark = item.tovar.startsWith("Доз") ? "Дозаказ" : "Заказ";
const delivStr = item.delivery_date ? `📬&nbsp;${escHtml(item.delivery_date)}` : "";
const assembler = item.assembler ? `🔧&nbsp;${escHtml(item.assembler)}` : "";
const places = item.places ? `📦&nbsp;${escHtml(item.places)} м.` : "";
const meta = [delivStr, assembler, places].filter(Boolean).join("&ensp;·&ensp;");
const furn = item.furn_spb ? `<span class="ship-check ${item.furn_spb === "+" ? "yes" : "no"}">Фурн: ${escHtml(item.furn_spb)}</span>` : "";
const pan = item.panels_spb ? `<span class="ship-check ${item.panels_spb === "+" ? "yes" : "no"}">Пан: ${escHtml(item.panels_spb)}</span>` : "";
const numStr = item.num ? `#${escHtml(item.num)}&ensp;` : "";
const contractStr = item.contract ? `Дог&nbsp;${escHtml(item.contract)}` : "";
const noteStr = item.note ? `<div class="ship-note">${escHtml(item.note)}</div>` : "";
rowsEl.appendChild(el(`
<div class="ship-row">
<div class="ship-row-top">
<span class="ship-type ${typeClass}">${typeMark}</span>
<span class="ship-id">${numStr}${contractStr}</span>
</div>
${meta ? `<div class="ship-meta">${meta}</div>` : ""}
${furn || pan ? `<div class="ship-supply">${furn}${pan}</div>` : ""}
${noteStr}
</div>
`));
}
container.appendChild(groupEl);
}
}
function renderBottomNav(active, opts = {}) { function renderBottomNav(active, opts = {}) {
// Удаляем предыдущий, если есть // Удаляем предыдущий, если есть
const old = document.getElementById("bottom-nav"); const old = document.getElementById("bottom-nav");

View File

@ -1833,6 +1833,138 @@
box-shadow: 0 2px 10px rgba(107, 74, 43, 0.28); box-shadow: 0 2px 10px rgba(107, 74, 43, 0.28);
} }
/* ============================================================
Секция отгрузок на главном дашборде менеджера (ОТГРУЗКИ.xlsx)
============================================================ */
.ship-group {
margin: 0 16px 12px;
background: var(--card, #fff);
border: 1px solid var(--line, #e8e1d9);
border-radius: 14px;
overflow: hidden;
}
.ship-group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--surface-2, #f4f1ed);
border-bottom: 1px solid var(--line, #e8e1d9);
}
.ship-factory-date {
font-size: 13px;
font-weight: 700;
color: var(--ink);
letter-spacing: 0.02em;
}
.ship-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.ship-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 7px;
border-radius: 20px;
white-space: nowrap;
}
.ship-badge.order {
background: rgba(0, 62, 126, 0.10);
color: #003E7E;
}
.ship-badge.resupply {
background: rgba(192, 57, 43, 0.10);
color: #C0392B;
}
.ship-rows {
display: flex;
flex-direction: column;
}
.ship-row {
padding: 10px 14px;
border-bottom: 1px solid var(--line, #e8e1d9);
}
.ship-row:last-child {
border-bottom: none;
}
.ship-row-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.ship-type {
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 20px;
flex-shrink: 0;
}
.ship-type.zakaz {
background: rgba(0, 62, 126, 0.10);
color: #003E7E;
}
.ship-type.dozakaz {
background: rgba(192, 57, 43, 0.10);
color: #C0392B;
}
.ship-id {
font-size: 13px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ship-meta {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
line-height: 1.4;
}
.ship-supply {
display: flex;
gap: 6px;
margin-top: 4px;
flex-wrap: wrap;
}
.ship-check {
font-size: 11px;
padding: 2px 6px;
border-radius: 6px;
}
.ship-check.yes {
background: rgba(39, 174, 96, 0.12);
color: #1a7a42;
}
.ship-check.no {
background: rgba(192, 57, 43, 0.10);
color: #C0392B;
}
.ship-note {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
font-style: italic;
line-height: 1.3;
white-space: pre-wrap;
}
.client-card { .client-card {
background: #fff; background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);

View File

@ -12,14 +12,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260515b"> <link rel="stylesheet" href="assets/styles.css?v=20260516a">
<link rel="stylesheet" href="assets/podbor.css?v=20260515b"> <link rel="stylesheet" href="assets/podbor.css?v=20260516a">
</head> </head>
<body> <body>
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск --> <!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
<div class="loader splash" id="splash"> <div class="loader splash" id="splash">
<div class="brand-logo-wrap"> <div class="brand-logo-wrap">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260515b" alt="@wasrusgen1"> <img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260516a" alt="@wasrusgen1">
<div class="splash-dust" aria-hidden="true"> <div class="splash-dust" aria-hidden="true">
<span class="dust d1"></span> <span class="dust d2"></span> <span class="dust d1"></span> <span class="dust d2"></span>
<span class="dust d3"></span> <span class="dust d4"></span> <span class="dust d3"></span> <span class="dust d4"></span>
@ -35,15 +35,15 @@
<div class="brand-tagline-gold">CRM</div> <div class="brand-tagline-gold">CRM</div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260515b"></script> <script src="assets/icons.js?v=20260516a"></script>
<script src="assets/podbor.config.js?v=20260515b"></script> <script src="assets/podbor.config.js?v=20260516a"></script>
<script src="assets/podbor.picts.js?v=20260515b"></script> <script src="assets/podbor.picts.js?v=20260516a"></script>
<script src="assets/podbor.js?v=20260515b"></script> <script src="assets/podbor.js?v=20260516a"></script>
<script src="assets/clients.js?v=20260515b"></script> <script src="assets/clients.js?v=20260516a"></script>
<script src="assets/zamer-picts.js?v=20260515b"></script> <script src="assets/zamer-picts.js?v=20260516a"></script>
<script src="assets/measurements.js?v=20260515b"></script> <script src="assets/measurements.js?v=20260516a"></script>
<script src="assets/request.js?v=20260515b"></script> <script src="assets/request.js?v=20260516a"></script>
<script src="assets/assembly.js?v=20260515b"></script> <script src="assets/assembly.js?v=20260516a"></script>
<script src="assets/app.js?v=20260515b"></script> <script src="assets/app.js?v=20260516a"></script>
</body> </body>
</html> </html>