226 lines
11 KiB
Python
Executable File
226 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
import argparse
|
||
import json
|
||
import re
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from zipfile import ZipFile, ZIP_DEFLATED
|
||
import xml.etree.ElementTree as ET
|
||
|
||
|
||
NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
|
||
ET.register_namespace('w', NS['w'])
|
||
|
||
|
||
def extract_docx_text(path: str) -> str:
|
||
with ZipFile(path, 'r') as z:
|
||
xml = z.read('word/document.xml').decode('utf-8', 'ignore')
|
||
chunks = re.findall(r'<w:t[^>]*>(.*?)</w:t>', xml)
|
||
text = ' '.join(chunks)
|
||
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
return text
|
||
|
||
|
||
def _p_text(p) -> str:
|
||
return ''.join((t.text or '') for t in p.findall('.//w:t', NS)).strip()
|
||
|
||
|
||
def _set_p_text(p, new_text: str):
|
||
ts = p.findall('.//w:t', NS)
|
||
if not ts:
|
||
return
|
||
ts[0].text = new_text
|
||
for t in ts[1:]:
|
||
t.text = ''
|
||
|
||
|
||
def autofix_docx(in_docx: str, out_docx: str):
|
||
src = Path(in_docx)
|
||
out = Path(out_docx)
|
||
with ZipFile(src, 'r') as zin:
|
||
xml = zin.read('word/document.xml')
|
||
root = ET.fromstring(xml)
|
||
|
||
changed = []
|
||
|
||
for p in root.findall('.//w:p', NS):
|
||
txt = _p_text(p)
|
||
if not txt:
|
||
continue
|
||
|
||
if 'Пушкино' in txt:
|
||
_set_p_text(p, txt.replace('Пушкино', 'Пушкина'))
|
||
changed.append('address_typo')
|
||
continue
|
||
|
||
if txt.startswith('1.1.'):
|
||
new = (
|
||
'1.1. Арендодатель передает, а Арендатор принимает во временное владение и пользование '
|
||
'недвижимое имущество — нежилые помещения, расположенные по адресу: Российская Федерация, '
|
||
'Республика Крым, г. Симферополь, ул. Пушкина, д. 24 (1 этаж): помещение с кадастровым номером '
|
||
'90:22:010301:552, площадью 80,6 кв.м; помещение с кадастровым номером 90:22:010301:7181, '
|
||
'площадью 84,2 кв.м. Общая площадь арендуемых помещений составляет 164,8 кв.м, '
|
||
'в том числе площадь зала для обслуживания посетителей — 48 кв.м.'
|
||
)
|
||
_set_p_text(p, new)
|
||
changed.append('clause_1_1')
|
||
elif txt.startswith('1.2.'):
|
||
new = '1.2. Срок аренды устанавливается с 01.01.2026 г. по 30.11.2026 г. (включительно), что составляет 11 месяцев.'
|
||
_set_p_text(p, new)
|
||
changed.append('clause_1_2')
|
||
elif txt.startswith('3.1.'):
|
||
new = (
|
||
'3.1. Арендная плата установлена за базовый месяц в размере 180 000 '
|
||
'(Сто восемьдесят тысяч) рублей. Уплата арендной платы производится до 7 (седьмого) '
|
||
'числа каждого месяца, следующего за отчетным, путем перечисления на расчетный счет '
|
||
'Арендодателя по реквизитам, указанным в разделе 14 настоящего договора. '
|
||
'Оплата начинается с 01.01.2026 г.'
|
||
)
|
||
_set_p_text(p, new)
|
||
changed.append('clause_3_1')
|
||
elif txt.startswith('3.2.'):
|
||
new = (
|
||
'3.2. Арендная плата выплачивается Арендатором Арендодателю ежемесячно не позднее '
|
||
'седьмого числа каждого месяца, следующего за отчетным. В случае задержки с оплатой '
|
||
'аренды более чем на 10 дней это считается невыполнением условий договора, дающим '
|
||
'право Арендодателю прекратить настоящий договор в одностороннем порядке.'
|
||
)
|
||
_set_p_text(p, new)
|
||
changed.append('clause_3_2')
|
||
elif 'последние 30 дней Аренды' in txt or 'Гарантированным платежом согласно первоначальному договору Аренды' in txt:
|
||
_set_p_text(p, 'Стороны подтверждают, что срок действия настоящего договора не превышает 11 месяцев.')
|
||
changed.append('clause_30days_conflict')
|
||
|
||
new_xml = ET.tostring(root, encoding='utf-8', xml_declaration=True)
|
||
with ZipFile(src, 'r') as zin, ZipFile(out, 'w', ZIP_DEFLATED) as zout:
|
||
for item in zin.infolist():
|
||
data = zin.read(item.filename)
|
||
if item.filename == 'word/document.xml':
|
||
data = new_xml
|
||
zout.writestr(item, data)
|
||
|
||
return sorted(set(changed))
|
||
|
||
|
||
def parse_date(s: str):
|
||
for fmt in ('%d.%m.%Y', '%d.%m.%y'):
|
||
try:
|
||
return datetime.strptime(s, fmt)
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def months_diff(a: datetime, b: datetime) -> int:
|
||
return (b.year - a.year) * 12 + (b.month - a.month) + (1 if b.day >= a.day else 0)
|
||
|
||
|
||
def find_all(pattern, text, flags=0):
|
||
return re.findall(pattern, text, flags)
|
||
|
||
|
||
def audit(doc_text: str, egrn_texts: list[str]):
|
||
findings = []
|
||
fixes = []
|
||
|
||
if 'Пушкино' in doc_text:
|
||
findings.append('Опечатка в адресе: найдено «Пушкино» вместо «Пушкина».')
|
||
fixes.append('Заменить во всех пунктах: «ул. Пушкино» -> «ул. Пушкина».')
|
||
|
||
if 'Арендная плата выплачивается Арендатору' in doc_text and 'Арендодателем' in doc_text:
|
||
findings.append('Перепутаны плательщик и получатель арендной платы.')
|
||
fixes.append('Исправить формулировку: «Арендная плата выплачивается Арендатором Арендодателю ...».')
|
||
|
||
if re.search(r'1\s*8\s*0000', doc_text):
|
||
findings.append('Некорректная запись суммы аренды (разрыв числа).')
|
||
fixes.append('Исправить сумму на единый формат: «180 000 (Сто восемьдесят тысяч) рублей».')
|
||
|
||
has_30days = 'последние 30 дней Аренды' in doc_text or 'Гарантированным платежом' in doc_text
|
||
m = re.search(r'Срок\s+Аренды\s+устанавливается\s+с\s+(\d{2}\.\d{2}\.\d{2,4})\s*г?\.?\s+по\s+(\d{2}\.\d{2}\.\d{2,4})', doc_text, re.I)
|
||
if m:
|
||
d1 = parse_date(m.group(1))
|
||
d2 = parse_date(m.group(2))
|
||
if d1 and d2:
|
||
months = months_diff(d1, d2)
|
||
if months > 11:
|
||
findings.append(f'Срок договора превышает 11 месяцев: ~{months} мес.')
|
||
fixes.append('Сократить срок до 11 месяцев максимум и явно указать период в п.1.2.')
|
||
if has_30days and d2.month == 11:
|
||
findings.append('Есть конфликт срока с блоком про «последние 30 дней» за пределами периода аренды.')
|
||
fixes.append('Удалить/переформулировать блок про «последние 30 дней», оставив условия в пределах срока договора.')
|
||
|
||
cadasters = sorted(set(find_all(r'90:22:010301:\d{3,5}', doc_text)))
|
||
areas = sorted(set(find_all(r'\b\d{2,3}[\.,]\d\b\s*кв\.?м', doc_text, re.I)))
|
||
|
||
if egrn_texts:
|
||
e_text = ' '.join(egrn_texts)
|
||
e_cadasters = sorted(set(find_all(r'90:22:010301:\d{3,5}', e_text)))
|
||
e_areas = sorted(set(find_all(r'\b\d{2,3}[\.,]\d\b', e_text)))
|
||
if e_cadasters and set(e_cadasters) - set(cadasters):
|
||
findings.append(f'В договоре отсутствуют кадастровые номера из ЕГРН: {", ".join(sorted(set(e_cadasters)-set(cadasters)))}.')
|
||
fixes.append('Добавить недостающие кадастровые номера в п.1.1.')
|
||
if e_areas and not any(a in doc_text for a in ('80,6', '84,2', '80.6', '84.2')):
|
||
findings.append('Площади помещений из ЕГРН не отражены явно в договоре.')
|
||
fixes.append('В п.1.1 указать площади каждого помещения и общую площадь.')
|
||
|
||
if not findings:
|
||
findings.append('Критичных несогласованностей по шаблонным проверкам не найдено.')
|
||
|
||
return {
|
||
'findings': findings,
|
||
'fixes': fixes,
|
||
'meta': {
|
||
'cadasters_in_doc': cadasters,
|
||
'areas_in_doc': areas,
|
||
},
|
||
}
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument('--docx', required=True)
|
||
ap.add_argument('--egrn-text', action='append', default=[])
|
||
ap.add_argument('--json', action='store_true')
|
||
ap.add_argument('--autofix', action='store_true')
|
||
ap.add_argument('--out', default='')
|
||
args = ap.parse_args()
|
||
|
||
target_docx = args.docx
|
||
applied = []
|
||
if args.autofix:
|
||
out = args.out or str(Path(args.docx).with_name(Path(args.docx).stem + '_fixed.docx'))
|
||
applied = autofix_docx(args.docx, out)
|
||
target_docx = out
|
||
|
||
doc_text = extract_docx_text(target_docx)
|
||
egrn_texts = []
|
||
for p in args.egrn_text:
|
||
with open(p, 'r', encoding='utf-8', errors='ignore') as f:
|
||
egrn_texts.append(f.read())
|
||
|
||
result = audit(doc_text, egrn_texts)
|
||
if applied:
|
||
result['autofix'] = {
|
||
'applied': applied,
|
||
'output_docx': target_docx,
|
||
}
|
||
|
||
if args.json:
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
else:
|
||
if applied:
|
||
print(f'Автоправки применены: {", ".join(applied)}')
|
||
print(f'Файл: {target_docx}\n')
|
||
print('Найдено:')
|
||
for i, x in enumerate(result['findings'], 1):
|
||
print(f'{i}. {x}')
|
||
if result['fixes']:
|
||
print('\nРекомендованные правки:')
|
||
for i, x in enumerate(result['fixes'], 1):
|
||
print(f'{i}. {x}')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|