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>
This commit is contained in:
Igor K.
2026-10-28 06:38:47 -04:00
co-authored by Claude Opus 5.5
commit a6a3d5d6cd
255 changed files with 32576 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
#!/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()