Приём документов → реестр + журнал
Внесение данных из файлов приёма в реестр невостребованных документов и заполнение журнала. Дата внесения — текущая; журнал заполняется чисто (без строк-примеров шаблона). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
6
priem_register/readers/__init__.py
Normal file
6
priem_register/readers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Читатели исходных файлов приёма."""
|
||||
from .base import SourceReader
|
||||
from .factory import get_reader
|
||||
|
||||
__all__ = ["SourceReader", "get_reader"]
|
||||
86
priem_register/readers/base.py
Normal file
86
priem_register/readers/base.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Базовый класс читателя источника."""
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from ..config import REGISTER_COLUMNS, SOURCE_NUMBER_COL
|
||||
from ..models import CellValue, ReadResult, Record
|
||||
|
||||
# Позиция колонки «Примечание» (L) в раскладке реестра (A=0 … M=12).
|
||||
NOTE_COL = 11
|
||||
|
||||
|
||||
class SourceReader(abc.ABC):
|
||||
"""Читает файл приёма и возвращает записи для внесения.
|
||||
|
||||
По заданию: «внести все строки, начиная со второй». Поэтому пропускается
|
||||
только шапка (строка 1), а каждая последующая непустая строка вносится как
|
||||
отдельная запись — без фильтрации по № п/п. № п/п в реестре назначается
|
||||
заново (сквозной), исходный № отбрасывается.
|
||||
|
||||
В реестре 13 колонок (A..M). Если в источнике есть данные правее колонки M
|
||||
(например, безымянная 14-я колонка), переносить их в отдельную колонку
|
||||
некуда — они добавляются в «Примечание» той же строки, а факт фиксируется
|
||||
в ``notes`` (информационно).
|
||||
|
||||
Подклассы реализуют только :meth:`_rows` — выдачу «сырых» строк таблицы
|
||||
(список типизированных значений ячеек) первого листа.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _rows(self) -> List[List[CellValue]]:
|
||||
"""Сырые строки первого листа, типизированные значения ячеек."""
|
||||
|
||||
def read(self) -> ReadResult:
|
||||
result = ReadResult()
|
||||
for index, row in enumerate(self._rows()):
|
||||
if index == 0:
|
||||
continue # шапка (строка 1)
|
||||
if not self._has_content(row):
|
||||
continue # пустые строки не вносим
|
||||
record = Record(values=list(row[SOURCE_NUMBER_COL + 1: REGISTER_COLUMNS]))
|
||||
self._fold_overflow(row, record, result, index + 1)
|
||||
result.records.append(record)
|
||||
return result
|
||||
|
||||
def _fold_overflow(self, row: List[CellValue], record: Record,
|
||||
result: ReadResult, human_row: int) -> None:
|
||||
overflow = [v for i, v in enumerate(row)
|
||||
if i >= REGISTER_COLUMNS and v not in (None, "")]
|
||||
for value in overflow:
|
||||
_merge_cell(record, NOTE_COL, value)
|
||||
if overflow:
|
||||
result.notes.append(
|
||||
"строка %d: данные за колонкой M добавлены в «Примечание»: %s"
|
||||
% (human_row, ", ".join(_as_text(v) for v in overflow)))
|
||||
|
||||
@staticmethod
|
||||
def _has_content(row: List[CellValue]) -> bool:
|
||||
return any(v not in (None, "") for v in row)
|
||||
|
||||
|
||||
def _merge_cell(record: Record, position: int, value: CellValue) -> None:
|
||||
"""Записать значение в колонку записи; занятое поле дополнить через «; »."""
|
||||
idx = position - 1
|
||||
while len(record.values) <= idx:
|
||||
record.values.append(None)
|
||||
current = record.values[idx]
|
||||
if current in (None, ""):
|
||||
record.values[idx] = value
|
||||
else:
|
||||
record.values[idx] = "%s; %s" % (_as_text(current), _as_text(value))
|
||||
|
||||
|
||||
def _as_text(value: CellValue) -> str:
|
||||
if isinstance(value, datetime.date): # покрывает и datetime
|
||||
return value.strftime("%d.%m.%Y")
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
24
priem_register/readers/factory.py
Normal file
24
priem_register/readers/factory.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Выбор читателя по расширению файла."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import SourceReader
|
||||
from .ods_reader import OdsReader
|
||||
from .xls_reader import XlsReader
|
||||
from .xlsx_reader import XlsxReader
|
||||
|
||||
_READERS = {
|
||||
".ods": OdsReader,
|
||||
".xls": XlsReader,
|
||||
".xlsx": XlsxReader,
|
||||
}
|
||||
|
||||
|
||||
def get_reader(path: Path) -> SourceReader:
|
||||
ext = Path(path).suffix.lower()
|
||||
try:
|
||||
return _READERS[ext](path)
|
||||
except KeyError:
|
||||
raise ValueError("Неподдерживаемый формат: %s" % ext)
|
||||
86
priem_register/readers/ods_reader.py
Normal file
86
priem_register/readers/ods_reader.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Читатель .ods (odfpy). Разворачивает повторяющиеся ячейки/строки."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import List
|
||||
|
||||
from odf.opendocument import load
|
||||
from odf.table import Table, TableCell, TableRow
|
||||
from odf.teletype import extractText
|
||||
from odf.text import P
|
||||
|
||||
from ..models import CellValue
|
||||
from .base import SourceReader
|
||||
|
||||
# Пределы на «бесконечные» повторения пустого хвоста таблицы.
|
||||
_MAX_COL_REPEAT = 200
|
||||
_MAX_ROW_REPEAT = 200
|
||||
|
||||
|
||||
class OdsReader(SourceReader):
|
||||
def _rows(self) -> List[List[CellValue]]:
|
||||
doc = load(str(self.path))
|
||||
table = doc.spreadsheet.getElementsByType(Table)[0]
|
||||
rows: List[List[CellValue]] = []
|
||||
for tr in table.getElementsByType(TableRow):
|
||||
row = self._row_values(tr)
|
||||
row_rep = _repeat(tr, "numberrowsrepeated", _MAX_ROW_REPEAT)
|
||||
for _ in range(row_rep):
|
||||
rows.append(list(row))
|
||||
_trim_trailing_empty(rows)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _row_values(tr: TableRow) -> List[CellValue]:
|
||||
values: List[CellValue] = []
|
||||
for cell in tr.getElementsByType(TableCell):
|
||||
col_rep = _repeat(cell, "numbercolumnsrepeated", _MAX_COL_REPEAT)
|
||||
value = _cell_value(cell)
|
||||
values.extend([value] * col_rep)
|
||||
return values
|
||||
|
||||
|
||||
def _repeat(element, attr: str, limit: int) -> int:
|
||||
raw = element.getAttribute(attr)
|
||||
n = int(raw) if raw else 1
|
||||
# огромные повторения — это пустой хвост листа, не размножаем
|
||||
return n if n <= limit else 1
|
||||
|
||||
|
||||
def _cell_value(cell: TableCell) -> CellValue:
|
||||
vtype = cell.getAttribute("valuetype")
|
||||
if vtype in ("float", "currency", "percentage"):
|
||||
raw = cell.getAttribute("value")
|
||||
if raw is not None:
|
||||
num = float(raw)
|
||||
return int(num) if num.is_integer() else num
|
||||
if vtype == "date":
|
||||
raw = cell.getAttribute("datevalue")
|
||||
if raw:
|
||||
return _parse_date(raw)
|
||||
text = _cell_text(cell).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _cell_text(cell: TableCell) -> str:
|
||||
# extractText корректно собирает текст из <text:span>, <text:s> (кратные
|
||||
# пробелы), <text:tab> и <text:line-break> — ручной обход childNodes их терял.
|
||||
return "\n".join(extractText(p) for p in cell.getElementsByType(P))
|
||||
|
||||
|
||||
def _parse_date(raw: str):
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.datetime.strptime(raw, fmt)
|
||||
if dt.hour == dt.minute == dt.second == 0:
|
||||
return dt.date()
|
||||
return dt
|
||||
except ValueError:
|
||||
continue
|
||||
return raw
|
||||
|
||||
|
||||
def _trim_trailing_empty(rows: List[List[CellValue]]) -> None:
|
||||
while rows and all(v in (None, "") for v in rows[-1]):
|
||||
rows.pop()
|
||||
44
priem_register/readers/xls_reader.py
Normal file
44
priem_register/readers/xls_reader.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Читатель .xls (xlrd). Корректно разбирает даты-сериалы Excel."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import xlrd
|
||||
|
||||
from ..models import CellValue
|
||||
from .base import SourceReader
|
||||
|
||||
|
||||
class XlsReader(SourceReader):
|
||||
def _rows(self) -> List[List[CellValue]]:
|
||||
book = xlrd.open_workbook(self.path)
|
||||
try:
|
||||
sheet = book.sheet_by_index(0)
|
||||
rows: List[List[CellValue]] = []
|
||||
for r in range(sheet.nrows):
|
||||
row: List[CellValue] = []
|
||||
for c in range(sheet.ncols):
|
||||
row.append(self._cell(book, sheet.cell(r, c)))
|
||||
rows.append(row)
|
||||
return rows
|
||||
finally:
|
||||
book.release_resources()
|
||||
|
||||
@staticmethod
|
||||
def _cell(book, cell) -> CellValue:
|
||||
t = cell.ctype
|
||||
if t == xlrd.XL_CELL_EMPTY or t == xlrd.XL_CELL_BLANK:
|
||||
return None
|
||||
if t == xlrd.XL_CELL_DATE:
|
||||
dt = xlrd.xldate_as_datetime(cell.value, book.datemode)
|
||||
# дата без времени -> date
|
||||
if dt.hour == dt.minute == dt.second == 0:
|
||||
return dt.date()
|
||||
return dt
|
||||
if t == xlrd.XL_CELL_NUMBER:
|
||||
return cell.value
|
||||
if t == xlrd.XL_CELL_BOOLEAN:
|
||||
return bool(cell.value)
|
||||
text = str(cell.value).strip()
|
||||
return text or None
|
||||
30
priem_register/readers/xlsx_reader.py
Normal file
30
priem_register/readers/xlsx_reader.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Читатель .xlsx (openpyxl)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import openpyxl
|
||||
|
||||
from ..models import CellValue
|
||||
from .base import SourceReader
|
||||
|
||||
|
||||
class XlsxReader(SourceReader):
|
||||
def _rows(self) -> List[List[CellValue]]:
|
||||
wb = openpyxl.load_workbook(self.path, data_only=True, read_only=True)
|
||||
try:
|
||||
ws = wb.worksheets[0]
|
||||
rows: List[List[CellValue]] = []
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
rows.append([_norm(v) for v in row])
|
||||
return rows
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _norm(value: CellValue) -> CellValue:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
return value or None
|
||||
return value
|
||||
Reference in New Issue
Block a user