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:
@@ -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()
|
||||
Reference in New Issue
Block a user