Приём документов → реестр + журнал
Внесение данных из файлов приёма в реестр невостребованных документов и заполнение журнала. Дата внесения — текущая; журнал заполняется чисто (без строк-примеров шаблона). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
priem_register/__init__.py
Normal file
5
priem_register/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Приём документов: внесение в реестр и заполнение журнала."""
|
||||
from .pipeline import Pipeline, Result
|
||||
|
||||
__all__ = ["Pipeline", "Result"]
|
||||
49
priem_register/config.py
Normal file
49
priem_register/config.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Конфигурация: пути, константы, раскладка колонок."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Корень проекта (на уровень выше пакета).
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Исходный реестр и файл-результат (оригинал не перезаписываем).
|
||||
REGISTER_SRC = BASE_DIR / "2026_ЕДИНЫЙ РЕЕСТР НЕВОСТРЕБОВАННЫХ ДОКУМЕНТОВ..ods"
|
||||
REGISTER_OUT = BASE_DIR / "2026_ЕДИНЫЙ РЕЕСТР НЕВОСТРЕБОВАННЫХ ДОКУМЕНТОВ._ЗАПОЛНЕНО.ods"
|
||||
|
||||
# Журнал внесения.
|
||||
JOURNAL_SRC = BASE_DIR / "Журнал внесения.xlsx"
|
||||
JOURNAL_OUT = BASE_DIR / "Журнал внесения_ЗАПОЛНЕНО.xlsx"
|
||||
|
||||
# Папка с файлами приёма.
|
||||
PRIEM_DIR = BASE_DIR / "прием"
|
||||
|
||||
# Дата внесения, которая проставляется в журнал (по умолчанию — сегодня).
|
||||
ENTRY_DATE = datetime.date.today()
|
||||
|
||||
# Раскладка листа реестра: 13 колонок A..M.
|
||||
# 0:A № п/п (нумеруется заново, сквозная)
|
||||
# 1:B Адрес хранения
|
||||
# 2:C Рег. номер дела
|
||||
# 3:D Объект недвижимости
|
||||
# 4:E Кол-во документов
|
||||
# 5:F Дата получения
|
||||
# 6:G Направление (филиал субъекта РФ)
|
||||
# 7:H Получение (филиал субъекта РФ)
|
||||
# 8:I Направление (тер. подразделение)
|
||||
# 9:J Получение (тер. подразделение)
|
||||
# 10:K Дата выдачи
|
||||
# 11:L Примечание
|
||||
# 12:M Номер реестра
|
||||
REGISTER_COLUMNS = 13
|
||||
|
||||
# Колонка № п/п в источнике (пропускается, заменяется сквозной нумерацией).
|
||||
SOURCE_NUMBER_COL = 0
|
||||
|
||||
# Шаблон имени файла приёма: DD.MM.YYYY_74_NN.<ext>
|
||||
FILENAME_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{4})_74_(\d+)\.(ods|xls|xlsx)$", re.I)
|
||||
|
||||
# Номер ТО берётся из пути .../ТОk/...
|
||||
TO_DIR_RE = re.compile(r"[/\\]ТО(\d+)[/\\]")
|
||||
44
priem_register/mapping.py
Normal file
44
priem_register/mapping.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Поиск файлов приёма и сопоставление их с листами реестра.
|
||||
|
||||
Правило: файл DD.MM.YYYY_74_NN.<ext> из папки ТОk -> лист ТОk_NN.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from .config import FILENAME_RE, PRIEM_DIR, TO_DIR_RE
|
||||
from .models import SourceFile
|
||||
|
||||
|
||||
def discover_sources(priem_dir: Path = PRIEM_DIR) -> List[SourceFile]:
|
||||
priem_dir = Path(priem_dir)
|
||||
if not priem_dir.is_dir():
|
||||
raise FileNotFoundError("Папка приёма не найдена: %s" % priem_dir)
|
||||
sources: List[SourceFile] = []
|
||||
for path in sorted(priem_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
match = FILENAME_RE.match(path.name)
|
||||
if not match:
|
||||
continue
|
||||
dd, mm, yyyy, nn, _ext = match.groups()
|
||||
to_match = TO_DIR_RE.search(str(path))
|
||||
if not to_match:
|
||||
raise ValueError("Не удалось определить ТО для файла: %s" % path)
|
||||
try:
|
||||
date = datetime.date(int(yyyy), int(mm), int(dd))
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"Некорректная дата в имени файла %s: %s" % (path.name, exc)) from exc
|
||||
sources.append(SourceFile(
|
||||
path=path,
|
||||
date=date,
|
||||
sheet="ТО%s_%s" % (to_match.group(1), nn),
|
||||
name=path.stem,
|
||||
))
|
||||
# порядок внесения: по листу, затем по дате (последующие записи)
|
||||
sources.sort(key=lambda s: (s.sheet, s.date))
|
||||
return sources
|
||||
58
priem_register/models.py
Normal file
58
priem_register/models.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Доменные модели."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
# Значение ячейки: число, дата, строка либо пусто.
|
||||
CellValue = Optional[Union[str, int, float, datetime.date]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record:
|
||||
"""Одна принятая запись — значения колонок B..M (без № п/п)."""
|
||||
|
||||
values: List[CellValue] = field(default_factory=list)
|
||||
|
||||
def value(self, register_col: int) -> CellValue:
|
||||
"""Значение для колонки реестра по её индексу (1=B .. 12=M)."""
|
||||
idx = register_col - 1
|
||||
if 0 <= idx < len(self.values):
|
||||
return self.values[idx]
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadResult:
|
||||
"""Результат чтения источника.
|
||||
|
||||
records — записи для внесения в реестр;
|
||||
notes — что было присоединено к записям (информационно, не ошибки);
|
||||
warnings — данные, которые присоединить не к чему (требуют внимания).
|
||||
"""
|
||||
|
||||
records: List["Record"] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceFile:
|
||||
"""Файл приёма и его привязка к листу реестра."""
|
||||
|
||||
path: Path
|
||||
date: datetime.date
|
||||
sheet: str # имя листа в реестре, напр. "ТО1_25"
|
||||
name: str # имя файла без расширения, напр. "09.04.2026_74_25"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JournalEntry:
|
||||
"""Строка журнала внесения."""
|
||||
|
||||
date: datetime.date
|
||||
file_name: str
|
||||
count: int
|
||||
97
priem_register/pipeline.py
Normal file
97
priem_register/pipeline.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Оркестратор: связывает чтение источников, запись реестра и журнала."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from . import config
|
||||
from .mapping import discover_sources
|
||||
from .models import JournalEntry
|
||||
from .readers import get_reader
|
||||
from .writers import JournalWriter, RegisterWriter
|
||||
|
||||
log = logging.getLogger("priem")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
entries: List[JournalEntry]
|
||||
notes: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return sum(e.count for e in self.entries)
|
||||
|
||||
|
||||
class Pipeline:
|
||||
def __init__(
|
||||
self,
|
||||
register_src: Path = config.REGISTER_SRC,
|
||||
register_out: Path = config.REGISTER_OUT,
|
||||
journal_src: Path = config.JOURNAL_SRC,
|
||||
journal_out: Path = config.JOURNAL_OUT,
|
||||
priem_dir: Path = config.PRIEM_DIR,
|
||||
entry_date=config.ENTRY_DATE,
|
||||
):
|
||||
self.register_src = Path(register_src)
|
||||
self.register_out = Path(register_out)
|
||||
self.journal_src = Path(journal_src)
|
||||
self.journal_out = Path(journal_out)
|
||||
self.priem_dir = Path(priem_dir)
|
||||
self.entry_date = entry_date
|
||||
if self.register_out == self.register_src:
|
||||
raise ValueError("register_out совпадает с оригиналом — он был бы перезаписан")
|
||||
if self.journal_out == self.journal_src:
|
||||
raise ValueError("journal_out совпадает с оригиналом — он был бы перезаписан")
|
||||
|
||||
def run(self) -> Result:
|
||||
sources = discover_sources(self.priem_dir)
|
||||
log.info("Найдено файлов приёма: %d", len(sources))
|
||||
|
||||
register = RegisterWriter(self.register_src)
|
||||
entries: List[JournalEntry] = []
|
||||
notes: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
for src in sources:
|
||||
if not register.has_sheet(src.sheet):
|
||||
raise ValueError(
|
||||
"В реестре нет листа '%s' (файл %s)" % (src.sheet, src.name))
|
||||
try:
|
||||
read = get_reader(src.path).read()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Не удалось прочитать файл %s: %s" % (src.path.name, exc)) from exc
|
||||
count = register.append(src.sheet, read.records)
|
||||
entries.append(JournalEntry(self.entry_date, src.name, count))
|
||||
log.info(" %-22s -> %-8s : %3d записей", src.name, src.sheet, count)
|
||||
for n in read.notes:
|
||||
notes.append("%s: %s" % (src.name, n))
|
||||
log.info(" • %s", n)
|
||||
for w in read.warnings:
|
||||
warnings.append("%s: %s" % (src.name, w))
|
||||
log.warning(" ⚠ %s", w)
|
||||
|
||||
register.save(self.register_out)
|
||||
log.info("Реестр сохранён: %s", self.register_out.name)
|
||||
|
||||
journal = JournalWriter(self.journal_src)
|
||||
journal.write(entries)
|
||||
journal.save(self.journal_out)
|
||||
log.info("Журнал сохранён: %s", self.journal_out.name)
|
||||
|
||||
result = Result(entries, notes, warnings)
|
||||
log.info("ИТОГО записей: %d", result.total)
|
||||
if notes:
|
||||
log.info("Присоединено заметок: %d (данные не потеряны, см. выше)",
|
||||
len(notes))
|
||||
if warnings:
|
||||
log.warning("ВНИМАНИЕ: %d строк НЕ удалось привязать — проверьте вручную:",
|
||||
len(warnings))
|
||||
for w in warnings:
|
||||
log.warning(" • %s", w)
|
||||
return result
|
||||
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
|
||||
6
priem_register/writers/__init__.py
Normal file
6
priem_register/writers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Запись результатов в реестр и журнал."""
|
||||
from .journal_writer import JournalWriter
|
||||
from .register_writer import RegisterWriter
|
||||
|
||||
__all__ = ["RegisterWriter", "JournalWriter"]
|
||||
52
priem_register/writers/journal_writer.py
Normal file
52
priem_register/writers/journal_writer.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Заполнение журнала внесения (.xlsx) через openpyxl.
|
||||
|
||||
Пишет строки (Дата внесения, Файл внесения, Количество записей) сразу под
|
||||
шапкой, сохраняя её оформление. Строки-примеры из шаблона (напр. «Например»)
|
||||
затираются — в результате остаются только реальные записи.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import openpyxl
|
||||
|
||||
from ..models import JournalEntry
|
||||
|
||||
_DATE_FORMAT = "DD.MM.YYYY"
|
||||
# Заголовок шапки в колонке A, по которому она опознаётся.
|
||||
_HEADER_MARKER = "дата внесения"
|
||||
|
||||
|
||||
class JournalWriter:
|
||||
def __init__(self, path: Path):
|
||||
self.wb = openpyxl.load_workbook(path)
|
||||
self.ws = self.wb.active
|
||||
|
||||
def write(self, entries: List[JournalEntry]) -> None:
|
||||
row = self._header_row() + 1
|
||||
self._clear_below(row)
|
||||
for entry in entries:
|
||||
date_cell = self.ws.cell(row=row, column=1, value=entry.date)
|
||||
date_cell.number_format = _DATE_FORMAT
|
||||
self.ws.cell(row=row, column=2, value=entry.file_name)
|
||||
self.ws.cell(row=row, column=3, value=entry.count)
|
||||
row += 1
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
self.wb.save(path)
|
||||
|
||||
def _header_row(self) -> int:
|
||||
"""Строка шапки (где в колонке A «Дата внесения»); по умолчанию 1."""
|
||||
for row in range(1, self.ws.max_row + 1):
|
||||
a = self.ws.cell(row=row, column=1).value
|
||||
if isinstance(a, str) and a.strip().lower() == _HEADER_MARKER:
|
||||
return row
|
||||
return 1
|
||||
|
||||
def _clear_below(self, start: int) -> None:
|
||||
"""Очистить значения колонок A..C во всех строках от start и ниже."""
|
||||
for row in range(start, self.ws.max_row + 1):
|
||||
for col in (1, 2, 3):
|
||||
self.ws.cell(row=row, column=col).value = None
|
||||
180
priem_register/writers/register_writer.py
Normal file
180
priem_register/writers/register_writer.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Запись принятых документов в реестр (.ods) через odfpy.
|
||||
|
||||
Новые строки дописываются сразу после последней нумерованной строки листа.
|
||||
Стиль каждой ячейки (границы, формат даты/числа) и стиль строки копируются
|
||||
из этой последней строки-образца, поэтому оформление реестра сохраняется.
|
||||
Остальные листы, формулы и стили документа odfpy не трогает.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from odf.opendocument import load
|
||||
from odf.table import Table, TableCell, TableRow
|
||||
from odf.teletype import extractText
|
||||
from odf.text import P
|
||||
|
||||
from ..config import REGISTER_COLUMNS
|
||||
from ..models import CellValue, Record
|
||||
|
||||
# Повторения больше этого — «бесконечный» пустой хвост листа, не данные.
|
||||
_MAX_REPEAT = 200
|
||||
|
||||
|
||||
class RegisterWriter:
|
||||
def __init__(self, path: Path):
|
||||
self.doc = load(str(path))
|
||||
self._tables: Dict[str, Table] = {
|
||||
t.getAttribute("name"): t
|
||||
for t in self.doc.spreadsheet.getElementsByType(Table)
|
||||
}
|
||||
|
||||
def has_sheet(self, name: str) -> bool:
|
||||
return name in self._tables
|
||||
|
||||
def append(self, sheet: str, records: List[Record]) -> int:
|
||||
"""Дописать записи на лист. Возвращает число добавленных строк."""
|
||||
table = self._tables[sheet]
|
||||
template, last_number = self._find_last_numbered_row(table)
|
||||
col_styles = self._column_styles(template)
|
||||
row_style = template.getAttribute("stylename")
|
||||
|
||||
anchor = template
|
||||
number = last_number
|
||||
for record in records:
|
||||
number += 1
|
||||
row = self._build_row(record, number, row_style, col_styles)
|
||||
table.insertBefore(row, anchor.nextSibling)
|
||||
anchor = row
|
||||
return len(records)
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
self.doc.save(str(path))
|
||||
|
||||
# --- поиск строки-образца -------------------------------------------
|
||||
|
||||
def _find_last_numbered_row(self, table: Table) -> Tuple[TableRow, int]:
|
||||
last_row: Optional[TableRow] = None
|
||||
last_number = 0
|
||||
for tr in table.getElementsByType(TableRow):
|
||||
if _row_repeat(tr) > 1:
|
||||
continue # повторяющийся пустой блок
|
||||
cells = tr.getElementsByType(TableCell)
|
||||
if not cells:
|
||||
continue
|
||||
number = _as_int(cells[0])
|
||||
if number is not None:
|
||||
last_row, last_number = tr, number
|
||||
if last_row is None:
|
||||
raise ValueError("На листе нет нумерованных строк: %s"
|
||||
% table.getAttribute("name"))
|
||||
return last_row, last_number
|
||||
|
||||
@staticmethod
|
||||
def _column_styles(template: TableRow) -> Dict[int, Optional[str]]:
|
||||
"""Карта {индекс колонки 0..12 -> имя стиля ячейки} из образца."""
|
||||
styles: Dict[int, Optional[str]] = {}
|
||||
col = 0
|
||||
for cell in template.getElementsByType(TableCell):
|
||||
rep = _col_repeat(cell)
|
||||
style = cell.getAttribute("stylename")
|
||||
for _ in range(rep):
|
||||
if col < REGISTER_COLUMNS:
|
||||
styles[col] = style
|
||||
col += 1
|
||||
if col >= REGISTER_COLUMNS:
|
||||
break
|
||||
return styles
|
||||
|
||||
# --- построение строки ----------------------------------------------
|
||||
|
||||
def _build_row(self, record: Record, number: int,
|
||||
row_style: Optional[str],
|
||||
col_styles: Dict[int, Optional[str]]) -> TableRow:
|
||||
row = TableRow(stylename=row_style) if row_style else TableRow()
|
||||
for col in range(REGISTER_COLUMNS):
|
||||
style = col_styles.get(col)
|
||||
value = number if col == 0 else record.value(col)
|
||||
row.addElement(_make_cell(value, style))
|
||||
return row
|
||||
|
||||
|
||||
# --- фабрика ячеек по типу значения -------------------------------------
|
||||
|
||||
def _make_cell(value: CellValue, style: Optional[str]) -> TableCell:
|
||||
if value is None or value == "":
|
||||
return _cell(style)
|
||||
if isinstance(value, bool):
|
||||
return _text_cell(str(value), style)
|
||||
if isinstance(value, datetime.datetime):
|
||||
return _date_cell(value, style, with_time=True)
|
||||
if isinstance(value, datetime.date):
|
||||
return _date_cell(value, style, with_time=False)
|
||||
if isinstance(value, (int, float)):
|
||||
return _number_cell(value, style)
|
||||
return _text_cell(str(value), style)
|
||||
|
||||
|
||||
def _cell(style: Optional[str]) -> TableCell:
|
||||
return TableCell(stylename=style) if style else TableCell()
|
||||
|
||||
|
||||
def _text_cell(text: str, style: Optional[str]) -> TableCell:
|
||||
cell = TableCell(valuetype="string", stylename=style) if style \
|
||||
else TableCell(valuetype="string")
|
||||
for line in text.split("\n"):
|
||||
cell.addElement(P(text=line))
|
||||
return cell
|
||||
|
||||
|
||||
def _number_cell(num, style: Optional[str]) -> TableCell:
|
||||
text = _number_text(num)
|
||||
kw = dict(valuetype="float", value=text)
|
||||
if style:
|
||||
kw["stylename"] = style
|
||||
cell = TableCell(**kw)
|
||||
cell.addElement(P(text=text))
|
||||
return cell
|
||||
|
||||
|
||||
def _date_cell(value, style: Optional[str], with_time: bool) -> TableCell:
|
||||
iso = value.strftime("%Y-%m-%dT%H:%M:%S" if with_time else "%Y-%m-%d")
|
||||
display = value.strftime("%d.%m.%Y")
|
||||
kw = dict(valuetype="date", datevalue=iso)
|
||||
if style:
|
||||
kw["stylename"] = style
|
||||
cell = TableCell(**kw)
|
||||
cell.addElement(P(text=display))
|
||||
return cell
|
||||
|
||||
|
||||
# --- мелкие помощники ---------------------------------------------------
|
||||
|
||||
def _number_text(num) -> str:
|
||||
if isinstance(num, float) and num.is_integer():
|
||||
return str(int(num))
|
||||
return str(num)
|
||||
|
||||
|
||||
def _row_repeat(tr: TableRow) -> int:
|
||||
raw = tr.getAttribute("numberrowsrepeated")
|
||||
n = int(raw) if raw else 1
|
||||
return n if n <= _MAX_REPEAT else _MAX_REPEAT + 1
|
||||
|
||||
|
||||
def _col_repeat(cell: TableCell) -> int:
|
||||
raw = cell.getAttribute("numbercolumnsrepeated")
|
||||
n = int(raw) if raw else 1
|
||||
return n if n <= _MAX_REPEAT else 1
|
||||
|
||||
|
||||
def _as_int(cell: TableCell) -> Optional[int]:
|
||||
if cell.getAttribute("valuetype") == "float":
|
||||
raw = cell.getAttribute("value")
|
||||
if raw:
|
||||
return int(float(raw))
|
||||
text = "".join(extractText(p) for p in cell.getElementsByType(P)).strip()
|
||||
return int(text) if text.isdigit() else None
|
||||
Reference in New Issue
Block a user