mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
feat(assembly): configurable assembly rates + admin panel
Backend: - Sheet "Assembly_Rates": rules by assembler_tg_id + scope - _resolve_rates(): priority matching (specific > wildcard) - Default: client 10%, assembler 9% (1% margin) - _calc_assembly_prices(): role-aware field set in detail API - Endpoints: assembly_rates_list, assembly_rate_save, assembly_rate_delete - Cache TTL 120s, auto-seeded default rule on first run Frontend: - assembly_detail.js: shows client rate %, assembler payout % (role-aware) - admin_rates.js: list/add/edit/deactivate rules with live margin preview - app.js: route #/admin/rates + "Ставки сборки" button in manager dashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f473f1dc03
commit
3e7ae7764a
@ -148,6 +148,9 @@ async def _dispatch_post(request: Request):
|
|||||||
"sign_request_create": _handle_sign_request_create,
|
"sign_request_create": _handle_sign_request_create,
|
||||||
"sign_request_submit": _handle_sign_request_submit,
|
"sign_request_submit": _handle_sign_request_submit,
|
||||||
"managers_list": _handle_managers_list,
|
"managers_list": _handle_managers_list,
|
||||||
|
"assembly_rates_list": _handle_assembly_rates_list,
|
||||||
|
"assembly_rate_save": _handle_assembly_rate_save,
|
||||||
|
"assembly_rate_delete": _handle_assembly_rate_delete,
|
||||||
"proposal_brief": proposals_mod.handle_brief,
|
"proposal_brief": proposals_mod.handle_brief,
|
||||||
"proposal_create": proposals_mod.handle_create,
|
"proposal_create": proposals_mod.handle_create,
|
||||||
"proposal_upsert_variant": proposals_mod.handle_upsert_variant,
|
"proposal_upsert_variant": proposals_mod.handle_upsert_variant,
|
||||||
@ -356,6 +359,24 @@ async def api_assembly_set_kitchen_price(request: Request):
|
|||||||
return _handle_assembly_set_kitchen_price(body)
|
return _handle_assembly_set_kitchen_price(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/assembly_rates_list")
|
||||||
|
async def api_assembly_rates_list(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_assembly_rates_list(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/assembly_rate_save")
|
||||||
|
async def api_assembly_rate_save(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_assembly_rate_save(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/assembly_rate_delete")
|
||||||
|
async def api_assembly_rate_delete(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_assembly_rate_delete(body)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/grant_role")
|
@app.post("/api/grant_role")
|
||||||
async def api_grant_role(request: Request):
|
async def api_grant_role(request: Request):
|
||||||
"""Админ выдаёт роль другому пользователю.
|
"""Админ выдаёт роль другому пользователю.
|
||||||
@ -2452,6 +2473,30 @@ def _handle_assembly_list(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"ok": True, "count": len(out), "assemblies": out}
|
return {"ok": True, "count": len(out), "assemblies": out}
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_assembly_prices(row: dict, viewer_tg_id) -> dict:
|
||||||
|
"""Вычисляет стоимости сборки с учётом ставок из Assembly_Rates.
|
||||||
|
Возвращает словарь полей для добавления в ответ assembly_detail."""
|
||||||
|
assembler_tg_id = str(row.get("assigned_to_tg_id") or "")
|
||||||
|
client_rate, assembler_rate = _resolve_rates(assembler_tg_id, scope="*")
|
||||||
|
is_assembler = str(viewer_tg_id) == assembler_tg_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
kp = float(row.get("kitchen_price") or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
kp = 0.0
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"client_rate_pct": client_rate,
|
||||||
|
"assembler_rate_pct": assembler_rate,
|
||||||
|
"assembly_price_for_client": round(kp * client_rate / 100, 2) if kp else None,
|
||||||
|
"viewer_is_assembler": is_assembler,
|
||||||
|
}
|
||||||
|
# Сборщик видит свой заработок; менеджер и клиент — только цену для клиента
|
||||||
|
if is_assembler or sheets.has_role(sheets.find_user(viewer_tg_id), "manager"):
|
||||||
|
result["assembler_payout"] = round(kp * assembler_rate / 100, 2) if kp else None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Детальная карточка сборки."""
|
"""Детальная карточка сборки."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
@ -2517,6 +2562,8 @@ def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"manager_note": row.get("manager_note", ""),
|
"manager_note": row.get("manager_note", ""),
|
||||||
"kitchen_price": row.get("kitchen_price", ""),
|
"kitchen_price": row.get("kitchen_price", ""),
|
||||||
"client_tg_id": row.get("client_tg_id", ""),
|
"client_tg_id": row.get("client_tg_id", ""),
|
||||||
|
# Ставки — подсчёт в реальном времени
|
||||||
|
**_calc_assembly_prices(row, tg_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -2560,8 +2607,228 @@ def _handle_assembly_set_kitchen_price(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"id": assembly_id, "kitchen_price": kitchen_price,
|
"id": assembly_id, "kitchen_price": kitchen_price,
|
||||||
})
|
})
|
||||||
|
|
||||||
assembly_price = round(kitchen_price * 0.09, 2)
|
client_rate, assembler_rate = _resolve_rates(
|
||||||
return {"ok": True, "kitchen_price": kitchen_price, "assembly_price": assembly_price}
|
row.get("assigned_to_tg_id") or "", scope="*"
|
||||||
|
)
|
||||||
|
assembly_price = round(kitchen_price * client_rate / 100, 2)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"kitchen_price": kitchen_price,
|
||||||
|
"assembly_price": assembly_price,
|
||||||
|
"client_rate_pct": client_rate,
|
||||||
|
"assembler_rate_pct": assembler_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# Assembly Rates — настройка % сборки (клиент / сборщик)
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
_RATES_COLUMNS = [
|
||||||
|
"rule_id", "assembler_tg_id", "assembler_name",
|
||||||
|
"scope", "client_rate_pct", "assembler_rate_pct",
|
||||||
|
"note", "active", "updated_by", "updated_at",
|
||||||
|
]
|
||||||
|
_DEFAULT_CLIENT_RATE = 10.0
|
||||||
|
_DEFAULT_ASSEMBLER_RATE = 9.0
|
||||||
|
_rates_cache: dict = {"data": None, "ts": 0.0}
|
||||||
|
_RATES_CACHE_TTL = 120 # секунд
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_rates_sheet() -> None:
|
||||||
|
try:
|
||||||
|
ws = sheets._ws("Assembly_Rates")
|
||||||
|
existing = ws.row_values(1)
|
||||||
|
except Exception:
|
||||||
|
sheets.ensure_sheet("Assembly_Rates", _RATES_COLUMNS)
|
||||||
|
# seed default rule
|
||||||
|
_seed_default_rate()
|
||||||
|
return
|
||||||
|
missing = [c for c in _RATES_COLUMNS if c not in existing]
|
||||||
|
if missing:
|
||||||
|
for col in missing:
|
||||||
|
ws.update_cell(1, len(existing) + 1, col)
|
||||||
|
existing.append(col)
|
||||||
|
# Seed если лист пуст (только заголовок)
|
||||||
|
try:
|
||||||
|
all_rows = sheets.get_all_rows("Assembly_Rates")
|
||||||
|
if not all_rows:
|
||||||
|
_seed_default_rate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_default_rate() -> None:
|
||||||
|
sheets.append_row("Assembly_Rates", _RATES_COLUMNS, {
|
||||||
|
"rule_id": str(uuid.uuid4()),
|
||||||
|
"assembler_tg_id": "*",
|
||||||
|
"assembler_name": "Все сборщики",
|
||||||
|
"scope": "*",
|
||||||
|
"client_rate_pct": str(_DEFAULT_CLIENT_RATE),
|
||||||
|
"assembler_rate_pct": str(_DEFAULT_ASSEMBLER_RATE),
|
||||||
|
"note": "Базовая ставка по умолчанию",
|
||||||
|
"active": "TRUE",
|
||||||
|
"updated_by": "system",
|
||||||
|
"updated_at": _now_iso(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rates_cached() -> list[dict]:
|
||||||
|
now = time.time()
|
||||||
|
if _rates_cache["data"] is None or (now - _rates_cache["ts"]) > _RATES_CACHE_TTL:
|
||||||
|
try:
|
||||||
|
_ensure_rates_sheet()
|
||||||
|
rows = sheets.get_all_rows("Assembly_Rates")
|
||||||
|
_rates_cache["data"] = [r for r in rows if r.get("active", "").upper() != "FALSE"]
|
||||||
|
_rates_cache["ts"] = now
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("_get_rates_cached error: %s", e)
|
||||||
|
_rates_cache["data"] = _rates_cache["data"] or []
|
||||||
|
return _rates_cache["data"] or []
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_rates(assembler_tg_id: str, scope: str = "*") -> tuple[float, float]:
|
||||||
|
"""Ищет наиболее специфичное правило для сборщика и типа работ.
|
||||||
|
Приоритет: конкретный сборщик+scope > сборщик+* > *+scope > *+* > дефолт."""
|
||||||
|
rules = _get_rates_cached()
|
||||||
|
best_score = -1
|
||||||
|
best_rule = None
|
||||||
|
tid = str(assembler_tg_id).strip()
|
||||||
|
for r in rules:
|
||||||
|
rtid = str(r.get("assembler_tg_id", "*")).strip()
|
||||||
|
rscope = str(r.get("scope", "*")).strip()
|
||||||
|
score = 0
|
||||||
|
if rtid == tid:
|
||||||
|
score += 2
|
||||||
|
elif rtid != "*":
|
||||||
|
continue
|
||||||
|
if rscope == scope:
|
||||||
|
score += 1
|
||||||
|
elif rscope != "*":
|
||||||
|
continue
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_rule = r
|
||||||
|
if best_rule:
|
||||||
|
try:
|
||||||
|
cpct = float(best_rule.get("client_rate_pct", _DEFAULT_CLIENT_RATE))
|
||||||
|
apct = float(best_rule.get("assembler_rate_pct", _DEFAULT_ASSEMBLER_RATE))
|
||||||
|
return (cpct, apct)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return (_DEFAULT_CLIENT_RATE, _DEFAULT_ASSEMBLER_RATE)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_assembly_rates_list(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Список всех правил ставок (включая неактивные). Доступен менеджеру."""
|
||||||
|
cfg = get_config()
|
||||||
|
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": "forbidden"}
|
||||||
|
_ensure_rates_sheet()
|
||||||
|
rows = sheets.get_all_rows("Assembly_Rates")
|
||||||
|
return {"ok": True, "rates": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_assembly_rate_save(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Создать или обновить правило ставки.
|
||||||
|
body: {initData, rule_id?, assembler_tg_id, assembler_name,
|
||||||
|
scope, client_rate_pct, assembler_rate_pct, note}"""
|
||||||
|
cfg = get_config()
|
||||||
|
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": "forbidden"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
cpct = float(body.get("client_rate_pct", _DEFAULT_CLIENT_RATE))
|
||||||
|
apct = float(body.get("assembler_rate_pct", _DEFAULT_ASSEMBLER_RATE))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {"error": "bad_rate", "msg": "Ставка должна быть числом"}
|
||||||
|
if not (0 < cpct <= 100) or not (0 < apct <= 100):
|
||||||
|
return {"error": "bad_rate", "msg": "Ставка должна быть от 0.1 до 100"}
|
||||||
|
if apct > cpct:
|
||||||
|
return {"error": "bad_rate", "msg": "Ставка сборщика не может быть больше ставки клиента"}
|
||||||
|
|
||||||
|
_ensure_rates_sheet()
|
||||||
|
rule_id = (body.get("rule_id") or "").strip()
|
||||||
|
now = _now_iso()
|
||||||
|
|
||||||
|
if rule_id:
|
||||||
|
# Обновляем существующее
|
||||||
|
for field, val in [
|
||||||
|
("assembler_tg_id", str(body.get("assembler_tg_id") or "*")),
|
||||||
|
("assembler_name", str(body.get("assembler_name") or "")),
|
||||||
|
("scope", str(body.get("scope") or "*")),
|
||||||
|
("client_rate_pct", str(cpct)),
|
||||||
|
("assembler_rate_pct", str(apct)),
|
||||||
|
("note", str(body.get("note") or "")),
|
||||||
|
("active", "TRUE"),
|
||||||
|
("updated_by", str(tg_id)),
|
||||||
|
("updated_at", now),
|
||||||
|
]:
|
||||||
|
sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, field, val)
|
||||||
|
else:
|
||||||
|
# Создаём новое
|
||||||
|
rule_id = str(uuid.uuid4())
|
||||||
|
sheets.append_row("Assembly_Rates", _RATES_COLUMNS, {
|
||||||
|
"rule_id": rule_id,
|
||||||
|
"assembler_tg_id": str(body.get("assembler_tg_id") or "*"),
|
||||||
|
"assembler_name": str(body.get("assembler_name") or ""),
|
||||||
|
"scope": str(body.get("scope") or "*"),
|
||||||
|
"client_rate_pct": str(cpct),
|
||||||
|
"assembler_rate_pct": str(apct),
|
||||||
|
"note": str(body.get("note") or ""),
|
||||||
|
"active": "TRUE",
|
||||||
|
"updated_by": str(tg_id),
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Сбрасываем кеш
|
||||||
|
_rates_cache["data"] = None
|
||||||
|
return {"ok": True, "rule_id": rule_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_assembly_rate_delete(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Деактивирует правило ставки.
|
||||||
|
body: {initData, rule_id}"""
|
||||||
|
cfg = get_config()
|
||||||
|
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": "forbidden"}
|
||||||
|
|
||||||
|
rule_id = (body.get("rule_id") or "").strip()
|
||||||
|
if not rule_id:
|
||||||
|
return {"error": "missing_rule_id"}
|
||||||
|
_ensure_rates_sheet()
|
||||||
|
sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, "active", "FALSE")
|
||||||
|
sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, "updated_by", str(tg_id))
|
||||||
|
sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, "updated_at", _now_iso())
|
||||||
|
_rates_cache["data"] = None
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
|
|||||||
399
miniapp/assets/admin_rates.js
Normal file
399
miniapp/assets/admin_rates.js
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
/* ============================================================
|
||||||
|
AdminRates — управление ставками сборки
|
||||||
|
#/admin/rates → список правил + форма добавления/редактирования
|
||||||
|
Доступно только менеджеру.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const AdminRates = (function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const DEFAULT_CLIENT = 10;
|
||||||
|
const DEFAULT_ASSEMBLER = 9;
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(html) {
|
||||||
|
const t = document.createElement("template");
|
||||||
|
t.innerHTML = html.trim();
|
||||||
|
return t.content.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _api(path, body = {}) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), 15000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
|
||||||
|
method: "POST", signal: ctrl.signal,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")),
|
||||||
|
initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : (window.tg?.initDataUnsafe || null)),
|
||||||
|
...body
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
|
||||||
|
throw e;
|
||||||
|
} finally { clearTimeout(t); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Главный экран: список правил ──────────────────────────── */
|
||||||
|
async function mount(container) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
document.body.classList.remove("has-bottom-nav");
|
||||||
|
document.getElementById("bottom-nav")?.remove();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const h = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">Ставки сборки</div>
|
||||||
|
<button id="addRateBtn" style="background:none;border:none;font-size:22px;cursor:pointer;padding:4px 8px;" title="Добавить правило">+</button>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
history.back();
|
||||||
|
});
|
||||||
|
h.querySelector("#addRateBtn").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
_openForm(container, null);
|
||||||
|
});
|
||||||
|
container.appendChild(h);
|
||||||
|
|
||||||
|
const screen = el(`<div class="podbor-screen"></div>`);
|
||||||
|
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
|
||||||
|
container.appendChild(screen);
|
||||||
|
|
||||||
|
// Инфо-бэджик
|
||||||
|
screen.innerHTML = "";
|
||||||
|
screen.appendChild(el(`
|
||||||
|
<div style="margin:12px 16px;padding:12px;background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:12px;font-size:13px;color:var(--muted);line-height:1.5;">
|
||||||
|
<b style="color:var(--ink);">Как работают ставки</b><br>
|
||||||
|
Правила применяются по приоритету: <b>конкретный сборщик</b> > <b>все сборщики</b>.<br>
|
||||||
|
Клиент платит по ставке <b>«Клиенту»</b>, сборщик получает по ставке <b>«Сборщику»</b>.<br>
|
||||||
|
Разница — маржа компании.
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await _api("assembly_rates_list");
|
||||||
|
if (data.error) {
|
||||||
|
screen.innerHTML += `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_renderList(screen, container, data.rates || []);
|
||||||
|
} catch (e) {
|
||||||
|
screen.innerHTML += `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderList(screen, container, rates) {
|
||||||
|
// Удаляем старый список если есть
|
||||||
|
screen.querySelectorAll(".rates-list").forEach(n => n.remove());
|
||||||
|
|
||||||
|
const active = rates.filter(r => (r.active || "").toUpperCase() !== "FALSE");
|
||||||
|
const inactive = rates.filter(r => (r.active || "").toUpperCase() === "FALSE");
|
||||||
|
|
||||||
|
if (!active.length) {
|
||||||
|
screen.appendChild(el(`
|
||||||
|
<div style="text-align:center;padding:32px 16px;color:var(--muted);font-size:13px;">
|
||||||
|
Нет активных правил.<br>Нажмите + чтобы добавить.
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = el(`<div class="rates-list"></div>`);
|
||||||
|
|
||||||
|
// Активные
|
||||||
|
if (active.length) {
|
||||||
|
list.appendChild(el(`<div class="section-head" style="margin-top:8px;"><span class="label">Активные правила</span></div>`));
|
||||||
|
active.forEach(r => list.appendChild(_ruleCard(r, container, screen, rates, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Неактивные (свёрнуто)
|
||||||
|
if (inactive.length) {
|
||||||
|
const toggle = el(`
|
||||||
|
<div style="margin:12px 16px 0;">
|
||||||
|
<button class="btn-secondary" style="width:100%;font-size:12px;" id="showInactive">
|
||||||
|
Показать неактивные (${inactive.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
const inactiveWrap = el(`<div style="display:none;" id="inactiveWrap"></div>`);
|
||||||
|
inactive.forEach(r => inactiveWrap.appendChild(_ruleCard(r, container, screen, rates, true)));
|
||||||
|
toggle.querySelector("#showInactive").addEventListener("click", function () {
|
||||||
|
inactiveWrap.style.display = inactiveWrap.style.display === "none" ? "" : "none";
|
||||||
|
this.textContent = inactiveWrap.style.display === "none"
|
||||||
|
? `Показать неактивные (${inactive.length})`
|
||||||
|
: `Скрыть неактивные`;
|
||||||
|
});
|
||||||
|
list.appendChild(toggle);
|
||||||
|
list.appendChild(inactiveWrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ruleCard(r, container, screen, allRates, isInactive) {
|
||||||
|
const cpct = parseFloat(r.client_rate_pct || DEFAULT_CLIENT).toFixed(1);
|
||||||
|
const apct = parseFloat(r.assembler_rate_pct || DEFAULT_ASSEMBLER).toFixed(1);
|
||||||
|
const margin = (parseFloat(cpct) - parseFloat(apct)).toFixed(1);
|
||||||
|
const isDefault = r.assembler_tg_id === "*" && r.scope === "*";
|
||||||
|
const who = isDefault
|
||||||
|
? "🌐 Все сборщики (базовая)"
|
||||||
|
: (r.assembler_name ? `👤 ${r.assembler_name}` : `ID: ${r.assembler_tg_id}`);
|
||||||
|
const scopeLabel = r.scope !== "*" ? ` · 🗂 ${r.scope}` : "";
|
||||||
|
|
||||||
|
const card = el(`
|
||||||
|
<div style="margin:8px 16px;padding:12px 14px;background:var(--surface);
|
||||||
|
border:1px solid var(--border);border-radius:12px;
|
||||||
|
opacity:${isInactive ? "0.55" : "1"};">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(who)}${escHtml(scopeLabel)}</div>
|
||||||
|
${!isInactive ? `<button data-edit="${escHtml(r.rule_id)}"
|
||||||
|
style="background:none;border:1px solid var(--border);border-radius:6px;
|
||||||
|
font-size:11px;padding:3px 8px;cursor:pointer;color:var(--muted);">✏️</button>` : ""}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:16px;margin-top:8px;flex-wrap:wrap;">
|
||||||
|
<div style="font-size:12px;color:var(--muted);">Клиенту
|
||||||
|
<span style="font-size:15px;font-weight:700;color:var(--ink);margin-left:4px;">${cpct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);">Сборщику
|
||||||
|
<span style="font-size:15px;font-weight:700;color:var(--accent);margin-left:4px;">${apct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);">Маржа
|
||||||
|
<span style="font-size:13px;font-weight:600;color:#27AE60;margin-left:4px;">${margin}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${r.note ? `<div style="font-size:11px;color:var(--muted);margin-top:6px;font-style:italic;">${escHtml(r.note)}</div>` : ""}
|
||||||
|
${!isInactive && !isDefault ? `
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<button data-del="${escHtml(r.rule_id)}"
|
||||||
|
style="background:none;border:none;font-size:11px;color:#C0392B;cursor:pointer;padding:0;">
|
||||||
|
Деактивировать
|
||||||
|
</button>
|
||||||
|
</div>` : ""}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Редактировать
|
||||||
|
card.querySelector(`[data-edit]`)?.addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
_openForm(container, r);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Деактивировать
|
||||||
|
card.querySelector(`[data-del]`)?.addEventListener("click", async () => {
|
||||||
|
if (!confirm(`Деактивировать правило "${who}"?`)) return;
|
||||||
|
haptic && haptic("impact");
|
||||||
|
try {
|
||||||
|
const res = await _api("assembly_rate_delete", { rule_id: r.rule_id });
|
||||||
|
if (res.error) { alert("Ошибка: " + res.error); return; }
|
||||||
|
// Перезагружаем список
|
||||||
|
mount(container);
|
||||||
|
} catch (e) { alert("Ошибка: " + e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Форма добавления / редактирования ─────────────────────── */
|
||||||
|
function _openForm(container, rule) {
|
||||||
|
// Удаляем старую форму если есть
|
||||||
|
document.getElementById("rates-form-overlay")?.remove();
|
||||||
|
|
||||||
|
const isEdit = !!rule;
|
||||||
|
const overlay = el(`
|
||||||
|
<div id="rates-form-overlay"
|
||||||
|
style="position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9000;
|
||||||
|
display:flex;align-items:flex-end;">
|
||||||
|
<div style="width:100%;max-height:92vh;overflow-y:auto;background:var(--bg);
|
||||||
|
border-radius:16px 16px 0 0;padding:20px 16px 32px;">
|
||||||
|
<div style="font-size:16px;font-weight:700;color:var(--ink);margin-bottom:16px;">
|
||||||
|
${isEdit ? "Редактировать правило" : "Новое правило"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кто -->
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
|
||||||
|
color:var(--muted);margin-bottom:6px;">Применять к</div>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">
|
||||||
|
<input type="radio" name="rateWho" value="all" ${!isEdit || rule?.assembler_tg_id === "*" ? "checked" : ""}>
|
||||||
|
Все сборщики (базовая)
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||||
|
<input type="radio" name="rateWho" value="specific" ${isEdit && rule?.assembler_tg_id !== "*" ? "checked" : ""}>
|
||||||
|
Конкретный сборщик
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поля конкретного сборщика -->
|
||||||
|
<div id="specificFields" style="display:${isEdit && rule?.assembler_tg_id !== "*" ? "" : "none"};">
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label style="font-size:12px;color:var(--muted);">Telegram ID сборщика</label>
|
||||||
|
<input id="fAssemblerTgId" type="text" value="${escHtml(isEdit && rule?.assembler_tg_id !== "*" ? rule.assembler_tg_id : "")}"
|
||||||
|
placeholder="например: 123456789"
|
||||||
|
style="width:100%;margin-top:4px;padding:8px 10px;border:1px solid var(--border);
|
||||||
|
border-radius:8px;background:var(--surface);color:var(--ink);font-size:13px;">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label style="font-size:12px;color:var(--muted);">Имя сборщика (для отображения)</label>
|
||||||
|
<input id="fAssemblerName" type="text" value="${escHtml(isEdit ? (rule?.assembler_name || "") : "")}"
|
||||||
|
placeholder="Иванов Иван"
|
||||||
|
style="width:100%;margin-top:4px;padding:8px 10px;border:1px solid var(--border);
|
||||||
|
border-radius:8px;background:var(--surface);color:var(--ink);font-size:13px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ставки -->
|
||||||
|
<div style="display:flex;gap:12px;margin-bottom:10px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="font-size:12px;color:var(--muted);">Клиенту %</label>
|
||||||
|
<input id="fClientRate" type="number" step="0.1" min="1" max="100"
|
||||||
|
value="${isEdit ? (rule?.client_rate_pct || DEFAULT_CLIENT) : DEFAULT_CLIENT}"
|
||||||
|
style="width:100%;margin-top:4px;padding:8px 10px;border:1px solid var(--border);
|
||||||
|
border-radius:8px;background:var(--surface);color:var(--ink);font-size:15px;font-weight:700;">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="font-size:12px;color:var(--muted);">Сборщику %</label>
|
||||||
|
<input id="fAssemblerRate" type="number" step="0.1" min="1" max="100"
|
||||||
|
value="${isEdit ? (rule?.assembler_rate_pct || DEFAULT_ASSEMBLER) : DEFAULT_ASSEMBLER}"
|
||||||
|
style="width:100%;margin-top:4px;padding:8px 10px;border:1px solid var(--border);
|
||||||
|
border-radius:8px;background:var(--surface);color:var(--ink);font-size:15px;font-weight:700;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Живая маржа -->
|
||||||
|
<div id="marginPreview" style="text-align:center;padding:8px;margin-bottom:12px;
|
||||||
|
background:var(--surface-2,var(--surface));border-radius:8px;font-size:12px;color:var(--muted);">
|
||||||
|
Маржа: <b id="marginVal">1%</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Примечание -->
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="font-size:12px;color:var(--muted);">Примечание (необязательно)</label>
|
||||||
|
<input id="fNote" type="text" value="${escHtml(isEdit ? (rule?.note || "") : "")}"
|
||||||
|
placeholder="Например: сезонная ставка"
|
||||||
|
style="width:100%;margin-top:4px;padding:8px 10px;border:1px solid var(--border);
|
||||||
|
border-radius:8px;background:var(--surface);color:var(--ink);font-size:13px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="formErr" style="color:#C0392B;font-size:13px;margin-bottom:8px;display:none;"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;">
|
||||||
|
<button id="fCancel" class="btn-secondary" style="flex:1;">Отмена</button>
|
||||||
|
<button id="fSave" class="btn-primary" style="flex:2;">
|
||||||
|
${isEdit ? "Сохранить" : "Создать правило"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Показываем/скрываем поля конкретного сборщика
|
||||||
|
overlay.querySelectorAll('input[name="rateWho"]').forEach(radio => {
|
||||||
|
radio.addEventListener("change", () => {
|
||||||
|
const specificFields = overlay.querySelector("#specificFields");
|
||||||
|
specificFields.style.display = radio.value === "specific" ? "" : "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Живая маржа
|
||||||
|
const updateMargin = () => {
|
||||||
|
const c = parseFloat(overlay.querySelector("#fClientRate").value) || 0;
|
||||||
|
const a = parseFloat(overlay.querySelector("#fAssemblerRate").value) || 0;
|
||||||
|
const m = (c - a).toFixed(1);
|
||||||
|
const el_ = overlay.querySelector("#marginVal");
|
||||||
|
if (el_) {
|
||||||
|
el_.textContent = m + "%";
|
||||||
|
el_.style.color = parseFloat(m) >= 0 ? "#27AE60" : "#C0392B";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
overlay.querySelector("#fClientRate").addEventListener("input", updateMargin);
|
||||||
|
overlay.querySelector("#fAssemblerRate").addEventListener("input", updateMargin);
|
||||||
|
updateMargin();
|
||||||
|
|
||||||
|
// Отмена
|
||||||
|
overlay.querySelector("#fCancel").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
|
||||||
|
// Сохранить
|
||||||
|
overlay.querySelector("#fSave").addEventListener("click", async () => {
|
||||||
|
const errEl = overlay.querySelector("#formErr");
|
||||||
|
errEl.style.display = "none";
|
||||||
|
|
||||||
|
const whoVal = overlay.querySelector('input[name="rateWho"]:checked')?.value || "all";
|
||||||
|
const assemblerTgId = whoVal === "specific"
|
||||||
|
? (overlay.querySelector("#fAssemblerTgId").value || "").trim()
|
||||||
|
: "*";
|
||||||
|
const assemblerName = whoVal === "specific"
|
||||||
|
? (overlay.querySelector("#fAssemblerName").value || "").trim()
|
||||||
|
: "Все сборщики";
|
||||||
|
|
||||||
|
if (whoVal === "specific" && !assemblerTgId) {
|
||||||
|
errEl.textContent = "Укажите Telegram ID сборщика";
|
||||||
|
errEl.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientRate = parseFloat(overlay.querySelector("#fClientRate").value);
|
||||||
|
const assemblerRate = parseFloat(overlay.querySelector("#fAssemblerRate").value);
|
||||||
|
if (isNaN(clientRate) || isNaN(assemblerRate)) {
|
||||||
|
errEl.textContent = "Укажите ставки";
|
||||||
|
errEl.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (assemblerRate > clientRate) {
|
||||||
|
errEl.textContent = "Ставка сборщика не может быть больше ставки клиента";
|
||||||
|
errEl.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBtn = overlay.querySelector("#fSave");
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = "Сохраняем...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await _api("assembly_rate_save", {
|
||||||
|
rule_id: isEdit ? rule.rule_id : "",
|
||||||
|
assembler_tg_id: assemblerTgId,
|
||||||
|
assembler_name: assemblerName,
|
||||||
|
scope: "*",
|
||||||
|
client_rate_pct: clientRate,
|
||||||
|
assembler_rate_pct: assemblerRate,
|
||||||
|
note: (overlay.querySelector("#fNote").value || "").trim(),
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
errEl.textContent = res.msg || res.error;
|
||||||
|
errEl.style.display = "";
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = isEdit ? "Сохранить" : "Создать правило";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
haptic && haptic("success");
|
||||||
|
overlay.remove();
|
||||||
|
mount(container); // перезагружаем список
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = "Сеть: " + e.message;
|
||||||
|
errEl.style.display = "";
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = isEdit ? "Сохранить" : "Создать правило";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mount };
|
||||||
|
})();
|
||||||
@ -194,6 +194,7 @@ async function renderManagerHome(me) {
|
|||||||
{ icon: "clipboard", title: "Заказы", subtitle: "Сборки + заявки", href: "#/assembly" },
|
{ icon: "clipboard", title: "Заказы", subtitle: "Сборки + заявки", href: "#/assembly" },
|
||||||
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
||||||
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
|
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
|
||||||
|
{ icon: "wrench", title: "Ставки сборки", subtitle: "% клиент / сборщик", href: "#/admin/rates" },
|
||||||
];
|
];
|
||||||
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
||||||
const grid = el(`<div class="quick-grid"></div>`);
|
const grid = el(`<div class="quick-grid"></div>`);
|
||||||
@ -1695,6 +1696,9 @@ function routeByHash() {
|
|||||||
else init();
|
else init();
|
||||||
} else if (location.hash.startsWith("#/assembly")) {
|
} else if (location.hash.startsWith("#/assembly")) {
|
||||||
Assembly.mount(app);
|
Assembly.mount(app);
|
||||||
|
} else if (location.hash.startsWith("#/admin/rates")) {
|
||||||
|
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
|
||||||
|
else init();
|
||||||
} else if (location.hash.startsWith("#/master/tools")) {
|
} else if (location.hash.startsWith("#/master/tools")) {
|
||||||
if (typeof MasterTools !== "undefined") {
|
if (typeof MasterTools !== "undefined") {
|
||||||
const h = location.hash;
|
const h = location.hash;
|
||||||
|
|||||||
@ -102,13 +102,29 @@ const AssemblyDetailScreen = (function () {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// Финансовый блок — ставки из backend (настраиваются в админке)
|
||||||
|
const _kp = data.kitchen_price ? Number(data.kitchen_price) : 0;
|
||||||
|
const _cr = data.client_rate_pct || 10;
|
||||||
|
const _ar = data.assembler_rate_pct || 9;
|
||||||
|
const _cp = data.assembly_price_for_client != null
|
||||||
|
? Number(data.assembly_price_for_client)
|
||||||
|
: (_kp ? Math.round(_kp * _cr / 100) : 0);
|
||||||
|
const _ap = data.assembler_payout != null
|
||||||
|
? Number(data.assembler_payout)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const _priceRows = _kp ? `
|
||||||
|
${row("Стоимость кухни", _kp.toLocaleString("ru-RU") + " ₽")}
|
||||||
|
${row("Стоимость сборки (" + _cr + "%)", _cp.toLocaleString("ru-RU") + " ₽")}
|
||||||
|
${_ap != null ? row("Ваш заработок (" + _ar + "%)", Math.round(_ap).toLocaleString("ru-RU") + " ₽", {color: "var(--accent)"}) : ""}
|
||||||
|
` : "";
|
||||||
|
|
||||||
// Основные данные
|
// Основные данные
|
||||||
const mainBlock = `
|
const mainBlock = `
|
||||||
<div style="margin:12px 16px 0;border:1px solid var(--border);border-radius:12px;
|
<div style="margin:12px 16px 0;border:1px solid var(--border);border-radius:12px;
|
||||||
padding:0 12px;background:var(--surface);">
|
padding:0 12px;background:var(--surface);">
|
||||||
${row("Адрес", data.address)}
|
${row("Адрес", data.address)}
|
||||||
${data.kitchen_price ? row("Стоимость кухни", Number(data.kitchen_price).toLocaleString("ru-RU") + " ₽") : ""}
|
${_priceRows}
|
||||||
${data.kitchen_price ? row("Стоимость сборки", Number(Math.round(data.kitchen_price * 0.09)).toLocaleString("ru-RU") + " ₽", {color: "var(--accent)"}) : ""}
|
|
||||||
${row("Объём работ", data.scope_of_work)}
|
${row("Объём работ", data.scope_of_work)}
|
||||||
${row("Дата сборки", fmtDate(data.scheduled_at))}
|
${row("Дата сборки", fmtDate(data.scheduled_at))}
|
||||||
${row("Начало", fmtDate(data.started_at))}
|
${row("Начало", fmtDate(data.started_at))}
|
||||||
|
|||||||
@ -53,7 +53,8 @@
|
|||||||
<script src="assets/orders.js?v=20260518l"></script>
|
<script src="assets/orders.js?v=20260518l"></script>
|
||||||
<script src="assets/master_tools.js?v=20260518p"></script>
|
<script src="assets/master_tools.js?v=20260518p"></script>
|
||||||
<script src="assets/signrequest.js?v=20260518o"></script>
|
<script src="assets/signrequest.js?v=20260518o"></script>
|
||||||
<script src="assets/assembly_detail.js?v=20260518o"></script>
|
<script src="assets/admin_rates.js?v=20260519a"></script>
|
||||||
<script src="assets/app.js?v=20260518n"></script>
|
<script src="assets/assembly_detail.js?v=20260519a"></script>
|
||||||
|
<script src="assets/app.js?v=20260519a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user