gui-sparim commited on
Commit
46d350b
·
verified ·
1 Parent(s): 8e748f3

Organiza justificativa e composição do BDI por construção

Browse files

Manté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 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) or 'não preservada no arquivo; complemento técnico pendente.'
462
- notes.setdefault('Justificativa do BDI: '+note,[]).append((row,c,i))
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
- notes.setdefault(f"Referência técnica para {reference['tipo']}: BDI {reference['bdi']}. O percentual adotado consta no quadro de cálculo.",[]).append((row,c,i))
466
- composition = c.get('bdiComposicaoAdotada')
467
- if isinstance(composition,list) and composition:
468
- details = [{key:item.get(key) for key in ('descricao','percentual')} for item in composition]
469
- compositions.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i))
470
- construction_notes(doc,notes,groups)
471
- if compositions:
472
- records = [['Construção / referência','Componente de BDI adotado','Percentual']]
473
- for identity,entries in compositions.items():
474
- records.extend([[entry_scope(entries,groups,show_base=False),str(item.get('descricao') or ''),pct(item.get('percentual'))] for item in json.loads(identity)])
475
- if num is not None:
476
- heading(doc,num,'Composição do BDI',5)
477
- table(doc,records,[2.3,3.2,1],font_size=7)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- for text in ['3.4.1.1.1 Composição das áreas', '3.4.1.3.1 Composição do BDI',
 
 
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)