food-market/scripts/make_supplier_template.py
nns 6b0fc68f77
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(invoices+inventory): порт улучшений 05.10.2026 из sklad
Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff).

Backend (src/food-market.api/Infrastructure/Invoices):
- Ean.cs — контрольная цифра EAN-8/13/UPC-A/GTIN-14 (ловит одну ошибку в цифре).
- TableReader.cs — своя читалка .xlsx/.csv без библиотек, UTF-8/1251,
  .xls → понятная ошибка; TableInvoiceParser — шаблон накладной.
- InvoiceModels: +Photo/BarcodeSource/Note в InvoiceLine, PhotoHint,
  IInvoiceExtractor.ExtractPhotosAsync.
- InvoiceMatcher.FindByBarcode — для photo-matching.
- InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto с EAN-проверкой
  (barcode_source="read" → валидация), SweepAbandonedPhotosAsync раз в час
  убирает папки needs_review старше 24ч; DeleteStored (owner-policy: НЕ копить).
- ClaudeCliInvoiceExtractor: ExtractPhotosAsync + BuildPhotosPrompt +
  FromPhotosJson (barcode_source → BarcodeSource, comment → Note, unmatched_photos
  → Warnings); дефолт модели claude-sonnet-5-5, PhotoTimeoutSeconds=600.

Backend (Controllers):
- POST /purchases/invoices/upload-photos (≤80 файлов, 60 МБ) +
  GET /purchases/invoices/{id}/photo/{n}; Apply/Delete убирают папку (не только файл).
- InvoiceDetailDto +Source/+HasFiles — клиент не ломится за 404 миниатюрами.
- POST /inventory/inventories/import — xlsx/csv → {found, unknown}, повторы ШК
  суммируются, документ не создаётся.
- Program.cs: UseResponseCompression + GzipCompressionProvider для application/json.
- InventoriesController.mobile-catalog: weak ETag + If-None-Match → 304;
  Cache-Control private max-age=60. Замер 31k: JSON 3.60 МБ → gzip 0.98 МБ.

Frontend:
- lib/shots.ts — ZXing TRY_HARDER на 1x+1600; compress JPEG 0.85 до 2400.
- components/PhotoSession.tsx — полноэкранная пачка (камера + готовые фото).
- components/CameraScanner.tsx: onCodeRef + эффект [], камера не закрывается
  после каждого скана.
- pages/InvoicesPage.tsx — четыре способа: камера одиночными, файл (xlsx/csv/img/pdf),
  камера многостраничная, фото-приёмка (камера или готовые фото) + «Отправить ещё раз».
- pages/InvoiceReviewPage.tsx — автосохранение 700 мс + индикатор; row-picker панель
  «в какую строку?» если fuzzy-match неуверен; linesRef; глобальный keydown; миниатюра
  снимка у строки; пометки Note.
- pages/SupplyScanPage.tsx + InventoryMobilePage.tsx — глобальный keydown.
- InventoryMobilePage: поле сканера на виду + импорт Excel/CSV.
- docs/supplier-invoice-template.xlsx — шаблон для поставщиков (9-я строка заголовки).

Хранение файлов (owner-policy 2026-10-05): фото НЕ копим.
- Обычная загрузка → удаление сразу после разбора.
- Фото-приёмка → удаление при Apply/Delete/24h abandoned.
- KeepFilesDays удалён из флоу (конфиг-ключ игнорируется).

Тесты (unit, зелёные):
- EanTests (6): валидные/невалидные/one-digit error.
- TableReaderTests (5): xlsx cells, шапка «Поставщик/Номер/Дата», CSV UTF-8+BOM,
  CSV Windows-1251, .xls friendly error.
- ClaudeCliInvoicePhotosParseTests (4): decoded проходит, source=null чистит Barcode,
  unmatched_photos → Warnings, comment → Note.
- InvoicePhotoRetentionTests (3): DeleteStored файл/папка/идемпотентность.

Preexisting ApiReferenceDocsJob (Sprint 28) failure не связан с портом.

Прогресс: docs/sklad-port-20261005-progress.md. Пользовательская инструкция:
docs/supply-inventory.md обновлена.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-06 00:02:13 +05:00

168 lines
8 KiB
Python
Raw Permalink 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
"""Генерация шаблона накладной для поставщиков (handoff §7).
Создаёт docs/supplier-invoice-template.xlsx: 9-строчная шапка (Поставщик /
Номер / Дата / пустые строки с подсказкой «← заполните» ) и таблица товаров
на 9-й строке (заголовок) с колонками Штрихкод | Наименование товара |
Количество | Ед. изм. | Цена за единицу | Сумма.
TableInvoiceParser (Infrastructure/Invoices/TableReader.cs) ищет первую
строку, где есть заголовки «Штрихкод» и «Количество» — поэтому порядок
колонок свободный, но сами слова нужны именно эти или похожие.
"""
import io
import os
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs" / "supplier-invoice-template.xlsx"
# ─── XML parts ───────────────────────────────────────────────────────
CONTENT_TYPES = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>
"""
RELS = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>
"""
WB_RELS = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>
"""
WORKBOOK = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Накладная" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>
"""
STYLES = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2">
<font><sz val="11"/><name val="Calibri"/></font>
<font><b/><sz val="11"/><name val="Calibri"/></font>
</fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border/></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>
</cellXfs>
</styleSheet>
"""
# Содержимое шаблона: 8 строк шапки + заголовок таблицы на 9-й строке.
ROWS = [
# row 1 — название
["Накладная поставщика", ""],
[],
["Поставщик", "← ТОО/ИП «...»"],
["Номер накладной", "← например, 1234"],
["Дата", "← ГГГГ-ММ-ДД"],
[],
["", "Заполните эту таблицу и пришлите файл: система создаст приёмку автоматически."],
[],
# row 9 — заголовок таблицы, который ищет TableInvoiceParser
["Штрихкод", "Наименование товара", "Количество", "Ед. изм.", "Цена за единицу", "Сумма"],
# примеры (можно удалить при реальном заполнении)
["4870123456789", "Молоко 3.2%, 1 л", 10, "шт", 450, 4500],
["4870987654321", "Хлеб серый, 400 г", 20, "шт", 180, 3600],
]
def _xl_col(n: int) -> str:
s = ""
while n >= 0:
s = chr(ord("A") + n % 26) + s
n = n // 26 - 1
return s
def _escape(s: str) -> str:
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
def build_shared_strings(rows):
"""Собрать уникальные строки, вернуть (xml, index-map)."""
seen = {}
order = []
for row in rows:
for c in row:
if isinstance(c, str) and c:
if c not in seen:
seen[c] = len(order); order.append(c)
body = "".join(f"<si><t xml:space=\"preserve\">{_escape(s)}</t></si>" for s in order)
xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="{sum(1 for r in rows for c in r if isinstance(c, str) and c)}" uniqueCount="{len(order)}">'
f'{body}</sst>'
)
return xml, seen
def build_sheet(rows, strings_idx):
out = io.StringIO()
out.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n')
out.write('<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">')
out.write('<cols>')
out.write('<col min="1" max="1" width="18"/>')
out.write('<col min="2" max="2" width="42"/>')
out.write('<col min="3" max="3" width="12"/>')
out.write('<col min="4" max="4" width="10"/>')
out.write('<col min="5" max="5" width="18"/>')
out.write('<col min="6" max="6" width="14"/>')
out.write('</cols>')
out.write('<sheetData>')
for r, row in enumerate(rows, start=1):
if not row:
out.write(f'<row r="{r}"/>')
continue
is_header_row = (r == 1 or r == 9)
out.write(f'<row r="{r}">')
for c_idx, val in enumerate(row):
ref = f"{_xl_col(c_idx)}{r}"
style = ' s="1"' if is_header_row else ''
if isinstance(val, (int, float)):
out.write(f'<c r="{ref}"{style}><v>{val}</v></c>')
elif isinstance(val, str) and val:
out.write(f'<c r="{ref}"{style} t="s"><v>{strings_idx[val]}</v></c>')
else:
out.write(f'<c r="{ref}"{style}/>')
out.write('</row>')
out.write('</sheetData>')
out.write('</worksheet>')
return out.getvalue()
def main() -> None:
ss_xml, idx = build_shared_strings(ROWS)
sheet_xml = build_sheet(ROWS, idx)
OUT.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", CONTENT_TYPES)
z.writestr("_rels/.rels", RELS)
z.writestr("xl/_rels/workbook.xml.rels", WB_RELS)
z.writestr("xl/workbook.xml", WORKBOOK)
z.writestr("xl/styles.xml", STYLES)
z.writestr("xl/sharedStrings.xml", ss_xml)
z.writestr("xl/worksheets/sheet1.xml", sheet_xml)
print(f"wrote {OUT} ({OUT.stat().st_size} bytes)")
if __name__ == "__main__":
main()