Files
obrabotka_kiz/obrabotka_kiz_python/kiz_to_csv.py
T
Igor K.andClaude Opus 5.5 a6a3d5d6cd feat: обрезка КИЗ до 31 символа, README, инструкции для AI-агентов
- VBS и Python: в итоговом CSV код КИЗ обрезается до первых 31 символа
  (код без криптохвоста: 01 + GTIN + 21 + серийный номер); более короткий
  код остаётся как есть, с замечанием в отчёте. Копия kiz_to_csv.py в
  портативном комплекте обновлена так же.
- README.md: инструкция на русском, переписана проще и яснее;
  Инструкция_экспорт_CSV.docx пересобрана из того же текста, с настоящими
  заголовками и списками.
- AGENTS.md, CLAUDE.md, .agents/ (скилл kiz-testing, папки plans и reports).
- .gitignore для .DS_Store; .gitattributes хранит файлы без изменения
  концов строк (.bat и .vbs нужны CRLF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:38:47 -04:00

347 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()