mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +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)
|
# Внутренний секрет для вызовов бота → бэкенда (без 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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
`));
|
`));
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user