Remember per-supplier import column mapping; skip duplicate накладна on re-import

- Supplier.import_mapping stores the last confirmed column mapping (JSON),
  applied as a trusted pre-fill on the next import for that supplier —
  suppliers whose export tool produces the same layout every time no
  longer require re-mapping columns each month.
- Import confirm now skips rows whose № накладної already exists among
  that supplier's deliveries, reporting them as duplicates instead of
  silently double-importing an accidentally re-uploaded file.
This commit is contained in:
byrsapty 2026-08-25 17:09:20 +03:00
parent 001509f2b1
commit 024f5349ff
4 changed files with 55 additions and 9 deletions

View file

@ -1,3 +1,4 @@
import json
from datetime import datetime
from flask import flash, redirect, render_template, request, url_for
@ -206,9 +207,15 @@ def suppliers_import():
sheet = request.form.get("sheet") or None
header_row = request.form.get("header_row", type=int)
stored_mapping = None
if supplier and supplier.import_mapping:
try:
stored_mapping = json.loads(supplier.import_mapping)
except (TypeError, ValueError):
stored_mapping = None
try:
preview = supplier_import.load_preview(token, sheet=sheet, header_row=header_row)
preview = supplier_import.load_preview(token, sheet=sheet, header_row=header_row, stored_mapping=stored_mapping)
except FileNotFoundError:
flash("Файл імпорту застарів — завантажте його ще раз", "error")
return redirect(url_for("admin.suppliers_import", supplier_id=supplier_id))
@ -271,21 +278,38 @@ def suppliers_import_confirm():
do_restock = bool(request.form.get("restock_inventory")) and inventory_enabled()
ingredients_by_name = {i.name.strip().lower(): i for i in Ingredient.query.filter_by(is_active=True).all()}
# Накладна/invoice numbers already recorded for this supplier — catches
# re-uploading the same file (or the same накладна twice) without
# blocking a legitimate mixed re-import that only partially overlaps.
seen_refs = {d.reference for d in supplier.deliveries if d.reference}
duplicates = []
created = 0
for row in rows:
ref = row.get("reference")
if ref and ref in seen_refs:
duplicates.append(f"«{row['product_name']}» (накладна {ref})")
continue
ingredient = ingredients_by_name.get(row["product_name"].strip().lower()) if do_restock else None
delivery = SupplierDelivery(supplier_id=supplier.id, ingredient_id=ingredient.id if ingredient else None, **row)
db.session.add(delivery)
db.session.flush()
created += 1
if ref:
seen_refs.add(ref)
if ingredient:
_apply_stock_topup(delivery, supplier.name)
supplier.import_mapping = json.dumps(mapping)
db.session.commit()
log_action("supplier_import", f"Імпортовано {created} поставок для «{supplier.name}» з Excel")
message = f"Імпортовано {created} з {created + len(skipped)} рядків"
if skipped:
message += " — пропущено: " + "; ".join(skipped[:5]) + ("" if len(skipped) > 5 else "")
flash(message, "success" if not skipped else "warning")
total = created + len(skipped) + len(duplicates)
message = f"Імпортовано {created} з {total} рядків"
notes = list(skipped)
if duplicates:
notes.append(f"{len(duplicates)} пропущено як дублікати вже імпортованої накладної: " + "; ".join(duplicates[:3]) + ("" if len(duplicates) > 3 else ""))
if notes:
message += "" + "; ".join(notes[:5]) + ("" if len(notes) > 5 else "")
flash(message, "success" if not notes else "warning")
return redirect(url_for("admin.suppliers_detail", supplier_id=supplier.id))

View file

@ -40,3 +40,4 @@ def migrate() -> None:
_add_column_if_missing("orders", "manual_discount_note", "manual_discount_note VARCHAR(128)")
_add_column_if_missing("supplier_deliveries", "reference", "reference VARCHAR(64)")
_add_column_if_missing("supplier_deliveries", "sku", "sku VARCHAR(64)")
_add_column_if_missing("suppliers", "import_mapping", "import_mapping TEXT")

View file

@ -248,6 +248,7 @@ class Supplier(db.Model):
note = db.Column(db.Text, default="")
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow)
import_mapping = db.Column(db.Text) # JSON {field: col_index}, remembered from the last confirmed Excel import
deliveries = db.relationship(
"SupplierDelivery",

View file

@ -202,18 +202,32 @@ def _detect_header_row(rows: list[tuple]) -> tuple[int, int]:
return best_idx, best_score
def _guess_mapping(headers: list[str]) -> dict[str, int]:
def _guess_mapping(headers: list[str], stored_mapping: dict[str, int] | None = None) -> dict[str, int]:
"""Returns {field: column_index} for whatever it could confidently
guess from a header row. Tries an exact substring match first, then
falls back to per-word fuzzy similarity (real files have real typos
one sample had "отлата" for "оплата") so a close-enough header still
gets a starting guess. The admin always sees and can correct the guess
on the mapping screen either way."""
on the mapping screen either way.
`stored_mapping`, when given, is the mapping the admin confirmed the
last time THIS supplier's file was imported — trusted over the
synonym/fuzzy guess for any field it covers, since a supplier's export
tool tends to produce the same column layout every time. Only applied
for column indices that actually exist in the current header row."""
mapping: dict[str, int] = {}
used_cols: set[int] = set()
normalized = [_normalize(h) if h else "" for h in headers]
if stored_mapping:
for field, col_idx in stored_mapping.items():
if field in FIELD_SYNONYMS and 0 <= col_idx < len(headers) and col_idx not in used_cols:
mapping[field] = col_idx
used_cols.add(col_idx)
for field, synonyms in FIELD_SYNONYMS.items():
if field in mapping:
continue
syn_norm = [_normalize(s) for s in synonyms]
for col_idx, cell in enumerate(normalized):
if not cell or col_idx in used_cols:
@ -252,7 +266,13 @@ def guess_supplier_identity(rows: list[tuple]) -> tuple[str, str]:
return "", ""
def load_preview(token: str, sheet: str | None = None, header_row: int | None = None, preview_rows: int = 10) -> dict:
def load_preview(
token: str,
sheet: str | None = None,
header_row: int | None = None,
preview_rows: int = 10,
stored_mapping: dict[str, int] | None = None,
) -> dict:
"""Reads the saved temp file (any supported format) and returns
everything the mapping template needs. Picks the best-scoring sheet
automatically when there's more than one and none was specified —
@ -274,7 +294,7 @@ def load_preview(token: str, sheet: str | None = None, header_row: int | None =
header_row = detected_row
headers = [str(c).strip() if c is not None else "" for c in rows[header_row]] if rows else []
mapping = _guess_mapping(headers)
mapping = _guess_mapping(headers, stored_mapping)
name_guess, tax_id_guess = guess_supplier_identity(rows)
col_count = max((len(r) for r in rows), default=0)