эксплуатация: показания счётчиков, энергоактивы и РРО

Конфиг счётчиков и день/ночь-тарифы, учёт энергосистем (генераторы,
инверторы), доступ к разделу РРО/ПРРО.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:03:44 +03:00
parent 3e969e29dc
commit 16bf2cf1c7
26 changed files with 2044 additions and 88 deletions
+358
View File
@@ -0,0 +1,358 @@
{% load i18n %}
<div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:1.25rem;">
<div>
<h2 style="font-size:1.0625rem; font-weight:500; margin:0;">{% trans "Энергооборудование" %}</h2>
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin:0.25rem 0 0;">
{% trans "Инверторы и генераторы: объекты, обслуживание и плановое ТО" %}
</p>
</div>
<button type="button" class="btn btn-primary" onclick="document.getElementById('power-create-panel').style.display='block'; document.getElementById('power-create-overlay').style.display='block';">
{% trans "Добавить" %}
</button>
</div>
<div style="display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:0.75rem; margin-bottom:1.25rem;">
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">{% trans "Всего" %}</div>
<div style="font-size:1.5rem; font-weight:500;">{{ power_counts.total }}</div>
</div>
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">{% trans "Инверторы" %}</div>
<div style="font-size:1.5rem; font-weight:500;">{{ power_counts.inverter }}</div>
</div>
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">{% trans "Генераторы" %}</div>
<div style="font-size:1.5rem; font-weight:500;">{{ power_counts.generator }}</div>
</div>
<div class="stat-card" style="padding:1rem 1.25rem;">
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.375rem;">{% trans "Просрочено ТО" %}</div>
<div style="font-size:1.5rem; font-weight:500; {% if power_counts.overdue %}color:var(--color-destructive);{% endif %}">{{ power_counts.overdue }}</div>
</div>
</div>
{% if power_form_message %}
<div style="background:rgba(22,163,74,0.08); border:1px solid rgba(22,163,74,0.3); border-radius:0.75rem; padding:0.75rem 1rem; margin-bottom:1rem; font-size:0.875rem; color:#16a34a;">{{ power_form_message }}</div>
{% endif %}
{% if power_form_error %}
<div style="background:color-mix(in srgb, var(--color-destructive) 8%, transparent); border:1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); border-radius:0.75rem; padding:0.75rem 1rem; margin-bottom:1rem; font-size:0.875rem; color:var(--color-destructive);">{{ power_form_error }}</div>
{% endif %}
<form method="get" action="/accounting/" style="display:flex; gap:0.75rem; flex-wrap:wrap; margin-bottom:1rem; align-items:flex-end;">
<input type="hidden" name="tab" value="power">
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Тип" %}</label>
<select name="power_type" class="input" style="min-width:10rem;" onchange="this.form.requestSubmit()">
<option value="">{% trans "Все" %}</option>
{% for id, name in power_asset_types %}
<option value="{{ id }}" {% if power_type_filter == id|stringformat:"s" %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div style="min-width:16rem; flex:1;">
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Объект" %}</label>
<div class="searchable-select" data-placeholder="{% trans 'Аптека / офис...' %}">
<select name="power_location" hidden onchange="this.form.requestSubmit()">
<option value="">{% trans "Все объекты" %}</option>
{% for loc in power_locations %}
<option value="{{ loc.id }}" data-search="{{ loc.search_text }}" {% if power_location_filter == loc.id|stringformat:"s" %}selected{% endif %}>{{ loc }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Статус" %}</label>
<select name="power_status" class="input" style="min-width:10rem;" onchange="this.form.requestSubmit()">
<option value="">{% trans "Все" %}</option>
{% for id, name in power_asset_statuses %}
<option value="{{ id }}" {% if power_status_filter == id|stringformat:"s" %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<label style="display:flex; align-items:center; gap:0.375rem; font-size:0.8125rem; padding-bottom:0.35rem; cursor:pointer;">
<input type="checkbox" name="power_due" value="1" {% if power_due_filter %}checked{% endif %} onchange="this.form.requestSubmit()">
{% trans "Только просроченное ТО" %}
</label>
</form>
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
<thead>
<tr style="border-bottom:1px solid var(--color-border);">
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">{% trans "Тип / модель" %}</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">{% trans "Объект" %}</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">{% trans "С/н" %}</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">{% trans "кВт" %}</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">{% trans "ТО" %}</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">{% trans "Статус" %}</th>
</tr>
</thead>
<tbody>
{% for asset in power_assets_page %}
<tr class="it-row" style="border-bottom:1px solid var(--color-border); cursor:pointer;" onclick="var r=document.getElementById('power-row-{{ asset.id }}'); r.style.display=r.style.display==='none'?'':'none';">
<td style="padding:0.75rem 1rem;">
<strong>{{ asset.asset_type.name }}</strong>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ asset.brand }} {{ asset.model_name }}</div>
</td>
<td style="padding:0.75rem 1rem; color:var(--color-muted-foreground); font-size:0.8125rem;">{{ asset.location }}</td>
<td style="padding:0.75rem 1rem; color:var(--color-muted-foreground); font-size:0.8125rem;">{{ asset.serial_number|default:"—" }}</td>
<td style="padding:0.75rem 1rem;">{{ asset.capacity_kw|default:"—" }}</td>
<td style="padding:0.75rem 1rem; font-size:0.8125rem;">
{% if asset.next_service_at %}
{% if asset.service_overdue %}
<span class="badge badge-danger">{% trans "Просрочено" %} {{ asset.next_service_at|date:"d.m.Y" }}</span>
{% else %}
<span style="color:var(--color-muted-foreground);">{% trans "до" %} {{ asset.next_service_at|date:"d.m.Y" }}</span>
{% endif %}
{% elif asset.last_serviced_at %}
<span style="color:var(--color-muted-foreground);">{% trans "было" %} {{ asset.last_serviced_at|date:"d.m.Y" }}</span>
{% else %}
<span style="color:var(--color-muted-foreground);">—</span>
{% endif %}
</td>
<td style="padding:0.75rem 1rem;"><span class="badge badge-muted">{{ asset.status.name }}</span></td>
</tr>
<tr id="power-row-{{ asset.id }}" style="{% if power_open_asset_id != asset.id %}display:none;{% endif %} background:var(--color-muted); border-bottom:1px solid var(--color-border);">
<td colspan="6" style="padding:1rem;">
<form hx-post="/power-assets/{{ asset.id }}/update/" hx-target="#main-content" hx-indicator="#loader"
style="display:grid; grid-template-columns:repeat(auto-fill,minmax(11rem,1fr)); gap:0.75rem; align-items:end;" onclick="event.stopPropagation();">
{% csrf_token %}
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Тип" %}</label>
<select name="asset_type" class="input" style="width:100%;" required>
{% for id, name in power_asset_types %}
<option value="{{ id }}" {% if id == asset.asset_type_id %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div style="grid-column: span 2;">
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Объект" %}</label>
<div class="searchable-select" data-placeholder="{% trans 'Поиск объекта...' %}">
<select name="location_id" required hidden>
{% for loc in power_locations %}
<option value="{{ loc.id }}" data-search="{{ loc.search_text }}" {% if loc.id == asset.location_id %}selected{% endif %}>{{ loc }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Бренд" %}</label>
<input type="text" name="brand" value="{{ asset.brand }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Модель" %}</label>
<input type="text" name="model_name" value="{{ asset.model_name }}" class="input" style="width:100%;" required>
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Серийный №" %}</label>
<input type="text" name="serial_number" value="{{ asset.serial_number }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Инв. №" %}</label>
<input type="text" name="inventory_number" value="{{ asset.inventory_number }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "кВт" %}</label>
<input type="text" name="capacity_kw" value="{{ asset.capacity_kw|default_if_none:'' }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Топливо" %}</label>
<input type="text" name="fuel_type" value="{{ asset.fuel_type }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "АКБ кВт·ч" %}</label>
<input type="text" name="battery_capacity_kwh" value="{{ asset.battery_capacity_kwh|default_if_none:'' }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Установлен" %}</label>
<input type="date" name="installed_at" value="{{ asset.installed_at|date:'Y-m-d' }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Гарантия до" %}</label>
<input type="date" name="warranty_until" value="{{ asset.warranty_until|date:'Y-m-d' }}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Статус" %}</label>
<select name="status" class="input" style="width:100%;">
{% for id, name in power_asset_statuses %}
<option value="{{ id }}" {% if id == asset.status_id %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div style="grid-column: 1 / -1;">
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Примечания" %}</label>
<input type="text" name="notes" value="{{ asset.notes }}" class="input" style="width:100%;">
</div>
<div>
<button type="submit" class="btn btn-primary">{% trans "Сохранить" %}</button>
</div>
</form>
<div style="margin-top:1.25rem; padding-top:1rem; border-top:1px solid var(--color-border);" onclick="event.stopPropagation();">
<div style="font-size:0.8125rem; font-weight:600; margin-bottom:0.75rem;">{% trans "Журнал обслуживания" %}</div>
<form hx-post="/power-assets/{{ asset.id }}/service/" hx-target="#main-content" hx-indicator="#loader"
style="display:grid; grid-template-columns:repeat(auto-fill,minmax(10rem,1fr)); gap:0.625rem; align-items:end; margin-bottom:1rem;">
{% csrf_token %}
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Дата" %}</label>
<input type="date" name="service_date" value="{{ power_today|date:'Y-m-d' }}" class="input" style="width:100%;" required>
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Вид работ" %}</label>
<select name="kind" class="input" style="width:100%;">
{% for code, label in power_service_kinds %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div style="grid-column: span 2;">
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Что сделано" %}</label>
<input type="text" name="description" class="input" style="width:100%;" required placeholder="{% trans 'Например: замена масла, проверка АКБ…' %}">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Исполнитель" %}</label>
<input type="text" name="performed_by_name" class="input" style="width:100%;" placeholder="{% trans 'ФИО / подрядчик' %}">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Моточасы" %}</label>
<input type="text" name="engine_hours" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Стоимость" %}</label>
<input type="text" name="cost" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Следующее ТО" %}</label>
<input type="date" name="next_service_at" class="input" style="width:100%;">
</div>
<div>
<button type="submit" class="btn btn-secondary" style="border:1px solid var(--color-border);">{% trans "Записать ТО" %}</button>
</div>
</form>
{% if asset.service_logs.all %}
<ul style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:0.5rem;">
{% for log in asset.service_logs.all %}
{% if forloop.counter <= 8 %}
<li style="padding:0.625rem 0.75rem; background:var(--color-card); border:1px solid var(--color-border); border-radius:var(--radius); font-size:0.8125rem;">
<div style="display:flex; justify-content:space-between; gap:0.75rem; flex-wrap:wrap;">
<strong>{{ log.service_date|date:"d.m.Y" }} · {{ log.get_kind_display }}</strong>
<span style="color:var(--color-muted-foreground);">
{% if log.performed_by_name %}{{ log.performed_by_name }}{% elif log.performed_by %}{{ log.performed_by.get_full_name }}{% endif %}
{% if log.engine_hours %} · {{ log.engine_hours }} {% trans "м/ч" %}{% endif %}
{% if log.next_service_at %} · {% trans "след." %} {{ log.next_service_at|date:"d.m.Y" }}{% endif %}
</span>
</div>
<div style="margin-top:0.25rem;">{{ log.description }}</div>
</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin:0;">{% trans "Записей обслуживания пока нет." %}</p>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">{% trans "Пока нет записей — добавьте инвертор или генератор" %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "partials/pagination.html" with page=power_assets_page base_url="/accounting/?tab=power" page_param="power_page" size_param="power_per_page" item_label="единиц" %}
</div>
<div id="power-create-overlay" onclick="this.style.display='none'; document.getElementById('power-create-panel').style.display='none';" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:1000;"></div>
<div id="power-create-panel" style="display:none; position:fixed; top:0; right:0; height:100vh; width:28rem; max-width:92vw; background:var(--color-card); box-shadow:-8px 0 24px rgba(0,0,0,0.18); z-index:1001; overflow-y:auto;">
<div style="padding:1rem 1.25rem; border-bottom:1px solid var(--color-border); display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; background:var(--color-card);">
<span style="font-weight:600;">{% trans "Новое энергооборудование" %}</span>
<button type="button" class="btn btn-ghost btn-icon" onclick="document.getElementById('power-create-panel').style.display='none'; document.getElementById('power-create-overlay').style.display='none';" aria-label="{% trans 'Закрыть' %}">×</button>
</div>
<form hx-post="/power-assets/create/" hx-target="#main-content" hx-indicator="#loader"
style="padding:1.25rem; display:flex; flex-direction:column; gap:0.875rem;">
{% csrf_token %}
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Тип" %} *</label>
<select name="asset_type" class="input" style="width:100%;" required>
<option value="">{% trans "— выберите —" %}</option>
{% for id, name in power_asset_types %}
<option value="{{ id }}">{{ name }}</option>
{% endfor %}
</select>
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Объект (аптека / офис)" %} *</label>
<div class="searchable-select" data-placeholder="{% trans 'Поиск: А3, адрес...' %}">
<select name="location_id" required hidden>
<option value="">{% trans "— выберите объект —" %}</option>
{% for loc in power_locations %}
<option value="{{ loc.id }}" data-search="{{ loc.search_text }}">{{ loc }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem;">
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Бренд" %}</label>
<input type="text" name="brand" class="input" style="width:100%;" placeholder="Deye, Victron…">
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Модель" %} *</label>
<input type="text" name="model_name" class="input" style="width:100%;" required>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem;">
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Серийный №" %}</label>
<input type="text" name="serial_number" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Инв. №" %}</label>
<input type="text" name="inventory_number" class="input" style="width:100%;">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem;">
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Мощность, кВт" %}</label>
<input type="text" name="capacity_kw" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Топливо" %}</label>
<input type="text" name="fuel_type" class="input" style="width:100%;" placeholder="{% trans 'для генератора' %}">
</div>
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Ёмкость АКБ, кВт·ч" %}</label>
<input type="text" name="battery_capacity_kwh" class="input" style="width:100%;" placeholder="{% trans 'для инвертора' %}">
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem;">
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Дата установки" %}</label>
<input type="date" name="installed_at" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Гарантия до" %}</label>
<input type="date" name="warranty_until" class="input" style="width:100%;">
</div>
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Статус" %}</label>
<select name="status" class="input" style="width:100%;">
{% for id, name in power_asset_statuses %}
<option value="{{ id }}">{{ name }}</option>
{% endfor %}
</select>
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Примечания" %}</label>
<textarea name="notes" class="input" style="width:100%; min-height:4rem;"></textarea>
</div>
<button type="submit" class="btn btn-primary">{% trans "Сохранить" %}</button>
</form>
</div>
<script>if (typeof initSearchableSelects === 'function') initSearchableSelects(document);</script>
</div>