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
Диапазон коммитов 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>
168 lines
8 KiB
Python
168 lines
8 KiB
Python
#!/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("&", "&").replace("<", "<").replace(">", ">")
|
||
.replace('"', """))
|
||
|
||
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()
|