Spaces:
Sleeping
Sleeping
Organiza justificativa e composição do BDI por construção
Browse filesMantém a explicação geral do BDI no capítulo principal e cria subseção por construção com justificativa e tabela encerrada pelo total adotado em destaque. Compara por exercício quando houver mudanças e preserva divergências entre soma e percentual global. Validado com 123 testes e revisão visual do exemplo com três construções.
- document/evolutivo.py +61 -16
- tests/test_bdi_sections.py +103 -0
- tests/test_evo_hierarchy.py +3 -1
document/evolutivo.py
CHANGED
|
@@ -126,6 +126,7 @@ def compared(doc, rows, metrics, notes=None, show_base=False):
|
|
| 126 |
add_footnote(doc, obj.rows[index].cells[0].paragraphs[0], notes[label])
|
| 127 |
used_notes.append(notes[label])
|
| 128 |
fit_table_to_page(doc, obj, notes=used_notes)
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
def model_analysis(doc, model, tipo_y):
|
|
@@ -454,27 +455,71 @@ def construction_cub_notes(doc, groups):
|
|
| 454 |
|
| 455 |
|
| 456 |
def construction_bdi_notes(doc, groups, num=None):
|
| 457 |
-
notes,compositions = OrderedDict(),OrderedDict()
|
| 458 |
for entries in groups.values():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
for row,c,i in entries:
|
| 460 |
key = str(c.get('id') or c.get('seq') or i+1)
|
| 461 |
-
note = (row.get('complementos') or {}).get('bdi',{}).get(key)
|
| 462 |
-
|
| 463 |
reference = next((item for item in content_for(source(row))['referencias'].get('bdiTiposConstrucao',[]) if item.get('tipo')==c.get('tipoConstrucao')),None)
|
| 464 |
if reference:
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
|
| 479 |
|
| 480 |
def conservation_code(value):
|
|
|
|
| 126 |
add_footnote(doc, obj.rows[index].cells[0].paragraphs[0], notes[label])
|
| 127 |
used_notes.append(notes[label])
|
| 128 |
fit_table_to_page(doc, obj, notes=used_notes)
|
| 129 |
+
return obj
|
| 130 |
|
| 131 |
|
| 132 |
def model_analysis(doc, model, tipo_y):
|
|
|
|
| 455 |
|
| 456 |
|
| 457 |
def construction_bdi_notes(doc, groups, num=None):
|
|
|
|
| 458 |
for entries in groups.values():
|
| 459 |
+
name = construction_name(entries[0][1],entries[0][2])
|
| 460 |
+
if num is not None:
|
| 461 |
+
heading(doc,num,name,5)
|
| 462 |
+
else:
|
| 463 |
+
add_subsubsubsubsection_title(doc,'',name)
|
| 464 |
+
reasons,references = OrderedDict(),OrderedDict()
|
| 465 |
+
components,by_id,variants = OrderedDict(),{},set()
|
| 466 |
for row,c,i in entries:
|
| 467 |
key = str(c.get('id') or c.get('seq') or i+1)
|
| 468 |
+
note = (row.get('complementos') or {}).get('bdi',{}).get(key)
|
| 469 |
+
if note and note.strip(): reasons.setdefault(note.strip(),[]).append(row)
|
| 470 |
reference = next((item for item in content_for(source(row))['referencias'].get('bdiTiposConstrucao',[]) if item.get('tipo')==c.get('tipoConstrucao')),None)
|
| 471 |
if reference:
|
| 472 |
+
text = f"Referência técnica para {reference['tipo']}: BDI {reference['bdi']}. O percentual adotado é o registrado nesta tabela."
|
| 473 |
+
references.setdefault(text,[]).append(row)
|
| 474 |
+
values,counts = OrderedDict(),{}
|
| 475 |
+
composition = c.get('bdiComposicaoAdotada') or []
|
| 476 |
+
for item in composition if isinstance(composition,list) else []:
|
| 477 |
+
if not isinstance(item,dict): continue
|
| 478 |
+
label = str(item.get('descricao') or 'Componente não informado')
|
| 479 |
+
counts[label] = counts.get(label,0)+1
|
| 480 |
+
identity = (label,counts[label])
|
| 481 |
+
components.setdefault(identity,label)
|
| 482 |
+
values[identity] = number(item.get('percentual'))
|
| 483 |
+
total = sum(values.values(),Decimal(0)) if values and all(v is not None for v in values.values()) else None
|
| 484 |
+
adopted = number(c.get('bdi'))
|
| 485 |
+
by_id[row['id']] = {'components':values,'sum':total,'adopted':adopted}
|
| 486 |
+
variants.add((tuple(values.items()),adopted))
|
| 487 |
+
for text,applicable in reasons.items():
|
| 488 |
+
scope = ' ('+'; '.join(ref(row,show_base=False) for row in applicable)+')' if len(applicable)!=len(entries) else ''
|
| 489 |
+
add_body_text(doc,f'Justificativa do BDI{scope}: {text}').paragraph_format.keep_with_next = True
|
| 490 |
+
metrics = [(label,lambda row,k=key: pct(by_id[row['id']]['components'][k]) if k in by_id[row['id']]['components'] else '—') for key,label in components.items()]
|
| 491 |
+
differs = any(value['sum'] is not None and (value['adopted'] is None or
|
| 492 |
+
value['sum'].quantize(Decimal('.01')) != value['adopted'].quantize(Decimal('.01'))) for value in by_id.values())
|
| 493 |
+
if differs:
|
| 494 |
+
metrics.append(('Soma dos componentes',lambda row:pct(by_id[row['id']]['sum'])))
|
| 495 |
+
metrics.append(('Total do BDI',lambda row:pct(by_id[row['id']]['adopted'])))
|
| 496 |
+
used_notes = []
|
| 497 |
+
for text,applicable in references.items():
|
| 498 |
+
scope = '; '.join(ref(row,show_base=False) for row in applicable)+': ' if len(applicable)!=len(entries) else ''
|
| 499 |
+
used_notes.append(scope+text)
|
| 500 |
+
for row,c,i in entries:
|
| 501 |
+
value = by_id[row['id']]
|
| 502 |
+
text = ''
|
| 503 |
+
if not value['components']:
|
| 504 |
+
text = 'O arquivo registra o percentual global do BDI sem discriminar sua composição.'
|
| 505 |
+
elif value['sum'] is None:
|
| 506 |
+
text = 'A composição registrada contém percentuais não informados; o total do BDI é o percentual global adotado no arquivo.'
|
| 507 |
+
elif value['adopted'] is None or value['sum'].quantize(Decimal('.01')) != value['adopted'].quantize(Decimal('.01')):
|
| 508 |
+
text = 'A soma dos componentes difere do percentual global registrado. O total do BDI preserva o percentual adotado no arquivo.'
|
| 509 |
+
if text:
|
| 510 |
+
scope = ref(row,show_base=False)+': ' if len(variants)>1 else ''
|
| 511 |
+
if scope+text not in used_notes: used_notes.append(scope+text)
|
| 512 |
+
if len(variants)>1:
|
| 513 |
+
obj = compared(doc,[row for row,c,i in entries],metrics)
|
| 514 |
+
else:
|
| 515 |
+
row = entries[0][0]
|
| 516 |
+
obj = table(doc,[['Componente de BDI adotado','Percentual']]+[[label,getter(row)] for label,getter in metrics],[4.5,1.5],auto_fit=False)
|
| 517 |
+
for cell in obj.rows[-1].cells:
|
| 518 |
+
for paragraph in cell.paragraphs:
|
| 519 |
+
for run in paragraph.runs: run.bold = True
|
| 520 |
+
if used_notes:
|
| 521 |
+
add_footnote(doc,obj.rows[-1].cells[0].paragraphs[0],' '.join(used_notes))
|
| 522 |
+
fit_table_to_page(doc,obj,notes=used_notes)
|
| 523 |
|
| 524 |
|
| 525 |
def conservation_code(value):
|
tests/test_bdi_sections.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import unittest
|
| 3 |
+
|
| 4 |
+
from docx import Document
|
| 5 |
+
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
| 6 |
+
from docx.oxml import parse_xml
|
| 7 |
+
from docx.oxml.ns import qn
|
| 8 |
+
|
| 9 |
+
from document.evolutivo import construction_bdi_notes, construction_groups
|
| 10 |
+
from test_evo_adjustments import body
|
| 11 |
+
from test_evolutivo_documento import fixture, resolved
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
LABELS = ['Administração central', 'Despesas financeiras', 'Seguros e garantias', 'Riscos', 'Tributos', 'Remuneração']
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def composition(values):
|
| 18 |
+
return [{'descricao': label, 'percentual': value} for label, value in zip(LABELS, values)]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def notes(doc):
|
| 22 |
+
root = parse_xml(doc.part.part_related_by(RT.FOOTNOTES).blob)
|
| 23 |
+
return ' '.join(''.join(n.itertext(qn('w:t'))) for n in root if int(n.get(qn('w:id'))) > 0)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BdiSectionsTests(unittest.TestCase):
|
| 27 |
+
def data(self):
|
| 28 |
+
data = fixture((2021, 2022))
|
| 29 |
+
for year in data['anos']:
|
| 30 |
+
c = year['construcao']['construcoes'][0]
|
| 31 |
+
c.update(bdiComposicaoAdotada=composition([4, 1, 1, 2, 7, 10]), bdiJustificativa='Composição aditiva sobre o custo direto.')
|
| 32 |
+
return data
|
| 33 |
+
|
| 34 |
+
def render(self, data):
|
| 35 |
+
imported = resolved(data)
|
| 36 |
+
before = copy.deepcopy(imported)
|
| 37 |
+
doc = Document()
|
| 38 |
+
construction_bdi_notes(doc, construction_groups(imported['avaliacoes']))
|
| 39 |
+
self.assertEqual(imported, before)
|
| 40 |
+
return doc
|
| 41 |
+
|
| 42 |
+
def test_general_chapter_only_introduction_and_each_construction_has_total(self):
|
| 43 |
+
data = self.data()
|
| 44 |
+
for year in data['anos']:
|
| 45 |
+
second = copy.deepcopy(year['construcao']['construcoes'][0])
|
| 46 |
+
second.update(id='second', seq=2, nome='Galpão B', bdi=20,
|
| 47 |
+
bdiComposicaoAdotada=composition([3, 1, 1, 2, 6, 7]))
|
| 48 |
+
year['construcao']['construcoes'].append(second)
|
| 49 |
+
doc = body(resolved(data))
|
| 50 |
+
paragraphs = [p.text for p in doc.paragraphs]
|
| 51 |
+
start = paragraphs.index('3.4.1.3 BDI e justificativa')
|
| 52 |
+
end = paragraphs.index('3.4.1.3.1 Construção 1 — Galpão A')
|
| 53 |
+
self.assertEqual(len(paragraphs[start + 1:end]), 2)
|
| 54 |
+
self.assertTrue(paragraphs[start + 1].startswith('BDI (%):'))
|
| 55 |
+
self.assertTrue(paragraphs[start + 2].startswith('O BDI informado em percentual'))
|
| 56 |
+
self.assertIn('3.4.1.3.2 Construção 2 — Galpão B', paragraphs)
|
| 57 |
+
tables = [t for t in doc.tables if t.cell(0, 0).text == 'Componente de BDI adotado']
|
| 58 |
+
self.assertEqual(len(tables), 2)
|
| 59 |
+
for table, total in zip(tables, ['25,00%', '20,00%']):
|
| 60 |
+
self.assertEqual([c.text for c in table.rows[-1].cells], ['Total do BDI', total])
|
| 61 |
+
self.assertEqual(len(table.rows), 8)
|
| 62 |
+
self.assertEqual(len(table.columns), 2)
|
| 63 |
+
self.assertTrue(all(run.bold for c in table.rows[-1].cells for p in c.paragraphs for run in p.runs))
|
| 64 |
+
self.assertFalse(any('Aplicável a Construção' in p for p in paragraphs[start:paragraphs.index('3.4.1.4 Depreciação e conservação')]))
|
| 65 |
+
|
| 66 |
+
def test_year_changes_compare_components_and_totals_without_duplicate_subchapters(self):
|
| 67 |
+
data = self.data()
|
| 68 |
+
c = data['anos'][1]['construcao']['construcoes'][0]
|
| 69 |
+
c.update(bdi=20, bdiComposicaoAdotada=composition([3, 1, 1, 2, 6, 7]), bdiJustificativa='Justificativa do segundo exercício.')
|
| 70 |
+
doc = self.render(data)
|
| 71 |
+
self.assertEqual(len(doc.tables), 1)
|
| 72 |
+
table = doc.tables[0]
|
| 73 |
+
self.assertEqual([c.text for c in table.rows[0].cells], ['Parâmetro / valor', 'IPTU 2022', 'IPTU 2023'])
|
| 74 |
+
self.assertEqual([c.text for c in table.rows[1].cells], ['Administração central', '4,00%', '3,00%'])
|
| 75 |
+
self.assertEqual([c.text for c in table.rows[-1].cells], ['Total do BDI', '25,00%', '20,00%'])
|
| 76 |
+
text = '\n'.join(p.text for p in doc.paragraphs)
|
| 77 |
+
self.assertIn('Justificativa do BDI (IPTU 2022): Composição aditiva', text)
|
| 78 |
+
self.assertIn('Justificativa do BDI (IPTU 2023): Justificativa do segundo exercício.', text)
|
| 79 |
+
|
| 80 |
+
def test_divergent_sum_does_not_replace_saved_bdi(self):
|
| 81 |
+
data = self.data()
|
| 82 |
+
for year in data['anos']:
|
| 83 |
+
year['construcao']['construcoes'][0]['bdiComposicaoAdotada'][0]['percentual'] = 5
|
| 84 |
+
doc = self.render(data)
|
| 85 |
+
self.assertEqual([c.text for c in doc.tables[0].rows[-2].cells], ['Soma dos componentes', '26,00%'])
|
| 86 |
+
self.assertEqual([c.text for c in doc.tables[0].rows[-1].cells], ['Total do BDI', '25,00%'])
|
| 87 |
+
self.assertIn('difere do percentual global registrado', notes(doc))
|
| 88 |
+
|
| 89 |
+
def test_missing_composition_and_private_reason_are_not_invented_or_exposed(self):
|
| 90 |
+
data = self.data()
|
| 91 |
+
for year in data['anos']:
|
| 92 |
+
c = year['construcao']['construcoes'][0]
|
| 93 |
+
c.pop('bdiComposicaoAdotada')
|
| 94 |
+
c.update(bdi=0, bdiJustificativa='JUSTIFICATIVA INTERNA', bdiJustificativaInterna=True)
|
| 95 |
+
doc = self.render(data)
|
| 96 |
+
self.assertEqual(len(doc.tables[0].rows), 2)
|
| 97 |
+
self.assertEqual([c.text for c in doc.tables[0].rows[-1].cells], ['Total do BDI', '0,00%'])
|
| 98 |
+
self.assertNotIn('JUSTIFICATIVA INTERNA', '\n'.join(p.text for p in doc.paragraphs) + notes(doc))
|
| 99 |
+
self.assertIn('sem discriminar sua composição', notes(doc))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == '__main__':
|
| 103 |
+
unittest.main()
|
tests/test_evo_hierarchy.py
CHANGED
|
@@ -42,7 +42,9 @@ class EvolutivoHierarchyTests(unittest.TestCase):
|
|
| 42 |
self.assertTrue(p.paragraph_format.keep_with_next)
|
| 43 |
self.assertEqual(p._p.getnext().tag, qn('w:tbl'))
|
| 44 |
paragraphs = [p.text for p in doc.paragraphs]
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
'3.4.1.5 Cálculo por construção e exercício', '3.5 AVALIAÇÃO DO IMÓVEL OBJETO',
|
| 47 |
'4. ESPECIFICAÇÃO DA AVALIAÇÃO', '5. CONCLUSÃO TÉCNICA']:
|
| 48 |
self.assertIn(text, paragraphs)
|
|
|
|
| 42 |
self.assertTrue(p.paragraph_format.keep_with_next)
|
| 43 |
self.assertEqual(p._p.getnext().tag, qn('w:tbl'))
|
| 44 |
paragraphs = [p.text for p in doc.paragraphs]
|
| 45 |
+
bdi_headings = [p.text for p in doc.paragraphs if p.text.startswith('3.4.1.3.')]
|
| 46 |
+
self.assertEqual(bdi_headings, [f'3.4.1.3.{index} Construção {index} — {name}' for index, name in enumerate(names, 1)])
|
| 47 |
+
for text in ['3.4.1.1.1 Composição das áreas',
|
| 48 |
'3.4.1.5 Cálculo por construção e exercício', '3.5 AVALIAÇÃO DO IMÓVEL OBJETO',
|
| 49 |
'4. ESPECIFICAÇÃO DA AVALIAÇÃO', '5. CONCLUSÃO TÉCNICA']:
|
| 50 |
self.assertIn(text, paragraphs)
|