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:
wasrusgen 2026-05-19 09:20:03 +03:00
parent f473f1dc03
commit 3e7ae7764a
5 changed files with 693 additions and 6 deletions

View File

@ -148,6 +148,9 @@ async def _dispatch_post(request: Request):
"sign_request_create": _handle_sign_request_create,
"sign_request_submit": _handle_sign_request_submit,
"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_create": proposals_mod.handle_create,
"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)
@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")
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}
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]:
"""Детальная карточка сборки."""
cfg = get_config()
@ -2517,6 +2562,8 @@ def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]:
"manager_note": row.get("manager_note", ""),
"kitchen_price": row.get("kitchen_price", ""),
"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,
})
assembly_price = round(kitchen_price * 0.09, 2)
return {"ok": True, "kitchen_price": kitchen_price, "assembly_price": assembly_price}
client_rate, assembler_rate = _resolve_rates(
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}
# =================================================================

View 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, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 };
})();

View File

@ -194,6 +194,7 @@ async function renderManagerHome(me) {
{ icon: "clipboard", title: "Заказы", subtitle: "Сборки + заявки", href: "#/assembly" },
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
{ 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>`));
const grid = el(`<div class="quick-grid"></div>`);
@ -1695,6 +1696,9 @@ function routeByHash() {
else init();
} else if (location.hash.startsWith("#/assembly")) {
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")) {
if (typeof MasterTools !== "undefined") {
const h = location.hash;

View File

@ -102,13 +102,29 @@ const AssemblyDetailScreen = (function () {
</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 = `
<div style="margin:12px 16px 0;border:1px solid var(--border);border-radius:12px;
padding:0 12px;background:var(--surface);">
${row("Адрес", data.address)}
${data.kitchen_price ? row("Стоимость кухни", Number(data.kitchen_price).toLocaleString("ru-RU") + " ₽") : ""}
${data.kitchen_price ? row("Стоимость сборки", Number(Math.round(data.kitchen_price * 0.09)).toLocaleString("ru-RU") + " ₽", {color: "var(--accent)"}) : ""}
${_priceRows}
${row("Объём работ", data.scope_of_work)}
${row("Дата сборки", fmtDate(data.scheduled_at))}
${row("Начало", fmtDate(data.started_at))}

View File

@ -53,7 +53,8 @@
<script src="assets/orders.js?v=20260518l"></script>
<script src="assets/master_tools.js?v=20260518p"></script>
<script src="assets/signrequest.js?v=20260518o"></script>
<script src="assets/assembly_detail.js?v=20260518o"></script>
<script src="assets/app.js?v=20260518n"></script>
<script src="assets/admin_rates.js?v=20260519a"></script>
<script src="assets/assembly_detail.js?v=20260519a"></script>
<script src="assets/app.js?v=20260519a"></script>
</body>
</html>