gui-sparim commited on
Commit
ca18aa5
·
verified ·
1 Parent(s): af24288

Simplifica o quadro de CUB e move explicações para notas de rodapé

Browse files

Uma linha por construção com CUB final por exercício; projetos simples mostram 100%. Composições e definições técnicas da MESA ficam em notas nativas junto da primeira ocorrência. Preserva alterações de pesos entre exercícios e ordena notas conforme a leitura. Validação: 110 testes e renderização conferida.

document/evolutivo.py CHANGED
@@ -14,7 +14,7 @@ from .avaliacoes_importadas import currency
14
  from .mesa_content import content_for
15
  from .evo_adjustments import REDUCERS, applied_reducers
16
  from .fundamentacao import number, global_foundation
17
- from .footnotes import add_footnote
18
  from .formatters.table import set_table_font_size
19
  from .formatters.table_fit import fit_table_to_page
20
  from .anexos.metodologia import (_gerar_variaveis, _adicionar_pesquisa,
@@ -244,7 +244,6 @@ def gerar_corpo_evolutivo(doc, imported, num, caracteristicas_refs=None):
244
  if not groups: add_body_text(doc,'Não há sequências construtivas detalhadas preservadas; o total importado permanece identificado na composição.')
245
  construction_characteristics(doc,groups)
246
  heading(doc,num,'CUB histórico, referência temporal e adequações',4)
247
- definitions_for(doc,rows,['cubGroup','cubType','cubValue','cubPercent'])
248
  construction_cub_notes(doc,groups)
249
  heading(doc,num,'BDI e justificativa',4)
250
  definitions_for(doc,rows,['bdi'])
@@ -373,28 +372,67 @@ def construction_characteristics(doc, groups):
373
 
374
 
375
  def construction_cub_notes(doc, groups):
376
- notes,compositions = OrderedDict(),OrderedDict()
 
 
 
 
 
 
377
  for entries in groups.values():
 
 
378
  for row,c,i in entries:
379
- catalog = content_for(source(row))['referencias'].get('siglasCub',[])
380
- short = str(c.get('cubTipo') or '').split(' (',1)[0]
381
- found = next((item for item in catalog if item.get('sigla')==short),{})
382
- if found: notes.setdefault(f"{short}: {found['descricao']}.",[]).append((row,c,i))
383
- if not c.get('cubCompostoAtivo'): continue
384
- composition = c.get('cubComposicaoCalculada') or c.get('cubComposicao') or [
385
- {'cubTipo':c.get('cubTipo'),'peso':c.get('cubPesoPrincipalCalculado'),'valorCub':c.get('valorCubPrincipal')},
386
- {'cubTipo':c.get('cubTipoSecundario'),'peso':c.get('cubPesoSecundarioCalculado'),'valorCub':c.get('valorCubSecundario')}]
387
- details = [{key:item.get(key) for key in ('cubTipo','peso','valorCub')} for item in composition]
388
- # Historical values/weights distinguish compositions, incidental IDs do not.
389
- compositions.setdefault(json.dumps(details,sort_keys=True,ensure_ascii=False),[]).append((row,c,i))
390
- construction_notes(doc,notes,groups)
391
- if compositions:
392
- add_body_text(doc,'O CUB composto corresponde à média ponderada dos projetos: CUB = Σ(CUBᵢ × pesoᵢ) / Σ(pesoᵢ). Os valores compostos salvos constam no quadro de cálculo.')
393
- records = [['Construção / referência','Projeto padrão','Peso','CUB histórico (R$/m²)']]
394
- for identity,entries in compositions.items():
395
- for item in json.loads(identity):
396
- records.append([entry_scope(entries,groups,show_base=False),str(item.get('cubTipo') or 'Não informado'),fmt(item.get('peso'),4),currency(item.get('valorCub'))])
397
- table(doc,records,[2.5,1.8,.8,1.4],font_size=7)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
 
399
 
400
  def construction_bdi_notes(doc, groups):
 
14
  from .mesa_content import content_for
15
  from .evo_adjustments import REDUCERS, applied_reducers
16
  from .fundamentacao import number, global_foundation
17
+ from .footnotes import add_footnote, add_first_footnote, order_footnotes
18
  from .formatters.table import set_table_font_size
19
  from .formatters.table_fit import fit_table_to_page
20
  from .anexos.metodologia import (_gerar_variaveis, _adicionar_pesquisa,
 
244
  if not groups: add_body_text(doc,'Não há sequências construtivas detalhadas preservadas; o total importado permanece identificado na composição.')
245
  construction_characteristics(doc,groups)
246
  heading(doc,num,'CUB histórico, referência temporal e adequações',4)
 
247
  construction_cub_notes(doc,groups)
248
  heading(doc,num,'BDI e justificativa',4)
249
  definitions_for(doc,rows,['bdi'])
 
372
 
373
 
374
  def construction_cub_notes(doc, groups):
375
+ entries_all = [entry for entries in groups.values() for entry in entries]
376
+ if not entries_all:
377
+ return
378
+ rows = list({row['id']:row for row,c,i in entries_all}.values())
379
+ labels = [ref(row,show_base=False) for row in rows]
380
+ records = [['Construção','Projeto CUB'] + labels]
381
+ composition_notes, siglas = [], OrderedDict()
382
  for entries in groups.values():
383
+ by_id = {row['id']:c for row,c,i in entries}
384
+ variants = OrderedDict()
385
  for row,c,i in entries:
386
+ if c.get('cubCompostoAtivo'):
387
+ composition = c.get('cubComposicaoCalculada') or c.get('cubComposicao') or [
388
+ {'cubTipo':c.get('cubTipo'),'peso':c.get('cubPesoPrincipalCalculado')},
389
+ {'cubTipo':c.get('cubTipoSecundario'),'peso':c.get('cubPesoSecundarioCalculado')}]
390
+ parts = [f"{item.get('cubTipo') or 'Projeto não informado'}: {pct(item['peso']) if number(item.get('peso')) is not None else 'peso não informado'}"
391
+ for item in composition if isinstance(item,dict)]
392
+ description = '; '.join(parts) or 'Composição não preservada no arquivo.'
393
+ project = 'CUB composto'
394
+ else:
395
+ project = str(c.get('cubTipo') or 'Não informado').split(' (',1)[0] + ' (100%)'
396
+ description = project
397
+ short = str(c.get('cubTipo') or '').split(' (',1)[0]
398
+ catalog = content_for(source(row))['referencias'].get('siglasCub',[])
399
+ found = next((item for item in catalog if item.get('sigla')==short),{})
400
+ if found:
401
+ siglas.setdefault(short,found['descricao'])
402
+ variants.setdefault((project,description),[]).append((row,c,i))
403
+ project_labels = list(dict.fromkeys(key[0] for key in variants))
404
+ label = project_labels[0] if len(project_labels)==1 else 'Varia por exercício'
405
+ records.append([construction_name(entries[0][1],entries[0][2]),label] + [currency(by_id[row['id']].get('valorCub')) if row['id'] in by_id else '—' for row in rows])
406
+ if any(key[0]=='CUB composto' for key in variants) or len(variants)>1:
407
+ details = []
408
+ for (project,description),subset in variants.items():
409
+ scope = '; '.join(ref(row,show_base=False) for row,c,i in subset) if len(variants)>1 else 'Todos os exercícios apresentados'
410
+ details.append(f'{scope}: {description}.')
411
+ composition_notes.append((len(records)-1,' '.join(details),any(key[0]=='CUB composto' for key in variants)))
412
+ obj = table(doc,records,[2.5,1.5]+[1.15]*len(rows),auto_fit=False)
413
+ for key,pattern in [('cubGroup',r'\bCUB\b'),('cubType',r'Projeto CUB'),
414
+ ('cubValue',r'CUB histórico'),('cubPercent',r'%\s+(?:do\s+)?CUB')]:
415
+ variants = OrderedDict()
416
+ for row,c,i in entries_all:
417
+ text = content_for(source(row)).get('notas',{}).get(key)
418
+ if text:
419
+ variants.setdefault(text,[]).append(ref(row,show_base=False))
420
+ if variants:
421
+ text = ' '.join(text + (f" Aplicável a {'; '.join(dict.fromkeys(scope))}." if len(variants)>1 else '') for text,scope in variants.items())
422
+ if add_first_footnote(doc,pattern,text) is None:
423
+ add_footnote(doc,obj.cell(0,1).paragraphs[0],text)
424
+ used_notes = []
425
+ explained_composite = False
426
+ for index,text,has_composite in composition_notes:
427
+ if has_composite and not explained_composite:
428
+ text = 'O CUB composto corresponde à média ponderada dos projetos: CUB = Σ(CUBᵢ × pesoᵢ) / Σ(pesoᵢ). ' + text
429
+ explained_composite = True
430
+ add_footnote(doc,obj.cell(index,1).paragraphs[0],text)
431
+ used_notes.append(text)
432
+ for short,description in siglas.items():
433
+ add_first_footnote(doc,r'\b'+re.escape(short)+r'\b',f'{short}: {description}.')
434
+ order_footnotes(doc)
435
+ fit_table_to_page(doc,obj,notes=used_notes)
436
 
437
 
438
  def construction_bdi_notes(doc, groups):
document/footnotes.py CHANGED
@@ -6,6 +6,69 @@ from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT
6
  from docx.opc.packuri import PackURI
7
  from docx.opc.part import Part, XmlPart
8
  from docx.shared import Pt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
 
11
  def add_footnote(doc, paragraph, text):
 
6
  from docx.opc.packuri import PackURI
7
  from docx.opc.part import Part, XmlPart
8
  from docx.shared import Pt
9
+ from docx.text.paragraph import Paragraph
10
+ from copy import deepcopy
11
+ import re
12
+
13
+
14
+ def add_first_footnote(doc, pattern, text):
15
+ """Attach a note immediately after the first body/table occurrence of a term."""
16
+ for element in doc.element.body.iter(qn('w:p')):
17
+ paragraph = Paragraph(element, doc._body)
18
+ match = re.search(pattern, paragraph.text)
19
+ if not match:
20
+ continue
21
+ offset = 0
22
+ for run in paragraph.runs:
23
+ end = offset + len(run.text)
24
+ if offset < match.end() <= end:
25
+ cut = match.end() - offset
26
+ tail = run.text[cut:]
27
+ run.text = run.text[:cut]
28
+ identifier = add_footnote(doc, paragraph, text)
29
+ marker = paragraph.runs[-1]._r
30
+ run._r.addnext(marker)
31
+ if tail:
32
+ remainder = OxmlElement('w:r')
33
+ if run._r.rPr is not None:
34
+ remainder.append(deepcopy(run._r.rPr))
35
+ content = OxmlElement('w:t')
36
+ content.text = tail
37
+ content.set(qn('xml:space'), 'preserve')
38
+ remainder.append(content)
39
+ marker.addnext(remainder)
40
+ return identifier
41
+ offset = end
42
+ return add_footnote(doc, paragraph, text)
43
+ return None
44
+
45
+
46
+ def order_footnotes(doc):
47
+ """Keep IDs and stored note order aligned with their first body occurrence.
48
+
49
+ Notes can be attached retroactively to earlier terms. Align both orders so
50
+ Word and LibreOffice associate every visible number with the right text.
51
+ """
52
+ try:
53
+ part = doc.part.part_related_by(RT.FOOTNOTES)
54
+ except KeyError:
55
+ return
56
+ root = parse_xml(part.blob)
57
+ notes = {n.get(qn('w:id')): n for n in root if int(n.get(qn('w:id'))) > 0}
58
+ refs = list(doc.element.body.iter(qn('w:footnoteReference')))
59
+ ordered_ids = list(dict.fromkeys(r.get(qn('w:id')) for r in refs))
60
+ ordered_ids += [key for key in notes if key not in ordered_ids]
61
+ mapping = {key: str(i+1) for i,key in enumerate(ordered_ids)}
62
+ for reference in refs:
63
+ reference.set(qn('w:id'),mapping[reference.get(qn('w:id'))])
64
+ for old_id in ordered_ids:
65
+ note = notes[old_id]
66
+ note.set(qn('w:id'),mapping[old_id])
67
+ root.append(note)
68
+ if isinstance(part,XmlPart):
69
+ part._element = root
70
+ else:
71
+ part._blob = etree.tostring(root,xml_declaration=True,encoding='UTF-8',standalone=True)
72
 
73
 
74
  def add_footnote(doc, paragraph, text):
tests/test_cub_notes.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import io
3
+ import unittest
4
+ import zipfile
5
+
6
+ from docx import Document
7
+ from docx.oxml import parse_xml
8
+ from docx.oxml.ns import qn
9
+
10
+ from document.evolutivo import construction_cub_notes, construction_groups
11
+ from document.footnotes import add_first_footnote
12
+ from test_evolutivo_documento import fixture, resolved
13
+
14
+
15
+ class CubNotesTests(unittest.TestCase):
16
+ def notes(self, doc):
17
+ stream=io.BytesIO();doc.save(stream)
18
+ with zipfile.ZipFile(stream) as archive:
19
+ root=parse_xml(archive.read('word/footnotes.xml'))
20
+ return [''.join(t.text or '' for t in n.iter(qn('w:t'))).strip() for n in root if int(n.get(qn('w:id'))) > 0]
21
+
22
+ def test_one_row_per_construction_saved_values_and_composition_in_footnotes(self):
23
+ data=fixture((2021,2022,2023,2024,2025))
24
+ for year in data['anos']:
25
+ simple=copy.deepcopy(year['construcao']['construcoes'][0])
26
+ simple.update(id='simples',seq=2,nome='Galpão simples',cubTipo='GI (Galpao Industrial)',cubCompostoAtivo=False,valorCub=1000)
27
+ year['construcao']['construcoes'].append(simple)
28
+ imported=resolved(data)
29
+ doc=Document()
30
+ intro=doc.add_paragraph('O custo unitário básico (CUB) é empregado no cálculo.')
31
+ percent=doc.add_paragraph('CUNIT = CUB × (% CUB / 100)')
32
+ doc.add_paragraph('CUB histórico, referência temporal e adequações')
33
+ construction_cub_notes(doc,construction_groups(imported['avaliacoes']))
34
+ self.assertEqual(len(doc.tables),1)
35
+ table=doc.tables[0]
36
+ self.assertEqual(len(table.rows),3)
37
+ self.assertEqual(len(table.columns),7)
38
+ self.assertEqual(table.cell(1,1).text,'CUB composto')
39
+ self.assertEqual(table.cell(2,1).text,'GI (100%)')
40
+ self.assertEqual([c.text for c in table.rows[1].cells][2:],['R$ 1.400,00']*5)
41
+ self.assertEqual([c.text for c in table.rows[2].cells][2:],['R$ 1.000,00']*5)
42
+ notes=self.notes(doc)
43
+ self.assertEqual(len(notes),6)
44
+ self.assertIn('% do CUB',notes[1])
45
+ self.assertIn('valor do CUB por metro quadrado',notes[2])
46
+ self.assertIn('tipo ou composição de CUB',notes[3])
47
+ composite=next(n for n in notes if 'média ponderada' in n)
48
+ self.assertIn('GI: 60,00%; R1-N: 40,00%',composite)
49
+ self.assertIn('Todos os exercícios',composite)
50
+ self.assertTrue(any('GI: Galpao industrial' in n for n in notes))
51
+ self.assertIsNotNone(intro._p.find('.//'+qn('w:footnoteReference')))
52
+ self.assertIsNotNone(percent._p.find('.//'+qn('w:footnoteReference')))
53
+ self.assertFalse(any('grupo dos dados' in p.text or 'média ponderada' in p.text for p in doc.paragraphs))
54
+ self.assertEqual(intro.text,'O custo unitário básico (CUB) é empregado no cálculo.')
55
+
56
+ def test_first_occurrence_marker_preserves_text_and_formatting(self):
57
+ doc=Document()
58
+ first=doc.add_paragraph()
59
+ first.add_run('Projeto ')
60
+ first.add_run('CUB composto e CUB simples.').italic=True
61
+ later=doc.add_paragraph('Outro CUB composto.')
62
+ add_first_footnote(doc,r'CUB composto','Explicação técnica.')
63
+ self.assertEqual(first.text,'Projeto CUB composto e CUB simples.')
64
+ self.assertEqual(first.runs[1].text,'CUB composto')
65
+ self.assertTrue(first.runs[1].italic)
66
+ self.assertIsNotNone(first.runs[2]._r.find(qn('w:footnoteReference')))
67
+ self.assertTrue(first.runs[3].italic)
68
+ self.assertIsNone(later._p.find('.//'+qn('w:footnoteReference')))
69
+ self.assertEqual(self.notes(doc),['Explicação técnica.'])
70
+
71
+
72
+ if __name__=='__main__':
73
+ unittest.main()
tests/test_evo_adjustments.py CHANGED
@@ -121,8 +121,10 @@ class AppliedAdjustmentsTests(unittest.TestCase):
121
  c=copy.deepcopy(y['construcao']['construcoes'][0]);c.update(id='segundo',seq=2,nome='Galpão B')
122
  y['construcao']['construcoes'].append(c)
123
  doc=body(resolved(data))
124
- weighted=[t for t in doc.tables if t.rows[0].cells[1].text=='Projeto padrão']
125
  self.assertEqual(len(weighted),1);self.assertEqual(len(weighted[0].rows),3)
 
 
126
  calculations=[t for t in doc.tables if any(r.cells[0].text=='Valor da construção (R$)' for r in t.rows)]
127
  self.assertEqual(len(calculations),2)
128
  for t in calculations:
@@ -136,6 +138,10 @@ class AppliedAdjustmentsTests(unittest.TestCase):
136
  t=next(t for t in doc.tables if t.rows[0].cells[1].text=='Tipo / ano')
137
  self.assertEqual(len(t.rows),3)
138
  self.assertIn('IPTU 2023',t.rows[2].cells[0].text)
139
- t=next(t for t in doc.tables if t.rows[0].cells[1].text=='Projeto padrão')
140
- self.assertEqual(len(t.rows),5)
141
- self.assertIn('IPTU 2022',t.rows[1].cells[0].text);self.assertIn('IPTU 2023',t.rows[3].cells[0].text)
 
 
 
 
 
121
  c=copy.deepcopy(y['construcao']['construcoes'][0]);c.update(id='segundo',seq=2,nome='Galpão B')
122
  y['construcao']['construcoes'].append(c)
123
  doc=body(resolved(data))
124
+ weighted=[t for t in doc.tables if t.rows[0].cells[1].text=='Projeto CUB']
125
  self.assertEqual(len(weighted),1);self.assertEqual(len(weighted[0].rows),3)
126
+ self.assertEqual(weighted[0].cell(1,1).text,'CUB composto')
127
+ self.assertEqual([c.text for c in weighted[0].rows[0].cells][2:],['IPTU 2022','IPTU 2023'])
128
  calculations=[t for t in doc.tables if any(r.cells[0].text=='Valor da construção (R$)' for r in t.rows)]
129
  self.assertEqual(len(calculations),2)
130
  for t in calculations:
 
138
  t=next(t for t in doc.tables if t.rows[0].cells[1].text=='Tipo / ano')
139
  self.assertEqual(len(t.rows),3)
140
  self.assertIn('IPTU 2023',t.rows[2].cells[0].text)
141
+ t=next(t for t in doc.tables if t.rows[0].cells[1].text=='Projeto CUB')
142
+ self.assertEqual(len(t.rows),2)
143
+ from docx.opc.constants import RELATIONSHIP_TYPE as RT
144
+ from docx.oxml import parse_xml
145
+ text=' '.join(parse_xml(doc.part.part_related_by(RT.FOOTNOTES).blob).itertext())
146
+ for expected in ['IPTU 2022','IPTU 2023','60,00%','50,00%']:
147
+ self.assertIn(expected,text)
tests/test_evolutivo_documento.py CHANGED
@@ -127,8 +127,8 @@ class EvolutivoDocumentTests(unittest.TestCase):
127
  body = etree.fromstring(archive.read('word/document.xml'))
128
  refs = body.findall('.//'+qn('w:footnoteReference'))
129
  notes = {n.get(qn('w:id')): ''.join(n.itertext()) for n in root if int(n.get(qn('w:id'))) > 0}
130
- self.assertEqual(len(refs), 4)
131
- self.assertEqual(len(notes), 4)
132
  self.assertEqual({r.get(qn('w:id')) for r in refs}, set(notes))
133
  self.assertIn('CA: campo de arbítrio', notes['2'])
134
  self.assertIn('intervalo de confiança de 80%', notes['3'])
 
127
  body = etree.fromstring(archive.read('word/document.xml'))
128
  refs = body.findall('.//'+qn('w:footnoteReference'))
129
  notes = {n.get(qn('w:id')): ''.join(n.itertext()) for n in root if int(n.get(qn('w:id'))) > 0}
130
+ self.assertGreaterEqual(len(refs), 4)
131
+ self.assertEqual(len(refs), len(notes))
132
  self.assertEqual({r.get(qn('w:id')) for r in refs}, set(notes))
133
  self.assertIn('CA: campo de arbítrio', notes['2'])
134
  self.assertIn('intervalo de confiança de 80%', notes['3'])