feat: arrivals module + refactor xlsx parser

- Добавлен /api/arrivals для «Поступление заказов на склад СПб.xlsx»
  (Drive ID захардкожен в ARRIVALS_FILE_ID, дефолт = 1kgrDEIGcVMFnSdZs1Y...)
- _parse_xlsx_groups() — единый парсер для обоих файлов:
  * находит строку заголовков динамически (первая строка с «Товар»),
    чтобы корректно работать с файлом «Поступление» (2 строки шапки перед хедером)
  * пропускает разделители «Кухни» / «Дозаказы» внутри листа
  * пропускает шаблонные пустые строки (Заказ/Дозаказ без данных)
- _xlsx_auth_manager() — вынесена общая проверка initData + роль
- config: поле arrivals_file_id
- frontend: вторая секция «📥 Поступление в СПб» на дашборде менеджера;
  renderManagerShipments принимает label-параметр и переиспользуется для обоих файлов;
  оба запроса загружаются параллельно с измерениями

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-16 07:49:56 +03:00
parent f5ee9e5b33
commit cc38782b85
5 changed files with 156 additions and 81 deletions

View File

@ -26,8 +26,10 @@ class Config:
# Внутренний секрет для вызовов бота → бэкенда (без initData) # Внутренний секрет для вызовов бота → бэкенда (без initData)
internal_secret: str internal_secret: str
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода) # Google Drive ID файла ОТГРУЗКИ.xlsx (отгрузки с завода)
shipments_file_id: str shipments_file_id: str
# Google Drive ID файла «Поступление заказов на склад СПб.xlsx»
arrivals_file_id: str
def _required(name: str) -> str: def _required(name: str) -> str:
@ -54,4 +56,5 @@ def get_config() -> Config:
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"), shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E"),
arrivals_file_id=os.getenv("ARRIVALS_FILE_ID", "1kgrDEIGcVMFnSdZs1Y_QHVhjqsXFQk2h"),
) )

View File

@ -367,6 +367,14 @@ async def api_shipments(request: Request):
return JSONResponse(await asyncio.to_thread(_handle_shipments, body)) return JSONResponse(await asyncio.to_thread(_handle_shipments, body))
@app.post("/api/arrivals")
async def api_arrivals(request: Request):
"""Поступления на склад СПб из «Поступление заказов на склад СПб.xlsx». Только для менеджера."""
body = await _safe_json(request)
import asyncio
return JSONResponse(await asyncio.to_thread(_handle_arrivals, body))
def _handle_daily_reminders() -> dict[str, Any]: def _handle_daily_reminders() -> dict[str, Any]:
"""Находит клиентов с годовщиной договора сегодня по МСК. """Находит клиентов с годовщиной договора сегодня по МСК.
Дедуплицирует: один менеджер + один клиент = одно уведомление, Дедуплицирует: один менеджер + один клиент = одно уведомление,
@ -3131,57 +3139,51 @@ 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]: def _xlsx_auth_manager(body: dict[str, Any]) -> tuple[Any, dict[str, Any] | None]:
"""Читает ОТГРУЗКИ.xlsx из Google Drive и возвращает позиции, сгруппированные по дате отгрузки с завода. """Проверяет initData и возвращает (tg_id, user) для менеджера или (None, error_dict)."""
Листы Excel называются "ЗОВ ДД.ММ.ГГ" дата отгрузки с завода.
Колонки: , Товар (Заказ/Дозаказ), договор , Срок, Кол мест,
Фурн-ра СПБ, Панели/техника СПБ, (empty), Продавец,
Сборщик, Примечание, Дата отгрузки, Кто забрал.
"""
import io
try:
import openpyxl
except ImportError:
return {"error": "openpyxl_not_installed"}
cfg = get_config() cfg = get_config()
# Auth — только менеджер
auth = verify_init_data(body.get("initData") or "", cfg.bot_token) auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"): if not auth or not auth.get("user"):
unsafe = body.get("initDataUnsafe") or {} unsafe = body.get("initDataUnsafe") or {}
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
auth = {"user": unsafe["user"]} auth = {"user": unsafe["user"]}
else: else:
return {"error": "invalid_init_data"} return None, {"error": "invalid_init_data"}
tg_id = auth["user"]["id"] tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id) user = sheets.find_user(tg_id)
if not user or not sheets.has_role(user, "manager"): if not user or not sheets.has_role(user, "manager"):
return {"error": "only_manager"} return None, {"error": "only_manager"}
return tg_id, user
file_id = cfg.shipments_file_id
if not file_id:
return {"ok": True, "shipments": [], "note": "file_not_configured"}
def _parse_xlsx_groups(file_bytes: bytes, source_label: str) -> list[dict[str, Any]]:
"""Общий парсер для ОТГРУЗКИ.xlsx и «Поступление заказов на склад СПб.xlsx».
Оба файла содержат листы вида «ЗОВ ДД.ММ.ГГ» с одинаковыми столбцами:
, Товар (Заказ/Дозаказ), договор , Срок, Кол мест, Фурн-ра СПБ,
Панели/техника СПБ, (empty), Продавец, Сборщик, Примечание, Дата отгрузки, Кто забрал.
«Поступление» дополнительно имеет:
- 2 строки-шапки перед заголовком (Накладная от, Поставщик:)
- разделители «Кухни» / «Дозаказы» между блоками данных
Функция находит строку заголовков динамически (первая строка с «Товар»).
"""
import io
try: try:
file_bytes = drive.download_file_bytes(file_id) import openpyxl
except Exception as e: except ImportError:
log.exception("shipments: не удалось скачать файл drive=%s", file_id) raise RuntimeError("openpyxl_not_installed")
return {"error": f"drive_error: {str(e)}"}
try:
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) 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]] = [] groups: list[dict[str, Any]] = []
# Метки секций, которые нужно пропускать как строки-данные
_SECTION_LABELS = {"кухни", "дозаказы", "кухня"}
for sheet_name in wb.sheetnames: for sheet_name in wb.sheetnames:
if not sheet_name.upper().startswith("ЗОВ "): if not sheet_name.strip().upper().startswith("ЗОВ "):
continue continue
date_part = sheet_name[4:].strip() date_part = sheet_name.strip()[4:].strip()
try: try:
if len(date_part) == 8: if len(date_part) == 8:
factory_date = datetime.strptime(date_part, "%d.%m.%y").date() factory_date = datetime.strptime(date_part, "%d.%m.%y").date()
@ -3197,47 +3199,63 @@ def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]:
if len(all_rows) < 2: if len(all_rows) < 2:
continue continue
# Заголовки — первая строка # Динамически находим строку заголовков — первая строка, содержащая «Товар»
raw_headers = all_rows[0] header_idx = None
for i, row in enumerate(all_rows):
cells = [str(c).strip().lower() if c is not None else "" for c in row]
if "товар" in cells:
header_idx = i
break
if header_idx is None:
continue # нет строки с заголовком
raw_headers = all_rows[header_idx]
headers = [str(h).strip() if h is not None else "" for h in raw_headers] headers = [str(h).strip() if h is not None else "" for h in raw_headers]
def _clean(v: Any) -> str:
s = str(v or "").strip()
return "" if s.lower() in ("none", "") else s
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
for row in all_rows[1:]: for row in all_rows[header_idx + 1:]:
# Пропускаем полностью пустые строки # Пропускаем полностью пустые строки
if not any(v for v in row if v is not None and str(v).strip()): non_empty = [v for v in row if v is not None and str(v).strip()]
if not non_empty:
continue continue
# Строим словарь
rd: dict[str, Any] = {} rd: dict[str, Any] = {}
for i, val in enumerate(row): for i, val in enumerate(row):
key = headers[i] if i < len(headers) else f"_col{i}" key = headers[i] if i < len(headers) else f"_col{i}"
rd[key] = val rd[key] = val
tovar = str(rd.get("Товар") or "").strip() tovar_raw = str(rd.get("Товар") or "").strip()
if not tovar or tovar.lower() in ("none", "товар", ""): if not tovar_raw or tovar_raw.lower() in ("none", "товар"):
continue
# Пропускаем разделители-секции («Кухни», «Дозаказы»)
if tovar_raw.lower() in _SECTION_LABELS:
continue
# Пропускаем шаблонные пустые строки — Заказ/Дозаказ без прочих данных
if tovar_raw in ("Заказ", "Дозаказ") and len(non_empty) <= 2:
continue continue
# Договор № — может быть в колонке с пустым заголовком (C) или "договор №" # Договор № — колонка с заголовком «договор», или 3-я колонка (C)
contract = "" contract = ""
for h in headers: for h in headers:
if "договор" in h.lower() or "дог" in h.lower(): if "договор" in h.lower() or "дог" in h.lower():
contract = str(rd.get(h) or "").strip() contract = _clean(rd.get(h))
break break
if not contract and len(headers) > 2: if not contract and len(headers) > 2:
contract = str(rd.get(headers[2]) or "").strip() contract = _clean(rd.get(headers[2]))
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({ items.append({
"num": _clean(rd.get("") or rd.get("")), "num": _clean(rd.get("")),
"tovar": tovar, # "Заказ" | "Дозаказ" "tovar": tovar_raw,
"contract": contract, "contract": contract,
"deadline": _parse_xlsx_date(rd.get("Срок")), "deadline": _parse_xlsx_date(rd.get("Срок")),
"places": _clean(rd.get("Кол мест") or rd.get("Кол. мест") or rd.get("Кол.мест")), "places": _clean(rd.get("Кол мест") or rd.get("Кол. мест") or rd.get("Кол.мест")),
"furn_spb": _clean(rd.get("Фурн-ра СПБ") or rd.get("Фурн СПБ")), "furn_spb": _clean(rd.get("Фурн-ра СПБ") or rd.get("фурн-ра СПБ") or rd.get("Фурн СПБ")),
"panels_spb": _clean(rd.get("Панели/техника СПБ") or rd.get("Панели СПБ")), "panels_spb": _clean(rd.get("Панели/техника СПБ") or rd.get("панели и техника СПБ в заказе") or rd.get("Панели СПБ")),
"seller": _clean(rd.get("Продавец")), "seller": _clean(rd.get("Продавец")),
"assembler": _clean(rd.get("Сборщик")), "assembler": _clean(rd.get("Сборщик")),
"note": _clean(rd.get("Примечание")), "note": _clean(rd.get("Примечание")),
@ -3249,14 +3267,61 @@ def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]:
groups.append({ groups.append({
"factory_date": factory_date.strftime("%d.%m.%Y"), "factory_date": factory_date.strftime("%d.%m.%Y"),
"factory_date_iso": factory_date.isoformat(), "factory_date_iso": factory_date.isoformat(),
"sheet_name": sheet_name, "sheet_name": sheet_name.strip(),
"source": source_label,
"count": len(items), "count": len(items),
"count_zakazov": sum(1 for i in items if "Заказ" in i["tovar"] and "Доз" not in i["tovar"]), "count_zakazov": sum(1 for i in items if "Доз" not in i["tovar"]),
"count_dozakazov": sum(1 for i in items if "Доз" in i["tovar"]), "count_dozakazov": sum(1 for i in items if "Доз" in i["tovar"]),
"items": items, "items": items,
}) })
groups.sort(key=lambda x: x["factory_date_iso"]) groups.sort(key=lambda x: x["factory_date_iso"])
return groups
def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]:
"""ОТГРУЗКИ.xlsx — отгрузки с завода. Только для менеджера."""
tg_id, err = _xlsx_auth_manager(body)
if err:
return err
cfg = get_config()
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:
groups = _parse_xlsx_groups(file_bytes, "shipments")
except Exception as e:
log.exception("shipments: ошибка парсинга xlsx")
return {"error": f"parse_error: {str(e)}"}
return {"ok": True, "shipments": groups}
def _handle_arrivals(body: dict[str, Any]) -> dict[str, Any]:
"""«Поступление заказов на склад СПб.xlsx» — приход на склад. Только для менеджера."""
tg_id, err = _xlsx_auth_manager(body)
if err:
return err
cfg = get_config()
file_id = cfg.arrivals_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("arrivals: не удалось скачать drive=%s", file_id)
return {"error": f"drive_error: {str(e)}"}
try:
groups = _parse_xlsx_groups(file_bytes, "arrivals")
except Exception as e:
log.exception("arrivals: ошибка парсинга xlsx")
return {"error": f"parse_error: {str(e)}"}
return {"ok": True, "shipments": groups} return {"ok": True, "shipments": groups}

View File

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

View File

@ -170,34 +170,39 @@ 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>`); const shipmentsContainer = el(`<div id="shipmentsContainer"></div>`);
app.appendChild(shipmentsContainer); app.appendChild(shipmentsContainer);
// Контейнер для поступлений на склад СПб
const arrivalsContainer = el(`<div id="arrivalsContainer"></div>`);
app.appendChild(arrivalsContainer);
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 + отгрузки) // Параллельно грузим реальные данные (измерения + pending + отгрузки + поступления)
try { try {
const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }; const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
const [resM, resP, resS] = await Promise.all([ const [resM, resP, resS, resA] = await Promise.all([
fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }), fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }),
fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }), 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/shipments`, { method: "POST", body: JSON.stringify(authBody) }),
fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: JSON.stringify(authBody) }),
]); ]);
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 shipmentsData = await resS.json();
const measurements = (data.measurements || []); const arrivalsData = await resA.json();
const pending = (pendingData.pending || []);
renderManagerPending(pendingContainer, pending); renderManagerPending(pendingContainer, pendingData.pending || []);
renderManagerToday(todayContainer, measurements, firstName, greetingEl); renderManagerToday(todayContainer, data.measurements || [], firstName, greetingEl);
renderManagerProjects(projectsContainer, measurements); renderManagerProjects(projectsContainer, data.measurements || []);
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || []); renderManagerShipments(shipmentsContainer, shipmentsData.shipments || [], "📦 Отгрузки с завода");
renderManagerShipments(arrivalsContainer, arrivalsData.shipments || [], "📥 Поступление в СПб");
} catch (e) { } catch (e) {
todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`; todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`;
} }
@ -481,8 +486,8 @@ function renderManagerProjects(container, measurements) {
container.appendChild(list); container.appendChild(list);
} }
/* ----------------- Менеджер: секция отгрузок (ОТГРУЗКИ.xlsx) ----------------- */ /* ----------------- Менеджер: секция отгрузок / поступлений на склад ----------------- */
function renderManagerShipments(container, groups) { function renderManagerShipments(container, groups, label = "📦 Отгрузки") {
container.innerHTML = ""; container.innerHTML = "";
if (!groups || !groups.length) return; if (!groups || !groups.length) return;
@ -492,7 +497,7 @@ function renderManagerShipments(container, groups) {
const totalItems = visible.reduce((s, g) => s + g.count, 0); const totalItems = visible.reduce((s, g) => s + g.count, 0);
container.appendChild(el(` container.appendChild(el(`
<div class="section-head" style="margin-top:24px;"> <div class="section-head" style="margin-top:24px;">
<span class="label">📦 Отгрузки <span class="count">· ${totalItems} поз.</span></span> <span class="label">${escHtml(label)} <span class="count">· ${totalItems} поз.</span></span>
</div> </div>
`)); `));

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=20260516a"> <link rel="stylesheet" href="assets/styles.css?v=20260516b">
<link rel="stylesheet" href="assets/podbor.css?v=20260516a"> <link rel="stylesheet" href="assets/podbor.css?v=20260516b">
</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=20260516a" alt="@wasrusgen1"> <img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260516b" 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=20260516a"></script> <script src="assets/icons.js?v=20260516b"></script>
<script src="assets/podbor.config.js?v=20260516a"></script> <script src="assets/podbor.config.js?v=20260516b"></script>
<script src="assets/podbor.picts.js?v=20260516a"></script> <script src="assets/podbor.picts.js?v=20260516b"></script>
<script src="assets/podbor.js?v=20260516a"></script> <script src="assets/podbor.js?v=20260516b"></script>
<script src="assets/clients.js?v=20260516a"></script> <script src="assets/clients.js?v=20260516b"></script>
<script src="assets/zamer-picts.js?v=20260516a"></script> <script src="assets/zamer-picts.js?v=20260516b"></script>
<script src="assets/measurements.js?v=20260516a"></script> <script src="assets/measurements.js?v=20260516b"></script>
<script src="assets/request.js?v=20260516a"></script> <script src="assets/request.js?v=20260516b"></script>
<script src="assets/assembly.js?v=20260516a"></script> <script src="assets/assembly.js?v=20260516b"></script>
<script src="assets/app.js?v=20260516a"></script> <script src="assets/app.js?v=20260516b"></script>
</body> </body>
</html> </html>