Добавил редактирование материалла на странице склада
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s

This commit is contained in:
2026-04-23 08:49:26 +03:00
parent 963dc7105a
commit 6fd01c9a6e
5 changed files with 336 additions and 4 deletions

View File

@@ -49,8 +49,12 @@
## SHOULD — правила, которые желательно соблюдать ## SHOULD — правила, которые желательно соблюдать
### Комментарии ### Комментарии
- Python/бекенд: добавлять поясняющие комментарии там, где есть бизнес‑логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления). - Python/бекенд: добавлять поясняющие комментариии там, где они нужны, без личных формулировок.
- Комментарии нейтральные: описывают поведение/причину, без личных формулировок.
- Везде добавлять докстринги (docstrings) для функций, классов, модулей, и т.д.
- Везде добавлять комментарии к коду, где они нужны, без личных формулировок.
- Django HTMLшаблоны: не добавлять templateкомментарии ({# ... #}). - Django HTMLшаблоны: не добавлять templateкомментарии ({# ... #}).
### Стиль и конвенции ### Стиль и конвенции

View File

@@ -17,6 +17,7 @@
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк. - Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке. - Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
- Реестр заданий: комментарий сменного задания/операции отображается под наименованием. - Реестр заданий: комментарий сменного задания/операции отображается под наименованием.
- Склады: по клику по строке сырья/ДО открывается модальное окно редактирования позиции.
- Паспорта изделий/компонентов: ссылки на PDF/DXF/картинки отображаются иконками и открываются в новой вкладке. - Паспорта изделий/компонентов: ссылки на PDF/DXF/картинки отображаются иконками и открываются в новой вкладке.
- Паспорта изделий/сборок: блок «Состав» перенесён в верхнюю часть страницы, в таблицу состава добавлена колонка «Файлы». - Паспорта изделий/сборок: блок «Состав» перенесён в верхнюю часть страницы, в таблицу состава добавлена колонка «Файлы».
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки. - Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.

View File

@@ -97,7 +97,7 @@
</thead> </thead>
<tbody> <tbody>
{% for it in items %} {% for it in items %}
<tr> <tr{% if it.material_id %} class="js-stock-edit" role="button" tabindex="0" data-stock-item-id="{{ it.id }}" data-location="{{ it.location }}" data-location-id="{{ it.location_id }}" data-material-id="{{ it.material_id }}" data-name="{{ it.material.full_name|default:it.material.name }}" data-deal-id="{{ it.deal_id|default:'' }}" data-quantity="{{ it.quantity }}" data-current-length="{{ it.current_length|default:'' }}" data-current-width="{{ it.current_width|default:'' }}" data-unique-id="{{ it.unique_id|default:'' }}" data-is-remnant="{% if it.is_remnant %}1{% endif %}" data-is-customer-supplied="{% if it.is_customer_supplied %}1{% endif %}" data-ff="{{ it.material.category.form_factor|default:'' }}"{% endif %}>
<td>{{ it.location }}</td> <td>{{ it.location }}</td>
<td>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td> <td>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
<td> <td>
@@ -327,6 +327,90 @@
</div> </div>
</div> </div>
<div class="modal fade" id="stockEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<form method="post" action="{% url 'warehouse_stockitem_update' %}" class="modal-content border-secondary">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="stock_item_id" id="stockEditId">
<div class="modal-header border-secondary">
<div>
<h5 class="modal-title">Редактирование позиции</h5>
<div class="small text-muted" id="stockEditInfo"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="row g-2">
<div class="col-md-7">
<label class="form-label">Материал</label>
<select class="form-select" name="material_id" id="stockEditMaterial" required>
{% for m in materials %}
<option value="{{ m.id }}" data-ff="{{ m.category.form_factor|default:'' }}">{{ m.full_name|default:m.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label">Склад</label>
<select class="form-select" name="location_id" id="stockEditLocation" required>
{% for loc in locations %}
<option value="{{ loc.id }}">{{ loc }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Сделка</label>
<select class="form-select" name="deal_id" id="stockEditDeal">
<option value="">— не указано —</option>
{% for d in deals %}
<option value="{{ d.id }}">{{ d.number }}{% if d.company_id %} — {{ d.company.name }}{% endif %}{% if d.description %} — {{ d.description }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Маркировка (ID)</label>
<input class="form-control" name="unique_id" id="stockEditUniqueId" placeholder="Напр. ШТ-001">
</div>
<div class="col-md-4">
<label class="form-label">Кол-во</label>
<input class="form-control" name="quantity" id="stockEditQty" inputmode="decimal" required>
</div>
<div class="col-md-4">
<label class="form-label">Длина (мм)</label>
<input class="form-control" name="current_length" id="stockEditLen" inputmode="decimal">
</div>
<div class="col-md-4">
<label class="form-label">Ширина (мм)</label>
<input class="form-control" name="current_width" id="stockEditWid" inputmode="decimal">
</div>
<div class="col-12 mt-1">
<div class="d-flex flex-wrap gap-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_remnant" id="stockEditIsRemnant" value="1">
<label class="form-check-label" for="stockEditIsRemnant">ДО</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_customer_supplied" id="stockEditIsCustomerSupplied" value="1">
<label class="form-check-label" for="stockEditIsCustomerSupplied">Давальческий</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
</div>
</form>
</div>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('transferModal'); const modal = document.getElementById('transferModal');
@@ -547,6 +631,69 @@
receiptMaterial.addEventListener('change', applyReceiptDefaults); receiptMaterial.addEventListener('change', applyReceiptDefaults);
applyReceiptDefaults(); applyReceiptDefaults();
} }
const editModal = document.getElementById('stockEditModal');
if (editModal) {
const editId = document.getElementById('stockEditId');
const editInfo = document.getElementById('stockEditInfo');
const editMaterial = document.getElementById('stockEditMaterial');
const editLocation = document.getElementById('stockEditLocation');
const editDeal = document.getElementById('stockEditDeal');
const editUniqueId = document.getElementById('stockEditUniqueId');
const editQty = document.getElementById('stockEditQty');
const editLen = document.getElementById('stockEditLen');
const editWid = document.getElementById('stockEditWid');
const editIsRemnant = document.getElementById('stockEditIsRemnant');
const editIsCustomerSupplied = document.getElementById('stockEditIsCustomerSupplied');
function applyEditFF() {
if (!editMaterial || !editWid) return;
const opt = editMaterial.options[editMaterial.selectedIndex];
const ff = (opt && opt.getAttribute('data-ff') || '').toLowerCase();
if (ff === 'bar') {
editWid.value = '';
editWid.disabled = true;
} else {
editWid.disabled = false;
}
}
if (editMaterial) {
editMaterial.addEventListener('change', applyEditFF);
}
document.querySelectorAll('tr.js-stock-edit').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target && e.target.closest('button, a, input, select, label')) return;
const ds = row.dataset || {};
if (editId) editId.value = ds.stockItemId || '';
if (editInfo) editInfo.textContent = `Позиция #${ds.stockItemId || ''}`;
if (editMaterial) editMaterial.value = ds.materialId || '';
if (editLocation) editLocation.value = ds.locationId || '';
if (editDeal) editDeal.value = ds.dealId || '';
if (editUniqueId) editUniqueId.value = ds.uniqueId || '';
if (editQty) editQty.value = ds.quantity || '';
if (editLen) editLen.value = ds.currentLength || '';
if (editWid) editWid.value = ds.currentWidth || '';
if (editIsRemnant) editIsRemnant.checked = (ds.isRemnant || '') === '1';
if (editIsCustomerSupplied) editIsCustomerSupplied.checked = (ds.isCustomerSupplied || '') === '1';
applyEditFF();
const m = bootstrap.Modal.getOrCreateInstance(editModal);
m.show();
});
row.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
row.click();
});
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -56,6 +56,7 @@ from .views import (
LegacyRegistryView, LegacyRegistryView,
LegacyWriteOffsView, LegacyWriteOffsView,
WarehouseReceiptCreateView, WarehouseReceiptCreateView,
WarehouseStockItemUpdateView,
WarehouseStocksView, WarehouseStocksView,
WarehouseTransferCreateView, WarehouseTransferCreateView,
ProcurementDashboardView, ProcurementDashboardView,
@@ -119,6 +120,7 @@ urlpatterns = [
path('workitems/<int:deal_id>/<int:entity_id>/', WorkItemEntityListView.as_view(), name='workitem_entity_list'), path('workitems/<int:deal_id>/<int:entity_id>/', WorkItemEntityListView.as_view(), name='workitem_entity_list'),
path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'), path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'),
path('warehouse/stock-item/update/', WarehouseStockItemUpdateView.as_view(), name='warehouse_stockitem_update'),
path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'), path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'), path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),

View File

@@ -4442,6 +4442,184 @@ class WarehouseStocksView(LoginRequiredMixin, TemplateView):
return ctx return ctx
class WarehouseStockItemUpdateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
roles = get_user_roles(request.user)
role = primary_role(roles)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'prod_head', 'director']):
return JsonResponse({'error': 'forbidden'}, status=403)
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('warehouse_stocks')
stock_item_id = (request.POST.get('stock_item_id') or '').strip()
if not stock_item_id.isdigit():
messages.error(request, 'Не выбрана складская позиция.')
return redirect(next_url)
qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.')
len_raw = (request.POST.get('current_length') or '').strip().replace(',', '.')
wid_raw = (request.POST.get('current_width') or '').strip().replace(',', '.')
deal_id_raw = (request.POST.get('deal_id') or '').strip()
unique_id_raw = (request.POST.get('unique_id') or '').strip()
material_id_raw = (request.POST.get('material_id') or '').strip()
location_id_raw = (request.POST.get('location_id') or '').strip()
try:
qty = float(qty_raw)
except ValueError:
qty = 0.0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
def parse_float_opt(s: str):
s = (s or '').strip().replace(',', '.')
if not s:
return None
try:
return float(s)
except ValueError:
return None
cur_len = parse_float_opt(len_raw)
cur_wid = parse_float_opt(wid_raw)
is_remnant = bool(request.POST.get('is_remnant'))
is_customer_supplied = bool(request.POST.get('is_customer_supplied'))
deal_id = None
if deal_id_raw.isdigit():
deal_id = int(deal_id_raw)
material_id = None
if material_id_raw.isdigit():
material_id = int(material_id_raw)
location_id = None
if location_id_raw.isdigit():
location_id = int(location_id_raw)
unique_id = unique_id_raw or None
with transaction.atomic():
si = (
StockItem.objects.select_for_update(of=('self',))
.select_related('material', 'material__category', 'location', 'deal')
.filter(id=int(stock_item_id), is_archived=False)
.first()
)
if not si:
messages.error(request, 'Складская позиция не найдена.')
return redirect(next_url)
if not getattr(si, 'material_id', None) or getattr(si, 'entity_id', None):
messages.error(request, 'Редактирование доступно только для сырья/ДО.')
return redirect(next_url)
if role == 'master' and not has_any_role(roles, ['admin', 'technologist', 'clerk', 'prod_head', 'director']):
allowed_ws_ids = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if not allowed_ws_ids and profile:
user_machine_ids = list(profile.machines.values_list('id', flat=True))
allowed_ws_ids = list(
Machine.objects.filter(id__in=user_machine_ids)
.exclude(workshop_id__isnull=True)
.values_list('workshop_id', flat=True)
)
allowed_loc_ids = list(
Workshop.objects.filter(id__in=allowed_ws_ids)
.exclude(location_id__isnull=True)
.values_list('location_id', flat=True)
)
if allowed_loc_ids and int(si.location_id) not in {int(x) for x in allowed_loc_ids}:
messages.error(request, 'Мастер может редактировать только позиции своего цеха.')
return redirect(next_url)
ship_loc = (
Location.objects.filter(
Q(name__icontains='отгруж')
| Q(name__icontains='Отгруж')
| Q(name__icontains='отгруз')
| Q(name__icontains='Отгруз')
)
.order_by('id')
.first()
)
ship_loc_id = ship_loc.id if ship_loc else None
if deal_id is not None:
ok = Deal.objects.filter(id=int(deal_id)).exists()
if not ok:
messages.error(request, 'Выбрана несуществующая сделка.')
return redirect(next_url)
if material_id is None:
messages.error(request, 'Выбери материал.')
return redirect(next_url)
mat = Material.objects.select_related('category').filter(id=int(material_id)).first()
if not mat:
messages.error(request, 'Материал не найден.')
return redirect(next_url)
if location_id is None:
messages.error(request, 'Выбери склад.')
return redirect(next_url)
loc = Location.objects.filter(id=int(location_id)).first()
if not loc:
messages.error(request, 'Склад не найден.')
return redirect(next_url)
if ship_loc_id and int(location_id) == int(ship_loc_id):
messages.error(request, 'Перенос на склад отгрузки выполняется через отгрузку/перемещение.')
return redirect(next_url)
ff = (getattr(getattr(mat, 'category', None), 'form_factor', '') or '').strip().lower()
if ff == 'sheet':
if cur_len is None or cur_wid is None:
messages.error(request, 'Для листового материала нужно заполнить длину и ширину (мм).')
return redirect(next_url)
elif ff == 'bar':
if cur_len is None:
messages.error(request, 'Для проката нужно заполнить длину (мм).')
return redirect(next_url)
cur_wid = None
if unique_id:
exists = StockItem.objects.filter(unique_id=unique_id).exclude(id=int(si.id)).exists()
if exists:
messages.error(request, 'Маркировка (ID) уже используется в другой позиции.')
return redirect(next_url)
si.quantity = float(qty)
si.current_length = cur_len
si.current_width = cur_wid
si.deal_id = deal_id
si.unique_id = unique_id
si.is_remnant = bool(is_remnant)
si.is_customer_supplied = bool(is_customer_supplied)
si.material_id = int(material_id)
si.location_id = int(location_id)
si.save(
update_fields=[
'quantity',
'current_length',
'current_width',
'deal',
'unique_id',
'is_remnant',
'is_customer_supplied',
'material',
'location',
]
)
messages.success(request, 'Позиция обновлена.')
return redirect(next_url)
class WarehouseTransferCreateView(LoginRequiredMixin, View): class WarehouseTransferCreateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)