#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Сопоставление отсканированных КИЗ с товарной накладной + экспорт CSV. Кроссплатформенный порт скрипта xlsx_to_csv_utf8.vbs (тот же алгоритм). Что делает: 1. Спрашивает два файла (окна выбора; файл КИЗ можно передать аргументом командной строки или перетащить на .bat / .command): файл 1 - товарная накладная (ТОРГ-12), файл 2 - таблица с отсканированными КИЗ (последняя заполненная колонка - коды DataMatrix). 2. Из каждого КИЗ извлекает GTIN (DataMatrix: "01" + 14 цифр). 3. Ищет GTIN в колонке B ("Наименование") накладной, начиная с ряда 19: GTIN - последнее "слово" значения (текст после последнего пробела). 4. Из найденного ряда берет: название (B, текст до последнего пробела), цену (L), ставку НДС (N, со знаком %). 5. Формирует строку CSV для каждого КИЗ: B - название, C - цена, D - 1, E - 796, F - ставка НДС (%), G - "КИЗ". CSV - чистый текст, никаких преобразований чисел не происходит. КИЗ в CSV обрезается до первых 31 символа (код без криптохвоста); более короткий КИЗ остается как есть, с замечанием в отчете. 6. Сверяет: число КИЗ каждого GTIN в файле 2 = количеству из K накладной. 7. Сохраняет CSV рядом с файлом 2 (то же имя, .csv) и отчет _report.txt. Исходные файлы Excel не изменяются. Запуск: Windows: kiz_to_csv.bat (или: python kiz_to_csv.py) Mac: ./kiz_to_csv.command (или: python3 kiz_to_csv.py) Linux: python3 kiz_to_csv.py Аргументы: один файл -> он считается файлом 2 (КИЗ), файл 1 спросят; два файла -> первый = накладная, второй = КИЗ, без диалогов. """ import os import sys try: import openpyxl except ImportError: sys.exit("Не найден модуль openpyxl. Установите: pip install openpyxl") START_ROW = 19 # в накладной поиск по колонке B начинается с этого ряда COL_NAME = 2 # B - "Наименование ... GTIN" (GTIN - после последнего пробела) COL_QTY = 11 # K - количество COL_PRICE = 12 # L - цена COL_VAT = 14 # N - ставка НДС, % KIZ_LEN = 31 # КИЗ без криптохвоста: "01" + GTIN (14) + "21" + серийный номер (13) # ---------------------------------------------------------------- helpers def clean_text(v): if v is None: return "" return str(v).replace("\r", "").replace("\n", "").strip() def extract_gtin(s): """Из DataMatrix-строки КИЗ извлекает GTIN: '01' + 14 цифр.""" t = s.replace(" ", "") if len(t) >= 16 and t[:2] == "01" and t[2:16].isdigit(): return t[2:16] return "" def split_name_gtin(v): """Колонка B накладной: 'Наименование ... GTIN'. Возвращает (название, GTIN): GTIN - текст после последнего пробела, название - всё до него.""" t = clean_text(v) if " " not in t: return "", digits_only(t) name, _, gtin = t.rpartition(" ") return name.strip(), digits_only(gtin) def digits_only(v): """Только цифры из значения ячейки (числа - без экспоненты).""" if v is None: return "" if isinstance(v, bool): return "" if isinstance(v, int): return str(v) if isinstance(v, float): return str(int(v)) if float(v).is_integer() else str(v) return "".join(ch for ch in str(v) if ch.isdigit()) def norm_gtin(g): """Без ведущих нулей: GTIN может лежать как 14-значный код с нулем или как 13-значное число без него.""" t = g.lstrip("0") return t if t else "0" def num_text(v): """Число как текст с точкой в десятичной части (358.18, а не 358,18).""" if v is None or isinstance(v, bool): return "" if isinstance(v, (int, float)): t = ("%f" % v).rstrip("0").rstrip(".") return t t = str(v).strip() if t.endswith("%"): t = t[:-1].strip() return t def format_field(v): """Форматирование значения для CSV (как FormatField в VBS).""" if v is None: return "" if isinstance(v, bool): return str(v) if isinstance(v, int): return str(v) if isinstance(v, float): return ("%f" % v).rstrip("0").rstrip(".") return str(v) def pick_file(title, gui): """Окно выбора файла; без GUI - запрос из консоли.""" if gui: from tkinter import filedialog res = filedialog.askopenfilename( title=title, filetypes=[("Excel файлы", "*.xlsx *.xlsm *.xls"), ("Все файлы", "*.*")]) return res or "" return input(title + " (полный путь): ").strip().strip('"').strip("'") def show_message(title, text, warning=False): try: import tkinter from tkinter import messagebox root = tkinter.Tk() root.withdraw() root.attributes("-topmost", True) if warning: messagebox.showwarning(title, text, parent=root) else: messagebox.showinfo(title, text, parent=root) root.destroy() except Exception: print("\n" + "=" * 60) print(title) print("=" * 60) print(text) # ---------------------------------------------------------------- core def load_nakl(path): """Читает накладную: {key GTIN -> ряд}, {key GTIN -> количество K}. GTIN берется из колонки B ("Наименование") - текст после последнего пробела.""" try: wb = openpyxl.load_workbook(path, read_only=True, data_only=True) except Exception as e: sys.exit("Не удалось открыть накладную '%s': %s" % (path, e)) ws = wb.worksheets[0] dict_row, dict_qty = {}, {} for r in range(START_ROW, ws.max_row + 1): _, gtin_b = split_name_gtin(ws.cell(r, COL_NAME).value) key = norm_gtin(gtin_b) if len(key) >= 8 and key not in dict_row: dict_row[key] = r k = ws.cell(r, COL_QTY).value if isinstance(k, (int, float)) and not isinstance(k, bool): dict_qty[key] = k wb.close() if not dict_row: sys.exit('В колонке B ("Наименование") накладной, начиная с ряда %d, ' 'не найдено ни одного GTIN (код после последнего пробела).\n' 'Проверьте, что это файл товарной накладной.' % START_ROW) return dict_row, dict_qty def load_nakl_cells(path): """Вторая проходка: открывает накладную обычным режимом, чтобы по ряду доставать B/L/N (read_only-лист не дает произвольного доступа после close).""" wb = openpyxl.load_workbook(path, read_only=True, data_only=True) ws = wb.worksheets[0] return wb, ws def main(): args = [a for a in sys.argv[1:] if os.path.isfile(a)] try: import tkinter # noqa: F401 gui = True except Exception: gui = False if len(args) >= 2: file_nakl, file_kiz = args[0], args[1] else: if len(args) == 1: file_kiz = args[0] file_nakl = pick_file("Выберите файл 1 - товарная накладная (ТОРГ-12)", gui) else: file_nakl = pick_file("Выберите файл 1 - товарная накладная (ТОРГ-12)", gui) if not file_nakl: sys.exit(0) file_kiz = pick_file("Выберите файл 2 - таблица с отсканированными КИЗ", gui) if not file_nakl or not file_kiz: sys.exit(0) for p in (file_nakl, file_kiz): if p.lower().endswith(".xls"): sys.exit("Формат .xls не поддерживается Python-версией.\n" "Откройте файл в Excel и сохраните как .xlsx, либо\n" "для .xls используйте VBS-скрипт xlsx_to_csv_utf8.vbs.") dict_row, dict_qty = load_nakl(file_nakl) wb1, ws1 = load_nakl_cells(file_nakl) try: wb2 = openpyxl.load_workbook(file_kiz, data_only=True) except Exception as e: wb1.close() sys.exit("Не удалось открыть файл КИЗ '%s': %s" % (file_kiz, e)) ws2 = wb2.worksheets[0] n_rows2 = ws2.max_row last_col2 = ws2.max_column # последняя колонка - коды КИЗ kiz_total = 0 kiz_matched = 0 warnings = [] dict_found = {} # key GTIN -> число КИЗ в файле 2 dict_show = {} # key GTIN -> GTIN для показа filled = {} # ряд файла 2 -> (B, C, F) для CSV kiz_cut = {} # ряд файла 2 -> КИЗ без криптохвоста for r in range(1, n_rows2 + 1): s = clean_text(ws2.cell(r, last_col2).value) if not s: continue gtin = extract_gtin(s) if not gtin: warnings.append("Ряд %d: не удалось извлечь GTIN из кода КИЗ." % r) continue kiz_total += 1 kiz_cut[r] = s[:KIZ_LEN] if len(s) < KIZ_LEN: warnings.append("Ряд %d: КИЗ короче %d символов (%d), записан как есть." % (r, KIZ_LEN, len(s))) key = norm_gtin(gtin) dict_found[key] = dict_found.get(key, 0) + 1 dict_show.setdefault(key, gtin) if key in dict_row: row_n = dict_row[key] name, _ = split_name_gtin(ws1.cell(row_n, COL_NAME).value) price = num_text(ws1.cell(row_n, COL_PRICE).value) vat = num_text(ws1.cell(row_n, COL_VAT).value) if vat and not vat.endswith("%"): vat += "%" filled[r] = (str(name), str(price), str(vat)) kiz_matched += 1 else: warnings.append("Ряд %d: GTIN %s не найден в колонке B " "накладной (ряды с %d)." % (r, gtin, START_ROW)) # --- сверка количества: файл 2 vs колонка K накладной --- ok_lines = [] for key, cnt in dict_found.items(): if key not in dict_row: continue # уже отмечено построчно выше if key in dict_qty: if int(dict_qty[key]) == cnt: ok_lines.append("GTIN %s: %d шт. = накладной (K) %s - OK" % (dict_show[key], cnt, num_text(dict_qty[key]))) else: warnings.append("GTIN %s: КИЗ в файле %d шт., в накладной (K) %s" " - НЕ СОВПАДАЕТ" % (dict_show[key], cnt, num_text(dict_qty[key]))) else: warnings.append("GTIN %s: в ряде накладной не указано " "количество (K)." % dict_show[key]) # --- экспорт CSV (UTF-8; последняя колонка в кавычках при запятой) --- lines = [] for r in range(1, n_rows2 + 1): fields = [] for c in range(1, last_col2 + 1): if c == 2 and r in filled: fv = filled[r][0] # B - название elif c == 3 and r in filled: fv = filled[r][1] # C - цена elif c == 4 and r in filled: fv = "1" # D elif c == 5 and r in filled: fv = "796" # E elif c == 6 and r in filled: fv = filled[r][2] # F - НДС с % elif c == 7 and r in filled: fv = "КИЗ" # G elif c == last_col2 and r in kiz_cut: fv = kiz_cut[r] # КИЗ без криптохвоста else: fv = format_field(ws2.cell(r, c).value) if c == last_col2 and "," in fv: fv = '"' + fv.replace('"', '""') + '"' fields.append(fv) lines.append(",".join(fields)) wb1.close() wb2.close() out = "\r\n".join(lines) base = os.path.splitext(file_kiz)[0] csv_path = base + ".csv" report_path = base + "_report.txt" with open(csv_path, "w", encoding="utf-8", newline="") as f: f.write(out) with open(report_path, "w", encoding="utf-8") as f: f.write("Отчет сопоставления КИЗ\n") f.write("Накладная: %s\n" % file_nakl) f.write("Файл КИЗ: %s\n\n" % file_kiz) f.write("Обработано КИЗ: %d, сопоставлено: %d\n\n" % (kiz_total, kiz_matched)) f.write("Сверка количества (файл КИЗ vs колонка K накладной):\n") f.write("\n".join(ok_lines) + "\n\n") f.write("Замечания:\n") f.write("\n".join(warnings) + "\n") if not warnings: show_message("Сопоставление КИЗ", "Готово. Обработано КИЗ: %d, сопоставлено: %d.\n" "Все количества совпали с накладной.\n\nCSV: %s" % (kiz_total, kiz_matched, csv_path)) else: show_message("Сопоставление КИЗ", "Готово, но есть замечания (подробности в отчете):\n%s\n\n" "Обработано КИЗ: %d, сопоставлено: %d." % (report_path, kiz_total, kiz_matched), warning=True) if __name__ == "__main__": main()