mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
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:
parent
f5ee9e5b33
commit
cc38782b85
@ -26,8 +26,10 @@ class Config:
|
||||
# Внутренний секрет для вызовов бота → бэкенда (без initData)
|
||||
internal_secret: str
|
||||
|
||||
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода)
|
||||
# Google Drive ID файла ОТГРУЗКИ.xlsx (отгрузки с завода)
|
||||
shipments_file_id: str
|
||||
# Google Drive ID файла «Поступление заказов на склад СПб.xlsx»
|
||||
arrivals_file_id: str
|
||||
|
||||
|
||||
def _required(name: str) -> str:
|
||||
@ -54,4 +56,5 @@ def get_config() -> Config:
|
||||
proxy_list_file=os.getenv("PROXY_LIST_FILE", ""),
|
||||
internal_secret=os.getenv("INTERNAL_SECRET", ""),
|
||||
shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E"),
|
||||
arrivals_file_id=os.getenv("ARRIVALS_FILE_ID", "1kgrDEIGcVMFnSdZs1Y_QHVhjqsXFQk2h"),
|
||||
)
|
||||
|
||||
@ -367,6 +367,14 @@ async def api_shipments(request: Request):
|
||||
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]:
|
||||
"""Находит клиентов с годовщиной договора сегодня по МСК.
|
||||
Дедуплицирует: один менеджер + один клиент = одно уведомление,
|
||||
@ -3131,57 +3139,51 @@ def _initial(name: str) -> str:
|
||||
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"}
|
||||
|
||||
def _xlsx_auth_manager(body: dict[str, Any]) -> tuple[Any, dict[str, Any] | None]:
|
||||
"""Проверяет initData и возвращает (tg_id, user) для менеджера или (None, error_dict)."""
|
||||
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"}
|
||||
return None, {"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"}
|
||||
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:
|
||||
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)}"}
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
raise RuntimeError("openpyxl_not_installed")
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
groups: list[dict[str, Any]] = []
|
||||
|
||||
# Метки секций, которые нужно пропускать как строки-данные
|
||||
_SECTION_LABELS = {"кухни", "дозаказы", "кухня"}
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if not sheet_name.upper().startswith("ЗОВ "):
|
||||
if not sheet_name.strip().upper().startswith("ЗОВ "):
|
||||
continue
|
||||
date_part = sheet_name[4:].strip()
|
||||
date_part = sheet_name.strip()[4:].strip()
|
||||
try:
|
||||
if len(date_part) == 8:
|
||||
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:
|
||||
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]
|
||||
|
||||
def _clean(v: Any) -> str:
|
||||
s = str(v or "").strip()
|
||||
return "" if s.lower() in ("none", "") else s
|
||||
|
||||
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
|
||||
|
||||
# Строим словарь
|
||||
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", "товар", ""):
|
||||
tovar_raw = str(rd.get("Товар") or "").strip()
|
||||
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
|
||||
|
||||
# Договор № — может быть в колонке с пустым заголовком (C) или "договор №"
|
||||
# Договор № — колонка с заголовком «договор», или 3-я колонка (C)
|
||||
contract = ""
|
||||
for h in headers:
|
||||
if "договор" in h.lower() or "дог" in h.lower():
|
||||
contract = str(rd.get(h) or "").strip()
|
||||
contract = _clean(rd.get(h))
|
||||
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
|
||||
contract = _clean(rd.get(headers[2]))
|
||||
|
||||
items.append({
|
||||
"num": _clean(rd.get("№") or rd.get("№")),
|
||||
"tovar": tovar, # "Заказ" | "Дозаказ"
|
||||
"num": _clean(rd.get("№")),
|
||||
"tovar": tovar_raw,
|
||||
"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("Панели СПБ")),
|
||||
"furn_spb": _clean(rd.get("Фурн-ра СПБ") or rd.get("фурн-ра СПБ") or rd.get("Фурн СПБ")),
|
||||
"panels_spb": _clean(rd.get("Панели/техника СПБ") or rd.get("панели и техника СПБ в заказе") or rd.get("Панели СПБ")),
|
||||
"seller": _clean(rd.get("Продавец")),
|
||||
"assembler": _clean(rd.get("Сборщик")),
|
||||
"note": _clean(rd.get("Примечание")),
|
||||
@ -3249,14 +3267,61 @@ def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]:
|
||||
groups.append({
|
||||
"factory_date": factory_date.strftime("%d.%m.%Y"),
|
||||
"factory_date_iso": factory_date.isoformat(),
|
||||
"sheet_name": sheet_name,
|
||||
"sheet_name": sheet_name.strip(),
|
||||
"source": source_label,
|
||||
"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"]),
|
||||
"items": items,
|
||||
})
|
||||
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@ -29,5 +29,7 @@ GRACE_PERIOD_DAYS=14
|
||||
# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный)
|
||||
INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t
|
||||
|
||||
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода)
|
||||
# Google Drive ID файла ОТГРУЗКИ.xlsx (отгрузки с завода)
|
||||
SHIPMENTS_FILE_ID=1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E
|
||||
# Google Drive ID файла «Поступление заказов на склад СПб.xlsx»
|
||||
ARRIVALS_FILE_ID=1kgrDEIGcVMFnSdZs1Y_QHVhjqsXFQk2h
|
||||
|
||||
@ -170,34 +170,39 @@ async function renderManagerHome(me) {
|
||||
const projectsContainer = el(`<div id="projectsContainer"></div>`);
|
||||
app.appendChild(projectsContainer);
|
||||
|
||||
// Контейнер для отгрузок (под активными проектами)
|
||||
// Контейнер для отгрузок с завода (под активными проектами)
|
||||
const shipmentsContainer = el(`<div id="shipmentsContainer"></div>`);
|
||||
app.appendChild(shipmentsContainer);
|
||||
|
||||
// Контейнер для поступлений на склад СПб
|
||||
const arrivalsContainer = el(`<div id="arrivalsContainer"></div>`);
|
||||
app.appendChild(arrivalsContainer);
|
||||
|
||||
renderBottomNav("home", { unreadChats: 0 });
|
||||
|
||||
// Контейнер для карточек «Замер готов — что делать с подбором?»
|
||||
const pendingContainer = el(`<div id="pendingContainer"></div>`);
|
||||
app.insertBefore(pendingContainer, todayContainer);
|
||||
|
||||
// Параллельно грузим реальные данные (измерения + pending + отгрузки)
|
||||
// Параллельно грузим реальные данные (измерения + pending + отгрузки + поступления)
|
||||
try {
|
||||
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/manager_pending`, { 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 shipmentsData = await resS.json();
|
||||
const measurements = (data.measurements || []);
|
||||
const pending = (pendingData.pending || []);
|
||||
const arrivalsData = await resA.json();
|
||||
|
||||
renderManagerPending(pendingContainer, pending);
|
||||
renderManagerToday(todayContainer, measurements, firstName, greetingEl);
|
||||
renderManagerProjects(projectsContainer, measurements);
|
||||
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || []);
|
||||
renderManagerPending(pendingContainer, pendingData.pending || []);
|
||||
renderManagerToday(todayContainer, data.measurements || [], firstName, greetingEl);
|
||||
renderManagerProjects(projectsContainer, data.measurements || []);
|
||||
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || [], "📦 Отгрузки с завода");
|
||||
renderManagerShipments(arrivalsContainer, arrivalsData.shipments || [], "📥 Поступление в СПб");
|
||||
} catch (e) {
|
||||
todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`;
|
||||
}
|
||||
@ -481,8 +486,8 @@ function renderManagerProjects(container, measurements) {
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
/* ----------------- Менеджер: секция отгрузок (ОТГРУЗКИ.xlsx) ----------------- */
|
||||
function renderManagerShipments(container, groups) {
|
||||
/* ----------------- Менеджер: секция отгрузок / поступлений на склад ----------------- */
|
||||
function renderManagerShipments(container, groups, label = "📦 Отгрузки") {
|
||||
container.innerHTML = "";
|
||||
if (!groups || !groups.length) return;
|
||||
|
||||
@ -492,7 +497,7 @@ function renderManagerShipments(container, groups) {
|
||||
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>
|
||||
<span class="label">${escHtml(label)} <span class="count">· ${totalItems} поз.</span></span>
|
||||
</div>
|
||||
`));
|
||||
|
||||
|
||||
@ -12,14 +12,14 @@
|
||||
<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">
|
||||
<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/podbor.css?v=20260516a">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260516b">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260516b">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||
<div class="loader splash" id="splash">
|
||||
<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">
|
||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||
@ -35,15 +35,15 @@
|
||||
<div class="brand-tagline-gold">CRM</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260516a"></script>
|
||||
<script src="assets/podbor.config.js?v=20260516a"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260516a"></script>
|
||||
<script src="assets/podbor.js?v=20260516a"></script>
|
||||
<script src="assets/clients.js?v=20260516a"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260516a"></script>
|
||||
<script src="assets/measurements.js?v=20260516a"></script>
|
||||
<script src="assets/request.js?v=20260516a"></script>
|
||||
<script src="assets/assembly.js?v=20260516a"></script>
|
||||
<script src="assets/app.js?v=20260516a"></script>
|
||||
<script src="assets/icons.js?v=20260516b"></script>
|
||||
<script src="assets/podbor.config.js?v=20260516b"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260516b"></script>
|
||||
<script src="assets/podbor.js?v=20260516b"></script>
|
||||
<script src="assets/clients.js?v=20260516b"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260516b"></script>
|
||||
<script src="assets/measurements.js?v=20260516b"></script>
|
||||
<script src="assets/request.js?v=20260516b"></script>
|
||||
<script src="assets/assembly.js?v=20260516b"></script>
|
||||
<script src="assets/app.js?v=20260516b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user