Добавил редактирование материалла на странице склада
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s
This commit is contained in:
@@ -49,8 +49,12 @@
|
||||
## SHOULD — правила, которые желательно соблюдать
|
||||
|
||||
### Комментарии
|
||||
- Python/бекенд: добавлять поясняющие комментарии там, где есть бизнес‑логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления).
|
||||
- Комментарии нейтральные: описывают поведение/причину, без личных формулировок.
|
||||
- Python/бекенд: добавлять поясняющие комментариии там, где они нужны, без личных формулировок.
|
||||
|
||||
- Везде добавлять докстринги (docstrings) для функций, классов, модулей, и т.д.
|
||||
|
||||
- Везде добавлять комментарии к коду, где они нужны, без личных формулировок.
|
||||
|
||||
- Django HTML‑шаблоны: не добавлять template‑комментарии ({# ... #}).
|
||||
|
||||
### Стиль и конвенции
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
|
||||
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
|
||||
- Реестр заданий: комментарий сменного задания/операции отображается под наименованием.
|
||||
- Склады: по клику по строке сырья/ДО открывается модальное окно редактирования позиции.
|
||||
- Паспорта изделий/компонентов: ссылки на PDF/DXF/картинки отображаются иконками и открываются в новой вкладке.
|
||||
- Паспорта изделий/сборок: блок «Состав» перенесён в верхнюю часть страницы, в таблицу состава добавлена колонка «Файлы».
|
||||
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
|
||||
<td>
|
||||
@@ -327,6 +327,90 @@
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('transferModal');
|
||||
@@ -547,6 +631,69 @@
|
||||
receiptMaterial.addEventListener('change', 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>
|
||||
{% endblock %}
|
||||
@@ -56,6 +56,7 @@ from .views import (
|
||||
LegacyRegistryView,
|
||||
LegacyWriteOffsView,
|
||||
WarehouseReceiptCreateView,
|
||||
WarehouseStockItemUpdateView,
|
||||
WarehouseStocksView,
|
||||
WarehouseTransferCreateView,
|
||||
ProcurementDashboardView,
|
||||
@@ -119,6 +120,7 @@ urlpatterns = [
|
||||
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/stock-item/update/', WarehouseStockItemUpdateView.as_view(), name='warehouse_stockitem_update'),
|
||||
path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
|
||||
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
|
||||
|
||||
|
||||
@@ -4442,6 +4442,184 @@ class WarehouseStocksView(LoginRequiredMixin, TemplateView):
|
||||
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):
|
||||
def post(self, request, *args, **kwargs):
|
||||
profile = getattr(request.user, 'profile', None)
|
||||
|
||||
Reference in New Issue
Block a user