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:
parent
001509f2b1
commit
024f5349ff
4 changed files with 55 additions and 9 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue