Commit inicial: app de exportação da SD + deploy Docker/Coolify

Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON),
scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile,
docker-compose.yml) para VPS via Coolify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:07:38 -03:00
co-authored by Claude Sonnet 5
commit 0d4fdd2533
29 changed files with 4268 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+55
View File
@@ -0,0 +1,55 @@
---
name: frontend-design
description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
license: Complete terms in LICENSE.txt
---
# Frontend Design
Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify.
## Ground it in the subject
If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before – use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout.
## Design principles
For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option.
Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content.
Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them.
Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated.
Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance.
## Process: brainstorm, explore, plan, critique, build, critique again
For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn.
Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette as 4–6 named hex values. Type: the typefaces for 2+ roles (a characterful display face that's used with restraint, a complementary body face, and a utility face for captions or data if needed). Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way.
Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan exactly and deriving every color and type decision from it.
When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and a element-based selector like .cta). This can happen often with paddings/margins between sections.
Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them.
## Restraint and self-critique
Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it – a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes.
## More on writing in design
Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience.
Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever.
Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around.
Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act.
Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty.
@@ -0,0 +1,14 @@
24 Ago 26 - Segunda
Contexto:
0. A direção (Elias, Rafinha, outros) deu prazo até o final do mês Ago/26 para alimentar o sistema eles querem ver um previsão orçamentaria, alias eles mesmos desenharam o dashboard, o Ian esta ajudando
1. Com base na sua aplicação "exporta_sd.py" fiz algumas atualizações e ta funcionando mto bem ou seja converte: sd.mg (seu repositorio) e gera .json (com verificações)
2. A importação (via json) será feita de dentro do proprio sistema (ao invez de Nova SD, clicar Importação SD), o que será importado: SD, Entregaveis, Alocação, Backlog, Criterio Aceite, Documentos e Enquadramento TR. Dps popderá ser executada por qq pessoa
3. Qto importação de Documentos os anexos não serão importados, tem q ser um processo manual, através do botão anexar documento, porém titulo e data ja estarão cadastrados no sistema
/////////////// Perguntas ////////////
1. Encontrei no seu repositorio 37 SD.
Pergunto:
1. Como saber quais (sd.md) estão prontas p/serem importadas (tem algum Flag?) Propriedade: redigida = True
2. Qtas estão prontas em condições de ser importadas - 13 SD
3. Qdo Nr Semanas não for compativel (diferença entre datas /7). O q devo considerar? Nr de semanas do documento e alterar a data final e manter inicial?
4. No seu documento (word) "Solicitação de Demanda" na seção: Enquadramento TR existem as colunas: Item,Titulo, Descrição, Aderencia). Porém na base de dados só existem os campos: Item e descrição. Pergunto: Incluir no banco dados os campos faltantes?
@@ -0,0 +1,44 @@
Analise a interface atual da aplicação Flask e melhore apenas o **design/frontend**.
Atue principalmente em:
* templates HTML/Jinja;
* arquivos CSS;
* JavaScript somente se necessário para interface.
### Regras
* Preserve totalmente as rotas, regras de negócio e lógica Python/Flask existentes.
* Não alterar funcionalidades já implementadas.
* Manter os dois temas existentes:
* **Claro**
* **Escuro**
### Tema escuro
O tema escuro atual está excessivamente preto e visualmente pesado.
Melhorar para um dark theme mais agradável, usando tons como:
* cinza grafite;
* azul/cinza escuro;
* fundos em diferentes níveis de contraste;
* cards ligeiramente mais claros que o fundo;
* textos com boa legibilidade;
* bordas e separadores discretos.
Evitar fundo totalmente preto (`#000`) em toda a interface.
### Objetivo visual
Criar uma interface:
* moderna;
* limpa;
* profissional;
* responsiva;
* com boa hierarquia visual;
* mantendo consistência entre os temas claro e escuro.
Melhorar espaçamentos, tipografia, botões, cards, formulários, tabelas e organização visual sem modificar o comportamento da aplicação.
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+55
View File
@@ -0,0 +1,55 @@
---
name: frontend-design
description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
license: Complete terms in LICENSE.txt
---
# Frontend Design
Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify.
## Ground it in the subject
If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before – use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout.
## Design principles
For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option.
Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content.
Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them.
Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated.
Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance.
## Process: brainstorm, explore, plan, critique, build, critique again
For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn.
Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette as 4–6 named hex values. Type: the typefaces for 2+ roles (a characterful display face that's used with restraint, a complementary body face, and a utility face for captions or data if needed). Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way.
Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan exactly and deriving every color and type decision from it.
When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and a element-based selector like .cta). This can happen often with paddings/margins between sections.
Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them.
## Restraint and self-critique
Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it – a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes.
## More on writing in design
Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience.
Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever.
Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around.
Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act.
Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty.
@@ -0,0 +1,14 @@
24 Ago 26 - Segunda
Contexto:
0. A direção (Elias, Rafinha, outros) deu prazo até o final do mês Ago/26 para alimentar o sistema eles querem ver um previsão orçamentaria, alias eles mesmos desenharam o dashboard, o Ian esta ajudando
1. Com base na sua aplicação "exporta_sd.py" fiz algumas atualizações e ta funcionando mto bem ou seja converte: sd.mg (seu repositorio) e gera .json (com verificações)
2. A importação (via json) será feita de dentro do proprio sistema (ao invez de Nova SD, clicar Importação SD), o que será importado: SD, Entregaveis, Alocação, Backlog, Criterio Aceite, Documentos e Enquadramento TR. Dps popderá ser executada por qq pessoa
3. Qto importação de Documentos os anexos não serão importados, tem q ser um processo manual, através do botão anexar documento, porém titulo e data ja estarão cadastrados no sistema
/////////////// Perguntas ////////////
1. Encontrei no seu repositorio 37 SD.
Pergunto:
1. Como saber quais (sd.md) estão prontas p/serem importadas (tem algum Flag?) Propriedade: redigida = True
2. Qtas estão prontas em condições de ser importadas - 13 SD
3. Qdo Nr Semanas não for compativel (diferença entre datas /7). O q devo considerar? Nr de semanas do documento e alterar a data final e manter inicial?
4. No seu documento (word) "Solicitação de Demanda" na seção: Enquadramento TR existem as colunas: Item,Titulo, Descrição, Aderencia). Porém na base de dados só existem os campos: Item e descrição. Pergunto: Incluir no banco dados os campos faltantes?
@@ -0,0 +1,44 @@
Analise a interface atual da aplicação Flask e melhore apenas o **design/frontend**.
Atue principalmente em:
* templates HTML/Jinja;
* arquivos CSS;
* JavaScript somente se necessário para interface.
### Regras
* Preserve totalmente as rotas, regras de negócio e lógica Python/Flask existentes.
* Não alterar funcionalidades já implementadas.
* Manter os dois temas existentes:
* **Claro**
* **Escuro**
### Tema escuro
O tema escuro atual está excessivamente preto e visualmente pesado.
Melhorar para um dark theme mais agradável, usando tons como:
* cinza grafite;
* azul/cinza escuro;
* fundos em diferentes níveis de contraste;
* cards ligeiramente mais claros que o fundo;
* textos com boa legibilidade;
* bordas e separadores discretos.
Evitar fundo totalmente preto (`#000`) em toda a interface.
### Objetivo visual
Criar uma interface:
* moderna;
* limpa;
* profissional;
* responsiva;
* com boa hierarquia visual;
* mantendo consistência entre os temas claro e escuro.
Melhorar espaçamentos, tipografia, botões, cards, formulários, tabelas e organização visual sem modificar o comportamento da aplicação.
+19
View File
@@ -0,0 +1,19 @@
# Fora da imagem. clientes/ e _derivados/ entram por volume (ver Dockerfile):
# excluir aqui garante que build local e Coolify se comportem igual — se o
# volume faltar, falha nos dois, e não só em produção.
.git
.gitignore
.venv
.claude
.agents
skills-lock.json
clientes
_derivados
scripts/backlog
**/__pycache__
*.pyc
*.md
*.bat
Dockerfile
docker-compose.yml
.dockerignore
+19
View File
@@ -0,0 +1,19 @@
.venv/
__pycache__/
*.pyc
_derivados/
# Backlog de trabalho — rascunhos de SD e JSONs de apoio, não entram no deploy.
scripts/backlog/
# Dados do cliente: contrato, projetos e SDs. Fora do git por decisão de
# 04/09/2026. No deploy, entram por volume montado em /app/clientes — o
# app exige clientes/ses-mg/contrato/itens.yaml em tempo de execução.
clientes/
# Documentos de trabalho na RAIZ (contrato, propostas, prompts, leiame).
# Só na raiz: os .md sob clientes/ e scripts/backlog/ já saem pelas regras acima.
/*.md
# Lock de sessão local do Claude Code — efêmero (PID, sessionId), não é config do projeto.
.claude/scheduled_tasks.lock
+58
View File
@@ -0,0 +1,58 @@
# Imagem da aplicação de Exportação da SD — deploy em VPS via painel Coolify.
#
# O que NÃO entra na imagem, e por que: clientes/. O app lê
# clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico)
# e grava SD.md de volta em clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses
# são dados do cliente, versionados fora do git — entram por VOLUME montado em
# /app/clientes. Sem esse volume, a imagem sobe e a tela inicial responde, mas o
# primeiro upload falha com "Arquivo canônico ausente".
#
# Build local: docker build -t exporta-sd .
# Run local: docker run --rm -p 5000:5000 -v "$PWD/clientes:/app/clientes" exporta-sd
FROM python:3.13-slim
# PYTHONUNBUFFERED: log do gunicorn sai na hora no painel do Coolify.
# LANG/LC_ALL em UTF-8: identificadores de SD trazem "·" (P2·SD8) e o
# escrita_sd monta nome de arquivo a partir deles.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=5000
WORKDIR /app
# Dependências em camada própria: mudar código não reinstala pacote.
# requirements.txt é o mesmo do venv Windows; o gunicorn fica separado porque
# é só do deploy (no Windows ele não instala).
COPY requirements.txt requirements-deploy.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-deploy.txt
# Só o código. O resto (clientes/, backlog, .md da raiz) está no .dockerignore.
COPY scripts/ ./scripts/
COPY app/ ./app/
# Usuário sem privilégio. Precisa poder escrever em /app/clientes (o "Salvar
# SD.md") e em /app/_derivados — o dono do volume no host tem de bater com
# este UID, ou a gravação recusa com permissão negada.
RUN mkdir -p /app/clientes /app/_derivados \
&& useradd --create-home --uid 10001 sd \
&& chown -R sd:sd /app
USER sd
EXPOSE 5000
# GET / responde 200 sem sessão (renderiza a tela de upload).
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,os,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','5000')+'/',timeout=4).status==200 else 1)"
# -w 1 NÃO é ajuste de desempenho, é requisito de correção: o estado da sessão
# é a global ATUAL no processo do servidor.py (uma SD por vez, em memória).
# Com dois workers, dois requests do MESMO usuário caem em processos
# diferentes e a sessão desaparece de forma aleatória. Pelo mesmo motivo, o
# número de réplicas no Coolify tem de ficar em 1.
#
# --chdir app + servidor:app: o servidor.py insere ../scripts no sys.path a
# partir de __file__, então funciona igual sob gunicorn e sob `python app/servidor.py`.
CMD ["sh", "-c", "exec gunicorn --chdir app servidor:app -w 1 -b 0.0.0.0:${PORT:-5000} --timeout 120 --access-logfile - --error-logfile -"]
+99
View File
@@ -0,0 +1,99 @@
"""
escrita_sd.py — regrava o SD.md preservando os comentários do frontmatter.
Por que round-trip e não yaml.safe_dump: os comentários do frontmatter carregam
decisão de contrato, não enfeite. O campo `estado` da P2·SD10 tem quatro linhas
de correção datada ("CORREÇÃO 17/08/2026 (gestão do contrato): o protótipo
navegável está EM CRIAÇÃO, não em produção como registrado em 11/08"). Um dump
comum apagaria isso sem deixar rastro, e o histórico da decisão morreria num
clique de "Salvar".
Nada aqui grava sozinho: quem chama mostra o diff primeiro e pede confirmação.
"""
from __future__ import annotations
import difflib
import io
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from caminhos import PROJETOS, RAIZ # noqa: E402
def _yaml_rt():
from ruamel.yaml import YAML
y = YAML()
y.preserve_quotes = True
# Largura efetivamente infinita: o padrão do ruamel é 80 e requebraria
# linhas longas que hoje estão inteiras, produzindo um diff cheio de ruído
# em campos que ninguém tocou.
y.width = 4096
return y
def render_sd(dados, corpo: str) -> str:
"""Monta o texto completo do SD.md: frontmatter + prosa, verbatim."""
buf = io.StringIO()
_yaml_rt().dump(dados, buf)
frontmatter = buf.getvalue()
if not frontmatter.endswith("\n"):
frontmatter += "\n"
corpo = corpo or ""
if corpo and not corpo.startswith("\n"):
corpo = "\n" + corpo
if corpo and not corpo.endswith("\n"):
corpo += "\n"
return f"---\n{frontmatter}---\n{corpo}"
def destino_provavel(sd_id: str) -> Path | None:
"""O SD.md do repositório cujo campo `sd:` casa com o da sessão.
Só devolve caminho quando há UMA correspondência. Duas seria ambiguidade, e
escolher sozinho o arquivo que vai ser sobrescrito não é decisão de código.
"""
if not sd_id:
return None
import yaml
achados = []
for f in sorted(PROJETOS.glob("*/sds/*/SD.md")):
try:
linhas = f.read_text(encoding="utf-8").splitlines()
if not linhas or linhas[0].strip() != "---":
continue
fim = next((i for i, l in enumerate(linhas[1:], 1) if l.strip() == "---"), None)
if fim is None:
continue
fm = yaml.safe_load("\n".join(linhas[1:fim])) or {}
except Exception:
continue
if str(fm.get("sd", "")).strip() == str(sd_id).strip():
achados.append(f)
return achados[0] if len(achados) == 1 else None
def diff_contra(destino: Path, texto_novo: str) -> str:
atual = destino.read_text(encoding="utf-8") if destino.exists() else ""
return "".join(difflib.unified_diff(
atual.splitlines(keepends=True),
texto_novo.splitlines(keepends=True),
fromfile=f"a/{destino.name} (no disco)",
tofile=f"b/{destino.name} (da sessão)",
n=3,
)) or "(sem diferenças)"
def dentro_do_repositorio(destino: Path) -> bool:
try:
destino.resolve().relative_to(RAIZ)
return True
except ValueError:
return False
def gravar(destino: Path, texto: str) -> None:
destino.parent.mkdir(parents=True, exist_ok=True)
destino.write_text(texto, encoding="utf-8")
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env python3
"""
servidor.py — a interface do fluxo enviar SD.md → corrigir o que falta → exportar.
python3 app/servidor.py # http://127.0.0.1:5000
O fluxo é curto de propósito: o upload lê o SD.md, o exportador o analisa
contra o contrato da carga (CONTRATO-JSON-V2.md) e a tela mostra o resumo do
que vai sair. Se falta algo, aparece um formulário SÓ com os campos pendentes;
corrigido, o botão Exportar libera. Nada mais.
A INVARIANTE desta aplicação, e o critério pelo qual ela deve ser julgada:
nenhuma rota entrega um caminho de arquivo ao exporta_sd.py, e nenhuma
chama o main() dele. A exportação chama montar() com o dicionário DA
SESSÃO — o mesmo objeto que o formulário editou.
Duas consequências. Primeira: "upload → download automático" é impossível por
construção — não existe caminho de código do /upload até o send_file; o único
que gera JSON é o /exportar, e ele só roda depois de um clique do usuário.
Segunda: o /exportar não confia no botão nem no estado da sessão — chama
montar(), que reprova sozinho se houver pendência. Um POST direto não passa por
botão nenhum.
Estado em memória de processo, uma SD por vez: é ferramenta local de uso
individual. Reiniciar o servidor descarta a sessão — o SD.md de origem continua
intacto no disco, e nada é gravado sem ação explícita (Salvar SD.md).
"""
from __future__ import annotations
import io
import sys
from datetime import date
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from flask import Flask, redirect, render_template, request, send_file, url_for
import escrita_sd
import sessao as S
from exporta_sd import ExportacaoReprovada, Pendencia, montar, serializar
from regras_sd import (ESTADOS_SD, TIPOS_IMPORTAVEIS, carregar_canonico, definir_secao,
ler_sd_texto, secao)
app = Flask(__name__)
# Chave YAML que existe com valor nulo — `processo_sei: null`, `linha_os: null` —
# renderizava como o texto "None" no campo do formulário, e voltava do POST como
# a STRING "None". Um "Salvar SD.md" depois disso gravaria `linha_os: "None"`.
# Vale para todo o template de uma vez, e não campo a campo.
app.jinja_env.finalize = lambda v: "" if v is None else v
# Dias da janela, para a tabela de prévia — a mesma subtração que a carga faz
# para derivar semanas (e, a partir delas, a UST).
app.jinja_env.globals["dias"] = lambda a, b: (date.fromisoformat(b) - date.fromisoformat(a)).days
# Uma SD por vez, em memória de processo. Ver docstring.
ATUAL: dict = {"sessao": None, "aviso": None, "erro": None}
def canonico():
return carregar_canonico()
# ---------------------------------------------------------------------------
# Conversão formulário → dados
# ---------------------------------------------------------------------------
# Tipo YAML de cada chave do frontmatter que o formulário pode editar. O que não
# está aqui é texto.
TIPO_DO_CAMPO = {
"n": "int", "semanas": "int", "ust": "int",
"inicio": "data", "fim": "data", "data_abertura": "data",
}
def _limpo(v) -> str:
return (v or "").strip()
def _como_valor(texto: str, tipo: str):
"""Converte o texto do formulário para o tipo do YAML.
Quando não converte, devolve o texto cru DE PROPÓSITO: quem reclama de data
ilegível ou semana não-inteira é o exportador, com mensagem própria e
apontando o entregável. Silenciar aqui — virando None, ou recusando o post
— tiraria do usuário a única explicação que ele ia receber.
"""
if tipo == "int":
try:
return int(texto)
except ValueError:
return texto
if tipo == "data":
try:
return date.fromisoformat(texto)
except ValueError:
return texto
return texto
def _aplicar(mapa, chave: str, texto: str, tipo: str = "texto") -> None:
"""Grava a chave no CommentedMap, ou a remove quando o campo vem vazio.
Campo esvaziado vira chave AUSENTE, e não string vazia: é assim que o
exportador entende "campo faltante" — e, para `semanas`/`ust`/`linha_os`,
é como o usuário diz "apague a declaração e derive". Só escreve quando o
valor mudou de fato — reatribuir um valor idêntico é oportunidade gratuita
de perder formatação e comentário de linha no writeback.
"""
atual = mapa.get(chave)
if not texto:
if chave in mapa and atual is not None:
del mapa[chave]
return
if isinstance(atual, str) and atual.strip() == texto.strip():
return
novo = _como_valor(texto, tipo)
if atual != novo or chave not in mapa:
mapa[chave] = novo
def _linhas(texto: str) -> list[str]:
return [l.strip() for l in (texto or "").splitlines() if l.strip()]
def _aplicar_lista(mapa, chave: str, texto: str) -> None:
itens = _linhas(texto)
atual = mapa.get(chave)
if not itens:
if chave in mapa and atual:
del mapa[chave]
return
if isinstance(atual, list) and [str(x).strip() for x in atual] == itens:
return
mapa[chave] = itens
def _artefatos_de_texto(texto: str) -> list[dict]:
"""Uma linha por artefato, no formato `nome | AAAA-MM-DD`."""
saida = []
for linha in _linhas(texto):
nome, sep, data = linha.partition("|")
art = {"nome": nome.strip()}
if sep and data.strip():
art["data"] = _como_valor(data.strip(), "data")
saida.append(art)
return saida
def _texto_de_artefatos(artefatos) -> str:
linhas = []
for a in (artefatos or []):
if isinstance(a, dict):
d = a.get("data")
linhas.append(f"{a.get('nome', '')} | {d}" if d else str(a.get("nome", "")))
else:
linhas.append(str(a))
return "\n".join(linhas)
def aplicar_formulario(sess: S.Sessao, form) -> None:
"""Escreve as edições NO OBJETO DA SESSÃO. É a única mutação dos dados.
Percorre as chaves que VIERAM no POST, e só elas. O formulário só traz os
campos pendentes; um laço sobre uma lista fixa de campos leria os ausentes
como "apagados" e o primeiro Salvar removeria do YAML tudo o que não estava
pendente. A chave segue Pendencia.chave: `sd__campo`, `e{i}__campo`,
`corpo__campo`.
"""
d = sess.dados
for chave in form.keys():
escopo, sep, campo = chave.partition("__")
if not sep or not campo:
continue
bruto = form.get(chave, "")
texto = _limpo(bruto)
if escopo == "sd":
_aplicar(d, campo, texto, TIPO_DO_CAMPO.get(campo, "texto"))
elif escopo == "corpo":
if campo == "objetivo":
sess.corpo = definir_secao(sess.corpo, "1", texto.replace("\r\n", "\n"))
elif escopo.startswith("e") and escopo[1:].isdigit():
i = int(escopo[1:])
ents = d.get("entregaveis") or []
if i >= len(ents) or not isinstance(ents[i], dict):
continue
e = ents[i]
if campo in ("atividades", "criterios_aceite"):
_aplicar_lista(e, campo, bruto)
elif campo == "artefatos":
novos = _artefatos_de_texto(bruto)
if novos:
if _texto_de_artefatos(e.get("artefatos")).strip() != _texto_de_artefatos(novos).strip():
e["artefatos"] = novos
elif "artefatos" in e and e.get("artefatos"):
del e["artefatos"]
else:
_aplicar(e, campo, texto, TIPO_DO_CAMPO.get(campo, "texto"))
# ---------------------------------------------------------------------------
# Da pendência ao controle do formulário
# ---------------------------------------------------------------------------
# Decisão de APRESENTAÇÃO, e por isso mora aqui e não no exportador: ele diz
# qual campo falhou, não com que controle se edita.
ROTULO = {
"sd": "Identificador da SD", "titulo": "Título", "po_responsavel": "PO responsável",
"versao": "Versão do documento", "estado": "Estado da SD", "data_abertura": "Data de abertura",
"objetivo": "Objetivo (Seção 1 do SD.md)",
"n": "Número (n)", "nome": "Nome", "tipo": "Tipo", "item": "Item",
"inicio": "Início", "fim": "Fim", "semanas": "Semanas declaradas", "ust": "UST declarada",
"linha_os": "Linha de OS — copie da tela da OS no sistema, formato {OS}-L{n}",
"atividades": "Backlog — um por linha",
"criterios_aceite": "Critérios de aceite — um por linha",
"artefatos": "Artefatos — nome | AAAA-MM-DD, um por linha",
}
TEXTAREA = {"objetivo", "atividades", "criterios_aceite", "artefatos"}
# Sem controle na tela: o conserto é estrutural e se faz no SD.md.
SO_MENSAGEM = {"entregaveis", "dependencias", "secao5", "alocacoes_desvio"}
PLACEHOLDER = {"inicio": "AAAA-MM-DD", "fim": "AAAA-MM-DD", "data_abertura": "AAAA-MM-DD",
"versao": "V1", "sd": "P2·SD8", "linha_os": "1090-L1"}
def _valor_atual(sess: S.Sessao, p: Pendencia):
if p.escopo == "corpo":
return secao(sess.corpo or "", "1") or "" if p.campo == "objetivo" else ""
fonte = sess.dados if p.escopo == "sd" else (sess.dados.get("entregaveis") or [])[p.indice]
if not isinstance(fonte, dict):
return ""
v = fonte.get(p.campo)
if p.campo == "artefatos":
return _texto_de_artefatos(v)
if p.campo in ("atividades", "criterios_aceite"):
return "\n".join(str(x) for x in (v or []))
return "" if v is None else v
def _opcoes(p: Pendencia) -> list | None:
if p.campo == "estado":
return list(ESTADOS_SD)
if p.campo == "tipo":
return list(TIPOS_IMPORTAVEIS)
if p.campo == "item":
return list(p.opcoes or [])
return None
def montar_formulario(sess: S.Sessao) -> list[dict]:
"""Agrupa as pendências por alvo (SD, cada entregável, corpo), um controle
por campo. Uma pendência de `semanas` traz junto `inicio` e `fim`: a
correção pode ser em qualquer um dos três, e a pessoa decide qual."""
grupos: dict[tuple, dict] = {}
for p in sess.pendencias:
if p.escopo == "entregavel":
gk = ("entregavel", p.indice)
titulo = f"Entregável {p.n}" if p.n not in (None, "") else f"Entregável #{p.indice + 1}"
elif p.escopo == "corpo":
gk, titulo = ("corpo", None), "Corpo do documento"
else:
gk, titulo = ("sd", None), "Cabeçalho da SD"
g = grupos.setdefault(gk, {"titulo": titulo, "campos": {}})
# Semanas, início e fim são um só problema visto de três lados: a janela
# que dá as semanas. Pendência em qualquer um traz os outros para a tela.
campos = [p.campo]
if p.campo in ("semanas", "inicio", "fim"):
campos = [c for c in ("semanas", "inicio", "fim") if c == p.campo or c != "semanas"]
for campo in campos:
c = g["campos"].get(campo)
if c is None:
chave = Pendencia(p.escopo, campo, "", indice=p.indice, n=p.n).chave
c = g["campos"][campo] = {
"chave": chave, "campo": campo,
"rotulo": ROTULO.get(campo, campo),
"controle": ("mensagem" if campo in SO_MENSAGEM
else "textarea" if campo in TEXTAREA
else "select" if _opcoes(Pendencia(p.escopo, campo, "")) is not None
else "texto"),
"valor": _valor_atual(sess, Pendencia(p.escopo, campo, "", indice=p.indice)),
"opcoes": _opcoes(p) if campo == p.campo else _opcoes(Pendencia(p.escopo, campo, "")),
"placeholder": PLACEHOLDER.get(campo, ""),
"mensagens": [], "derivado": None,
}
if campo == p.campo:
c["mensagens"].append(p.mensagem)
if p.derivado is not None:
c["derivado"] = p.derivado
return [{"titulo": g["titulo"], "campos": list(g["campos"].values())}
for g in grupos.values()]
# ---------------------------------------------------------------------------
# Rotas
# ---------------------------------------------------------------------------
@app.get("/")
def inicio():
if ATUAL["sessao"] is None:
return render_template("upload.html", estado=S.SEM_ARQUIVO,
rotulo=S.ROTULO_ESTADO[S.SEM_ARQUIVO],
erro=ATUAL.pop("erro", None))
return redirect(url_for("revisar"))
@app.post("/upload")
def upload():
arq = request.files.get("arquivo")
if arq is None or not arq.filename:
ATUAL["erro"] = "Nenhum arquivo selecionado."
return redirect(url_for("inicio"))
if not arq.filename.lower().endswith(".md"):
ATUAL["erro"] = (f"“{arq.filename}” não é um arquivo .md. "
"A SD é um Markdown com frontmatter YAML.")
return redirect(url_for("inicio"))
bruto = arq.read()
if not bruto.strip():
ATUAL["erro"] = f"“{arq.filename}” está vazio."
return redirect(url_for("inicio"))
try:
texto = bruto.decode("utf-8")
except UnicodeDecodeError:
ATUAL["erro"] = (f"“{arq.filename}” não está em UTF-8. "
"Salve o arquivo como UTF-8 e envie de novo.")
return redirect(url_for("inicio"))
try:
# preservar_comentarios=True: é este objeto que o writeback regrava, e os
# comentários do frontmatter carregam decisão de contrato.
dados, corpo = ler_sd_texto(texto, preservar_comentarios=True)
except Exception as exc:
ATUAL["erro"] = f"Não consegui ler “{arq.filename}”: {exc}"
return redirect(url_for("inicio"))
sess = S.Sessao(nome_arquivo=arq.filename, dados=dados, corpo=corpo)
sess.caminho_origem = escrita_sd.destino_provavel(str(dados.get("sd", "")))
ATUAL["sessao"] = sess
ATUAL["erro"] = None
reavaliar(sess)
# Redireciona para a tela de revisão — NUNCA para um download. É a única
# saída desta rota.
return redirect(url_for("revisar"))
def reavaliar(sess: S.Sessao) -> None:
try:
sess.reavaliar(canonico())
except Exception as exc:
sess.ultimo_erro = f"A análise falhou: {type(exc).__name__} — {exc}"
sess.pendencias = [Pendencia("sd", "entregaveis", f"a análise estourou: {exc}")]
sess.estado = S.COM_PENDENCIAS
@app.get("/revisar")
def revisar():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
return render_template(
"revisar.html",
s=sess,
rotulo=sess.rotulo_estado(),
formulario=montar_formulario(sess),
previa=sess.previa or {},
aviso=ATUAL.pop("aviso", None),
)
@app.post("/revisar")
def salvar_revisao():
"""Aplica as correções e reavalia. Não exporta — só o /exportar exporta."""
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
sess.ultimo_erro = None
try:
aplicar_formulario(sess, request.form)
except Exception as exc:
sess.ultimo_erro = f"Não consegui aplicar as correções: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
reavaliar(sess)
return redirect(url_for("revisar"))
@app.post("/exportar")
def exportar():
"""A ÚNICA rota que gera JSON — e montar() reprova sozinho se houver pendência."""
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
sess.ultimo_erro = None
try:
# montar() recebe o DICIONÁRIO DA SESSÃO — o mesmo que o formulário
# editou. Nenhum caminho de arquivo, nenhuma releitura do SD.md.
payload = montar(sess.dados, sess.corpo, canonico())
texto = serializar(payload)
except ExportacaoReprovada as exc:
# Reavaliado no servidor: um POST direto não passa por botão nenhum.
sess.pendencias = exc.pendencias
sess.estado = S.COM_PENDENCIAS
sess.ultimo_erro = (f"Exportação recusada — {len(exc.pendencias)} pendência(s). "
"Nada foi baixado: um arquivo que o importador rejeita não é "
"uma exportação.")
return redirect(url_for("revisar"))
except Exception as exc:
sess.ultimo_erro = f"A geração do JSON falhou: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
sess.marcar_exportada()
nome = f"{str(sess.dados.get('sd', 'sd')).replace('·', '-')}.json"
return send_file(io.BytesIO(texto.encode("utf-8")), mimetype="application/json",
as_attachment=True, download_name=nome)
@app.get("/salvar-sd")
def salvar_sd_preview():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
destino = Path(request.args.get("destino") or (sess.caminho_origem or ""))
try:
texto = escrita_sd.render_sd(sess.dados, sess.corpo)
except Exception as exc:
sess.ultimo_erro = f"Não consegui montar o SD.md: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
return render_template(
"salvar.html", s=sess, destino=str(destino) if str(destino) else "",
diff=escrita_sd.diff_contra(destino, texto) if str(destino) else "",
no_repositorio=bool(str(destino)) and escrita_sd.dentro_do_repositorio(destino),
rotulo=sess.rotulo_estado(),
)
@app.post("/salvar-sd")
def salvar_sd():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
destino = _limpo(request.form.get("destino"))
if not destino:
sess.ultimo_erro = "Informe o caminho de destino para salvar o SD.md."
return redirect(url_for("revisar"))
if request.form.get("confirmo") != "sim":
sess.ultimo_erro = "Gravação cancelada: a confirmação não foi marcada."
return redirect(url_for("revisar"))
try:
escrita_sd.gravar(Path(destino), escrita_sd.render_sd(sess.dados, sess.corpo))
except Exception as exc:
sess.ultimo_erro = f"Não consegui gravar {destino}: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
ATUAL["aviso"] = f"SD.md gravado em {destino}."
return redirect(url_for("revisar"))
@app.post("/reiniciar")
def reiniciar():
ATUAL["sessao"] = None
ATUAL["aviso"] = None
ATUAL["erro"] = None
return redirect(url_for("inicio"))
if __name__ == "__main__":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
print(" Exportação da SD em http://127.0.0.1:5000")
app.run(host="127.0.0.1", port=5000, debug=False)
+80
View File
@@ -0,0 +1,80 @@
"""
sessao.py — a fonte única da SD entre o upload e o download.
O problema que esta camada resolve: a SD é carregada uma vez, vive em memória,
é editada e reavaliada — e o exportador recebe ESSE objeto, não um caminho de
arquivo. Sem isto, a correção feita no formulário nunca chegaria ao JSON.
O que a sessão sabe: os dados (frontmatter, como CommentedMap do ruamel para o
writeback preservar comentários), a prosa, e o resultado da última análise do
exportador — a prévia do payload e a lista de pendências. Pendência vazia é a
única condição para exportar, e ela é reavaliada no servidor a cada POST: o
botão desabilitado é conforto de tela.
Estado em memória de processo, uma SD por vez: é ferramenta local de uso
individual. Reiniciar o servidor descarta a sessão — o SD.md de origem continua
intacto no disco, e nada é gravado sem ação explícita.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from exporta_sd import Pendencia, analisar # noqa: E402
from regras_sd import Canonico # noqa: E402
# ---------------------------------------------------------------------------
# Estados — os que a tela precisa distinguir, e nenhum a mais
# ---------------------------------------------------------------------------
SEM_ARQUIVO = "sem_arquivo"
COM_PENDENCIAS = "com_pendencias"
PRONTA = "pronta"
EXPORTADA = "exportada"
ROTULO_ESTADO = {
SEM_ARQUIVO: "Arquivo não enviado",
COM_PENDENCIAS: "Com pendências",
PRONTA: "Pronta para exportar",
EXPORTADA: "JSON exportado",
}
@dataclass
class Sessao:
nome_arquivo: str
dados: dict # CommentedMap do ruamel — FONTE ÚNICA, mutada pelo formulário
corpo: str
estado: str = COM_PENDENCIAS
pendencias: list[Pendencia] = field(default_factory=list)
previa: dict | None = None
caminho_origem: Path | None = None
ultimo_erro: str | None = None
exportada_em: str | None = None
def reavaliar(self, canon: Canonico) -> None:
"""Roda o exportador em modo de análise sobre os dados COMO ESTÃO.
É grátis e instantâneo; roda a cada edição. Uma edição depois da
exportação volta a sessão para pronta/pendente: o que foi exportado era
outra versão dos dados, e a tela não pode dizer "exportada" sobre esta.
"""
self.previa, self.pendencias = analisar(self.dados, self.corpo, canon)
self.estado = PRONTA if not self.pendencias else COM_PENDENCIAS
self.exportada_em = None
@property
def pode_exportar(self) -> bool:
return not self.pendencias
def marcar_exportada(self) -> None:
self.estado = EXPORTADA
self.exportada_em = datetime.now().isoformat(timespec="seconds")
def rotulo_estado(self) -> str:
return ROTULO_ESTADO.get(self.estado, self.estado)
+358
View File
@@ -0,0 +1,358 @@
/* Public Sans/IBM Plex Mono são carregadas via <link> em base.html, não por
@import aqui — @import serializa a busca (baixa o CSS, só então descobre e
busca a fonte) e travaria a primeira renderização enquanto tenta resolver
fonts.googleapis.com; o <link> deixa isso paralelo e falha mais barato numa
rede restrita. A pilha de fallback abaixo é system-ui: sem internet, o app
fica como antes, só sem o toque final. */
/* Tema claro — o padrão histórico da aplicação. Toda cor é token: o tema escuro
mais abaixo só redefine estes valores, e nenhuma regra do arquivo repete uma
cor crua. Cor crua no meio das regras é o que faz o tema escuro nascer com um
botão branco sobre fundo branco. */
:root {
--fonte: "Public Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-mono: "IBM Plex Mono", ui-monospace, Consolas, monospace;
--tinta: #1b1e25;
/* O par obrigatório de --tinta: o que se escreve EM CIMA dela quando ela é
fundo (pastilha do estado atual, botão primário). Fixar #fff aqui daria
branco sobre claro no tema escuro, onde --tinta é a cor clara. */
--tinta-contraste: #ffffff;
--fraco: #5a6270;
--linha: #dde2e8;
--fundo: #f1f3f6;
--papel: #ffffff;
/* Superfície aninhada — um nível acima do fundo, um abaixo do cartão.
Usada em itens de lista dentro de cartão (achado, bloco-tr) para que
eles se leiam como "dentro" do cartão, não como outro cartão. */
--papel-2: #f6f7f9;
--sombra: 0 1px 2px rgba(23, 27, 35, 0.06), 0 4px 10px rgba(23, 27, 35, 0.05);
--erro: #b3251d;
--erro-fundo: #fdedec;
--erro-borda: #f3c7c2;
--ok: #1b7a4d;
--ok-fundo: #eaf7f0;
--ok-borda: #c2e8d3;
--alerta: #92600a;
--alerta-fundo: #fdf4e0;
--alerta-borda: #efd9a0;
/* Linguagem de proveniência: tudo que o script decidiu por regra fica em
azul; tudo que exige julgamento (do agente, ou do humano confirmando)
fica em roxo. As mesmas duas cores aparecem na borda do cartão da
camada, no selo do cabeçalho e na caixa "Rodar o auditor-sd" — o mesmo
par se repete em qualquer lugar onde a pergunta é "isto foi provado ou
foi julgado?". */
--script: #2f5fa8;
--script-fundo: #eaf1fb;
--script-borda: #c8dbf3;
--agente: #6d3fa0;
--agente-fundo: #f3ecfa;
--agente-borda: #ddc7ef;
--diff-fundo: #1a1d23;
--diff-tinta: #e6e9ef;
--foco: #2f5fa8;
}
/* Tema escuro. Só tokens — nenhuma regra estrutural muda.
Grafite/azul-acinzentado, não preto puro: o fundo mais escuro daqui
(--fundo) ainda tem luminosidade perceptível, e o cartão (--papel) é
sempre um degrau mais claro que ele, nunca igual. As cores semânticas do
tema claro (#b3251d, #1b7a4d, #92600a) foram escolhidas para contrastar
com papel branco e desaparecem sobre fundo escuro; aqui elas sobem em
luminosidade, e os fundos correspondentes descem para tons de vinho,
verde-musgo e âmbar escuros — nunca preto com uma tinta de cor jogada
em cima. */
:root[data-tema="escuro"] {
--tinta: #e9ebf1;
--tinta-contraste: #14171d;
--fraco: #9aa1b0;
--linha: #383e4b;
--fundo: #1a1d23;
--papel: #23272f;
--papel-2: #2a2f39;
--sombra: 0 1px 0 rgba(0, 0, 0, 0.35);
--erro: #ff9891;
--erro-fundo: #3a1f20;
--erro-borda: #5c2d2c;
--ok: #7ad9a6;
--ok-fundo: #16302a;
--ok-borda: #275943;
--alerta: #f0c368;
--alerta-fundo: #362a17;
--alerta-borda: #5c4726;
--script: #89b1f4;
--script-fundo: #1f2b3d;
--script-borda: #33455f;
--agente: #cda3ef;
--agente-fundo: #2b2338;
--agente-borda: #453a5c;
--diff-fundo: #14171c;
--diff-tinta: #dfe3ea;
--foco: #89b1f4;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font: 15.5px/1.55 var(--fonte);
color: var(--tinta);
background: var(--fundo);
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--script-borda); color: var(--tinta); }
a { color: var(--script); }
:focus-visible {
outline: 2px solid var(--foco);
outline-offset: 2px;
border-radius: 4px;
}
header {
background: var(--papel);
border-bottom: 1px solid var(--linha);
padding: 1.1rem 1.75rem 0.85rem;
}
header h1 {
font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: -0.005em;
}
.cabecalho { display: flex; justify-content: space-between; align-items: center;
gap: 1rem; margin-bottom: 0.75rem; }
#alternar-tema {
font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none;
border-radius: 999px;
}
.estados { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.pastilha {
font-size: 0.72rem; font-weight: 500; padding: 0.25rem 0.6rem 0.25rem 0.55rem;
border-radius: 999px; background: var(--papel-2); color: var(--fraco);
border: 1px solid var(--linha); transition: background 0.15s ease, color 0.15s ease;
}
.pastilha.atual {
background: var(--tinta); color: var(--tinta-contraste);
border-color: var(--tinta); font-weight: 600;
}
/* Etapa da auditoria ocupada pelo bypass de teste: cor de alerta, e não a tinta
cheia da etapa cumprida. A trilha não deve parecer completa quando não está. */
.pastilha.atual.bypass {
background: var(--alerta-fundo); color: var(--alerta); border-color: var(--alerta-borda);
}
/* Tudo depois da etapa atual ainda não aconteceu — traço pontilhado e sem
preenchimento, só pela posição na lista (irmão depois de .atual), sem
precisar de uma classe nova vinda do Jinja para cada pastilha. */
.pastilha.atual ~ .pastilha {
background: transparent; border-style: dashed; color: var(--fraco); opacity: 0.8;
}
main { max-width: 1180px; margin: 0 auto; padding: 1.5rem 1.75rem 4.5rem; }
footer { max-width: 1180px; margin: 0 auto; padding: 0 1.75rem 2rem; color: var(--fraco);
font-size: 0.8rem; }
footer p { margin: 0.3rem 0; }
h2 { font-size: 1.2rem; margin: 0 0 0.4rem; font-weight: 700; letter-spacing: -0.01em; }
h3 { font-size: 0.98rem; margin: 0 0 0.5rem; font-weight: 600; }
h4 { font-size: 0.85rem; margin: 1.1rem 0 0.5rem; font-weight: 600; }
.topo { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.cartao {
background: var(--papel); border: 1px solid var(--linha); border-radius: 10px;
padding: 1.15rem 1.3rem; margin-bottom: 1.1rem; box-shadow: var(--sombra);
/* O link do achado da Fase 1 rola até aqui — sem a margem, o topo da seção
encosta na borda da janela e o cabeçalho da seção fica colado. */
scroll-margin-top: 1rem;
}
.fase3 { border-top: 3px solid var(--tinta); }
.duas-colunas { display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem; }
@media (max-width: 960px) { .duas-colunas { grid-template-columns: 1fr; } }
.camada-script { border-top: 3px solid var(--script); }
.camada-agente { border-top: 3px solid var(--agente); }
.selo {
float: right; font-size: 0.66rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--fraco); background: var(--papel-2);
border: 1px solid var(--linha); border-radius: 999px; padding: 0.15rem 0.55rem;
}
/* O selo herda a cor da camada onde mora — nunca precisa de uma classe própria
no template, porque a pergunta que ele responde ("provado ou julgado?") já
está respondida pela seção que o envolve. */
.camada-script .selo { background: var(--script-fundo); color: var(--script); border-color: var(--script-borda); }
.camada-agente .selo { background: var(--agente-fundo); color: var(--agente); border-color: var(--agente-borda); }
.faixa { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.1rem; font-size: 0.9rem; }
.faixa.erro { background: var(--erro-fundo); border: 1px solid var(--erro-borda); color: var(--erro); }
.faixa.aviso { background: var(--ok-fundo); border: 1px solid var(--ok-borda); color: var(--ok); }
.faixa.alerta { background: var(--alerta-fundo); border: 1px solid var(--alerta-borda); color: var(--alerta); }
.nota { color: var(--fraco); font-size: 0.83rem; margin: 0.35rem 0; }
.limitacao { color: var(--alerta); }
.verde { color: var(--ok); font-weight: 500; }
.vermelho { color: var(--erro); font-weight: 500; }
.alerta-texto { color: var(--alerta); font-weight: 500; }
ul.achados, ul.bloqueios { list-style: none; padding: 0; margin: 0.6rem 0 0; }
ul.achados li {
font-size: 0.85rem; padding: 0.5rem 0.6rem; border-left: 3px solid var(--linha);
border-radius: 0 6px 6px 0; margin-bottom: 0.35rem; background: var(--papel-2);
}
/* Com a Fase 2 fora da tela o painel da Fase 1 ocupa a largura toda, e uma
coluna só de achados deixava metade da linha vazia. Grade, e não `columns`:
`columns` quebra um item no meio entre uma coluna e outra. */
ul.achados.em-colunas {
display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.7rem; align-items: start;
}
@media (max-width: 760px) { ul.achados.em-colunas { grid-template-columns: 1fr; } }
li.nivel-erro { border-left-color: var(--erro); }
li.nivel-aviso { border-left-color: var(--alerta); }
.tag { font-weight: 700; font-size: 0.68rem; margin-right: 0.4rem; letter-spacing: 0.03em; }
.nivel-erro .tag { color: var(--erro); }
.nivel-aviso .tag { color: var(--alerta); }
.regra { font-family: var(--fonte-mono); font-size: 0.74rem;
color: var(--fraco); margin-right: 0.4rem; }
.ent { font-size: 0.72rem; background: var(--linha); border-radius: 4px;
padding: 0.05rem 0.35rem; margin-right: 0.35rem; }
.ir-para { font-size: 0.72rem; background: var(--linha); color: var(--tinta);
border-radius: 4px; padding: 0.05rem 0.35rem; margin-right: 0.35rem;
text-decoration: none; white-space: nowrap; transition: background 0.15s ease, color 0.15s ease; }
.ir-para:hover { background: var(--tinta); color: var(--tinta-contraste); }
ul.bloqueios li { font-size: 0.85rem; padding: 0.4rem 0.7rem 0.4rem 1rem;
border-left: 3px solid var(--erro); border-radius: 0 6px 6px 0;
margin-bottom: 0.35rem; background: var(--erro-fundo); }
ol.checklist { font-size: 0.83rem; color: var(--fraco); padding-left: 1.2rem; }
ol.checklist li { margin-bottom: 0.2rem; }
label { display: block; margin-bottom: 0.7rem; font-size: 0.82rem; color: var(--fraco); font-weight: 500; }
label.radio { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 1rem;
color: var(--tinta); font-size: 0.82rem; font-weight: 400; }
input[type=text], input:not([type]), select, textarea {
display: block; width: 100%; margin-top: 0.3rem; padding: 0.5rem 0.6rem;
border: 1px solid var(--linha); border-radius: 7px; font: inherit;
font-size: 0.88rem; color: var(--tinta); background: var(--papel);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input[type=text]:hover, input:not([type]):hover, select:hover, textarea:hover {
border-color: var(--fraco);
}
input[type=text]:focus, input:not([type]):focus, select:focus, textarea:focus {
border-color: var(--foco);
outline: 2px solid var(--foco); outline-offset: 1px;
}
input[type=checkbox], input[type=radio] { accent-color: var(--tinta); }
::placeholder { color: var(--fraco); opacity: 0.7; }
input[type=file] {
display: block; width: 100%; margin: 0.3rem 0 0.9rem; font: inherit;
font-size: 0.85rem; color: var(--fraco);
}
input[type=file]::file-selector-button {
font: inherit; font-size: 0.85rem; font-weight: 500; padding: 0.5rem 1rem;
border-radius: 7px; border: 1px solid var(--linha); background: var(--papel);
color: var(--tinta); cursor: pointer; margin-right: 0.7rem;
transition: border-color 0.15s ease;
}
input[type=file]::file-selector-button:hover { border-color: var(--fraco); }
textarea { resize: vertical; }
textarea.mono { font-family: var(--fonte-mono); font-size: 0.8rem; }
.grade { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 0.9rem; }
.grade-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 0.9rem; }
@media (max-width: 760px) { .grade-3 { grid-template-columns: 1fr; } }
button, .botao {
font: inherit; font-size: 0.85rem; font-weight: 500; padding: 0.5rem 1rem; border-radius: 7px;
border: 1px solid var(--linha); background: var(--papel); color: var(--tinta);
cursor: pointer; text-decoration: none; display: inline-block;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.05s ease;
}
button:hover, .botao:hover { border-color: var(--fraco); }
button:active, .botao:active { transform: translateY(1px); }
button.primario { background: var(--tinta); color: var(--tinta-contraste);
border-color: var(--tinta); }
button.primario:hover { background: var(--fraco); border-color: var(--fraco); }
button.primario[disabled] { background: var(--papel-2); color: var(--fraco);
border-color: var(--linha); cursor: not-allowed; }
button.primario[disabled]:hover { background: var(--papel-2); border-color: var(--linha); }
button.discreto, .botao.discreto { color: var(--fraco); background: transparent; border-color: transparent; }
button.discreto:hover, .botao.discreto:hover { color: var(--tinta); background: var(--papel-2); border-color: var(--linha); }
.acoes { display: flex; gap: 0.65rem; align-items: center; flex-wrap: wrap; margin-top: 0.9rem; }
.acoes form { margin: 0; }
/* Salvar / Voltar ao Topo no fim de cada seção. A barra grudada no rodapé saiu:
o "Salvar edições e revalidar" mora agora na Fase 3, no topo da tela, e um
terceiro lugar para o mesmo botão só faria o usuário procurar. */
.rodape-secao { border-top: 1px solid var(--linha); padding-top: 0.7rem;
margin-top: 1rem; }
.entregavel { border-left: 3px solid var(--linha); }
.achado-item { border: 1px solid var(--linha); border-radius: 8px; padding: 0.7rem 0.8rem;
margin-bottom: 0.55rem; background: var(--papel-2); }
.achado-item.pendente { border-color: var(--alerta-borda); background: var(--alerta-fundo); }
.texto-achado { margin: 0 0 0.45rem; font-size: 0.85rem; }
.opcoes { margin-bottom: 0.45rem; }
.bloco-tr { border-left: 3px solid var(--script); background: var(--script-fundo);
padding: 0.7rem 0.9rem; border-radius: 0 8px 8px 0; margin-bottom: 0.65rem; }
.bloco-tr .tr-item { margin: 0 0 0.35rem; font-size: 0.88rem; }
ul.aderencia { margin: 0.35rem 0 0; padding-left: 1.1rem; font-size: 0.83rem; }
ul.aderencia li { margin-bottom: 0.2rem; }
pre.diff {
background: var(--diff-fundo); color: var(--diff-tinta); padding: 0.9rem 1rem;
border-radius: 8px; overflow-x: auto; font-family: var(--fonte-mono);
font-size: 0.78rem; line-height: 1.5; max-height: 480px;
}
code { font-family: var(--fonte-mono); font-size: 0.85em;
background: var(--papel-2); border: 1px solid var(--linha);
padding: 0.05rem 0.3rem; border-radius: 4px; }
details summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; font-weight: 500; }
.agente { border: 1px dashed var(--agente-borda); background: var(--agente-fundo);
border-radius: 8px; padding: 0.8rem 0.9rem; margin-bottom: 0.9rem; }
.agente form { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; }
.agente .nota { margin: 0; flex: 1 1 16rem; }
/* A bifurcação Auditar / Não Auditar. Fica acima do painel do agente, no ponto
do fluxo em que a decisão é tomada, e visualmente separada dele: não é parte
da auditoria, é a alternativa a ela. */
.decisao-fase2 { border: 1px dashed var(--alerta-borda); border-radius: 8px;
padding: 0.8rem 0.9rem; margin-bottom: 0.9rem; background: var(--alerta-fundo); }
.decisao-fase2 form { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; }
.decisao-fase2 .nota, .decisao-fase2 p { margin: 0; flex: 1 1 16rem; }
.parecer-meta { margin-top: 0.7rem; border-top: 1px solid var(--linha); padding-top: 0.6rem; }
.parecer-meta p { font-size: 0.83rem; margin: 0.35rem 0; }
/* ---- Fluxo curto: pendências e resumo ---------------------------------- */
.pendencia { border-top: 1px solid var(--linha); padding-top: 0.7rem; margin-top: 0.7rem; }
.pendencia:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
.pendencia .texto-achado { color: var(--alerta); }
.pendencia .tag { color: var(--alerta); }
.pendencia label .nota { display: inline; font-weight: 400; }
.resumo { gap: 0.6rem 0.9rem; margin-bottom: 0.9rem; }
.resumo > div { display: flex; flex-direction: column; font-size: 0.9rem; }
.resumo .nota { margin: 0 0 0.1rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; }
.tabela-rolavel { overflow-x: auto; }
table.entregaveis { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
table.entregaveis th, table.entregaveis td {
text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--linha);
vertical-align: top;
}
table.entregaveis th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--fraco); font-weight: 600; }
table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem;
font-variant-numeric: tabular-nums; white-space: nowrap; }
+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titulo %}Exportação da SD{% endblock %}</title>
<!-- Antes da folha de estilo, e não no fim do body: aplicado depois, o tema
claro aparece por um quadro inteiro antes de escurecer. Sem tema gravado,
vale a preferência do sistema. -->
<script>
(function () {
try {
var t = localStorage.getItem("tema");
if (t !== "claro" && t !== "escuro") {
t = matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro";
}
document.documentElement.dataset.tema = t;
} catch (e) {
document.documentElement.dataset.tema = "claro";
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body>
<header>
<div class="cabecalho">
<h1>Solicitação de Demanda — enviar · corrigir · exportar</h1>
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
aria-label="Alternar tema claro e escuro">◐</button>
</div>
<div class="estados">
{# Três etapas, porque são três as que existem: o arquivo chega, ou está
pronto ou tem pendência, e sai. "Com pendências" e "Pronta" ocupam a
mesma posição — são os dois resultados possíveis da mesma análise. #}
<span class="pastilha {{ 'atual' if estado_atual == 'sem_arquivo' }}">1 Arquivo</span>
{% if estado_atual == 'com_pendencias' %}
<span class="pastilha atual bypass">2 Com pendências</span>
{% else %}
<span class="pastilha {{ 'atual' if estado_atual == 'pronta' }}">2 Pronta para exportar</span>
{% endif %}
<span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span>
</div>
</header>
<main id="topo">
{% if erro %}<div class="faixa erro"><strong>Erro.</strong> {{ erro }}</div>{% endif %}
{% if aviso %}<div class="faixa aviso">{{ aviso }}</div>{% endif %}
{% block conteudo %}{% endblock %}
</main>
<footer>
<p>Regras da carga: <code>CONTRATO-JSON-V2.md</code> ·
exportação: <code>scripts/exporta_sd.py</code> (função <code>montar</code>) ·
OS e linha: <code>linha_os</code> declarado no SD.md, conferido pelo sistema na carga.</p>
</footer>
<script>
document.getElementById("alternar-tema").addEventListener("click", function () {
var raiz = document.documentElement;
var novo = raiz.dataset.tema === "escuro" ? "claro" : "escuro";
raiz.dataset.tema = novo;
try { localStorage.setItem("tema", novo); } catch (e) {}
});
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% set estado_atual = s.estado %}
{% set erro = s.ultimo_erro %}
{% block titulo %}{{ s.dados.get('sd', 'SD') }} — exportação{% endblock %}
{% block conteudo %}
<div class="topo">
<div>
<h2>{{ s.dados.get('sd', '(sem identificador)') }} — {{ s.dados.get('titulo', '') }}</h2>
<p class="nota">Origem: <code>{{ s.nome_arquivo }}</code> ·
estado: <strong>{{ rotulo }}</strong>
{% if s.exportada_em %} · exportada em {{ s.exportada_em }}{% endif %}</p>
</div>
<form method="post" action="{{ url_for('reiniciar') }}">
<button type="submit" class="discreto">Enviar outra SD</button>
</form>
</div>
<!-- ================= Ações =================
No topo: é daqui que sai a única ação que gera arquivo. O botão fica
desabilitado com pendência por conforto; o que garante é o servidor —
montar() reprova sozinho no /exportar. -->
<section class="cartao fase3">
{% if s.pendencias %}
<p class="vermelho">{{ s.pendencias|length }} pendência(s) impedem a exportação.
Corrija abaixo e salve — ou edite o SD.md e envie de novo.</p>
{% else %}
<p class="verde">Tudo o que a carga exige está presente e fecha: datas, semanas,
linha de OS, status e vocabulário. UST, tarifa e valor são calculados pelo
sistema na carga. O JSON sai com a estrutura de sempre.</p>
{% endif %}
<div class="acoes">
<form method="post" action="{{ url_for('exportar') }}">
<button type="submit" class="primario" {{ 'disabled' if not s.pode_exportar }}>
Exportar JSON
</button>
</form>
{% if s.pendencias %}
<button type="submit" form="form-pendencias" class="primario">Salvar correções e conferir de novo</button>
{% endif %}
<a class="botao discreto" href="{{ url_for('salvar_sd_preview') }}">Salvar SD.md…</a>
</div>
</section>
<!-- ================= Pendências =================
Um controle por campo pendente, e nenhum a mais. Os nomes seguem
Pendencia.chave (sd__campo · e{i}__campo · corpo__campo); o servidor só
toca no que vem no POST, então campo fora daqui não é alterado. -->
{% if s.pendencias %}
<form method="post" action="{{ url_for('salvar_revisao') }}" class="formulario" id="form-pendencias">
{% for grupo in formulario %}
<section class="cartao entregavel" id="grupo-{{ loop.index }}">
<h3>{{ grupo.titulo }}</h3>
{% for c in grupo.campos %}
<div class="pendencia">
{% for m in c.mensagens %}
<p class="texto-achado"><span class="tag">PENDENTE</span> {{ m }}</p>
{% endfor %}
{% if c.controle == 'mensagem' %}
<p class="nota limitacao">Este conserto é no <code>SD.md</code>: corrija o arquivo e envie de novo.</p>
{% elif c.controle == 'textarea' %}
<label>{{ c.rotulo }}
<textarea name="{{ c.chave }}" rows="{{ 5 if c.campo == 'objetivo' else 3 }}">{{ c.valor }}</textarea></label>
{% elif c.controle == 'select' %}
<label>{{ c.rotulo }}
<select name="{{ c.chave }}">
<option value="">—</option>
{% for o in c.opcoes %}
<option value="{{ o }}" {{ 'selected' if c.valor|string == o|string }}>{{ o }}</option>
{% endfor %}
</select></label>
{% else %}
<label>{{ c.rotulo }}
{% if c.derivado is not none %}<span class="nota"> — pela regra da carga: <strong>{{ c.derivado }}</strong>; em branco, deriva</span>{% endif %}
<input name="{{ c.chave }}" value="{{ c.valor }}" placeholder="{{ c.placeholder }}"></label>
{% endif %}
</div>
{% endfor %}
</section>
{% endfor %}
</form>
{% endif %}
<!-- ================= Resumo do que vai sair =================
Derivado do payload como ele está. Com pendência, aparecem os buracos
(linha vazia, UST vazia) — é a prévia, não o arquivo. -->
<section class="cartao camada-script">
<h3>O que o JSON vai conter <span class="selo">derivado do SD.md</span></h3>
{% set sd = previa.sd or {} %}
<div class="grade resumo">
<div><span class="nota">Número</span><strong>{{ sd.numero_sequencial }}</strong></div>
<div><span class="nota">Projeto</span><strong>{{ (sd.projeto or {}).codigo }}</strong> {{ (sd.projeto or {}).nome }}</div>
<div><span class="nota">PO responsável</span><strong>{{ (sd.po_responsavel or {}).nome or '—' }}</strong></div>
<div><span class="nota">Status da SD</span><strong>{{ sd.status or '—' }}</strong></div>
<div><span class="nota">Período</span><strong>{{ sd.inicio or '—' }} → {{ sd.fim or '—' }}</strong></div>
<div><span class="nota">Itens</span><strong>{{ (sd.itens_contratuais or [])|join(', ') or '—' }}</strong></div>
<div><span class="nota">Entregáveis</span><strong>{{ (previa.entregaveis or [])|length }}</strong></div>
</div>
<div class="tabela-rolavel">
<table class="entregaveis">
<thead>
<tr><th>n</th><th>Entregável</th><th>Tipo</th><th>Item</th><th>Linha OS</th>
<th>Início</th><th>Fim</th><th>Dias</th><th>Sem.</th><th>Status</th></tr>
</thead>
<tbody>
{% for e in previa.entregaveis or [] %}
<tr>
<td class="num">{{ e.n }}</td>
<td>{{ e.titulo }}</td>
<td>{{ e.tipo_entrega }}</td>
<td>{{ e.item }}</td>
<td class="{{ 'vermelho' if not e.ordem_servico.linha }}">{{ e.ordem_servico.linha or '—' }}</td>
<td class="num">{{ e.data_inicio or '—' }}</td>
<td class="num">{{ e.data_prevista_termino or '—' }}</td>
<td class="num">{% if e.data_inicio and e.data_prevista_termino %}{{ dias(e.data_inicio, e.data_prevista_termino) }}{% else %}—{% endif %}</td>
<td class="num">{{ e.numero_semanas if e.numero_semanas is not none else '—' }}</td>
<td>{{ e.status or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="nota">Semanas = max(1, ⌈dias ÷ 7⌉) sobre as datas, teto de 28 dias. UST, tarifa e
valor <strong>não saem daqui</strong>: o sistema os calcula na carga a partir de datas, item
e tipo, com as tabelas vigentes. Linha de OS é o <code>linha_os</code> declarado no SD.md;
quem confere se a OS e a linha existem é o sistema de gestão, na carga.</p>
</section>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% set estado_atual = s.estado %}
{% set erro = s.ultimo_erro %}
{% block titulo %}Salvar SD.md{% endblock %}
{% block conteudo %}
<section class="cartao">
<h2>Salvar as edições no SD.md</h2>
<p>O frontmatter é regravado em <em>round-trip</em>: comentários, ordem das
chaves e aspas são preservados. Listas reconstruídas pelo formulário
(critérios, artefatos, dependências) perdem comentários internos, se houver.</p>
{% if no_repositorio %}
<div class="faixa alerta">
<strong>Este destino está dentro do repositório.</strong> A prosa gravada é a
da <em>sessão</em>, que veio do arquivo enviado — se o SD.md do repositório
tiver um corpo diferente, ele será substituído. Confira no diff abaixo.
</div>
{% endif %}
<form method="post" action="{{ url_for('salvar_sd') }}">
<label>Caminho de destino
<input name="destino" value="{{ destino }}" size="100"
placeholder="caminho completo do SD.md" required></label>
{% if not destino %}
<p class="nota">Não encontrei no repositório um <code>SD.md</code> cujo campo
<code>sd:</code> case sozinho com o desta sessão. Informe o caminho.</p>
{% endif %}
<h3>Diferenças</h3>
<pre class="diff">{{ diff }}</pre>
<label class="radio">
<input type="checkbox" name="confirmo" value="sim" required>
Confirmo a gravação neste caminho.</label>
<div class="acoes">
<button type="submit" class="primario">Gravar SD.md</button>
<a class="botao discreto" href="{{ url_for('revisar') }}">Voltar sem gravar</a>
</div>
</form>
</section>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% set estado_atual = 'sem_arquivo' %}
{% block titulo %}Enviar SD.md{% endblock %}
{% block conteudo %}
<section class="cartao">
<h2>Enviar o SD.md</h2>
<p>Selecione o <code>SD.md</code> da Solicitação de Demanda. O arquivo é lido
e conferido contra as regras da carga do sistema de gestão. Se estiver
completo, a próxima tela mostra o resumo e libera o botão
<em>Exportar JSON</em>; se faltar algo, ela abre um formulário só com os
campos a corrigir. Nada é exportado neste passo.</p>
<form method="post" action="{{ url_for('upload') }}" enctype="multipart/form-data">
<input type="file" name="arquivo" accept=".md" required>
<button type="submit" class="primario">Enviar e conferir</button>
</form>
<p class="nota">Somente arquivos <code>.md</code> com frontmatter YAML. O arquivo
enviado não é alterado: as correções ficam na sessão, e só voltam ao disco se
você pedir explicitamente em <em>Salvar SD.md</em>.</p>
</section>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
# Deploy em VPS pelo painel Coolify (recurso do tipo "Docker Compose").
#
# ANTES do primeiro deploy, ponha os dados do cliente em um caminho FIXO do
# host, FORA da pasta que o Coolify clona — ele re-clona a cada deploy, e
# clientes/ está no .gitignore, então o clone nunca traz esses dados:
#
# scp -r clientes/ usuario@vps:/dados/exporta-sd/clientes
# ssh usuario@vps 'sudo chown -R 10001:10001 /dados/exporta-sd/clientes'
#
# O chown usa o UID do usuário `sd` da imagem. Sem ele, a leitura funciona e o
# "Salvar SD.md" falha com permissão negada — falha parcial, difícil de ler.
# Depois, no Coolify → Environment Variables, aponte o volume para lá:
#
# CLIENTES_DIR=/dados/exporta-sd/clientes
# DERIVADOS_DIR=/dados/exporta-sd/_derivados # só se usar a linha de comando
#
# Sem essas variáveis, o padrão ./clientes cai dentro da pasta do clone, o
# compose cria o diretório VAZIO sem avisar e o primeiro upload morre com
# "Arquivo canônico ausente".
#
# Resto do painel: porta exposta 5000, réplicas 1 (ver o comentário de -w 1 no
# Dockerfile), e o domínio o Coolify publica via Traefik.
services:
exporta-sd:
build:
context: .
dockerfile: Dockerfile
# Sem `ports:` de propósito: no Coolify o Traefik alcança o container pela
# rede interna. `expose` publica a porta só para dentro dessa rede.
# Para rodar fora do Coolify, troque por: ports: ["5000:5000"].
expose:
- "5000"
environment:
PORT: "5000"
volumes:
# OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada
# análise e grava SD.md em clientes/ses-mg/projetos/. O caminho no
# container é fixo: caminhos.py resolve clientes/ a partir da raiz do
# código, que é o WORKDIR /app. Sem esta montagem, a tela inicial abre
# e o primeiro upload falha com "Arquivo canônico ausente".
- ${CLIENTES_DIR:-./clientes}:/app/clientes
# Saída da linha de comando (exporta_sd.py --dir _derivados/export).
# A aplicação web entrega o JSON pelo download, não por aqui.
- ${DERIVADOS_DIR:-./_derivados}:/app/_derivados
restart: unless-stopped
# Uma SD por vez, em memória de processo: reiniciar descarta a sessão
# aberta (o SD.md de origem no volume fica intacto).
stop_grace_period: 10s
+6
View File
@@ -0,0 +1,6 @@
# Só do deploy em container (Dockerfile). Fora do requirements.txt de propósito:
# o venv Windows do uso local roda `python app/servidor.py`, que não usa gunicorn.
#
# O servidor de desenvolvimento do Flask escuta em 127.0.0.1 e não é para
# produção; o gunicorn escuta em 0.0.0.0 para o proxy do Coolify alcançar.
gunicorn==23.0.0
+12
View File
@@ -0,0 +1,12 @@
# Leitura dos YAMLs de contrato e do frontmatter do SD.md (scripts/regras_sd.py).
PyYAML==6.0.3
# Aplicação web do fluxo enviar → corrigir → exportar (app/servidor.py).
Flask==3.1.3
# Writeback do SD.md em round-trip: preserva os comentários do frontmatter, que
# carregam decisão de contrato (ver o campo `estado` da P2·SD10).
ruamel.yaml==0.19.1
# Só nos testes (scripts/teste_fluxo.py): confere a ESTRUTURA do JSON exportado
# contra clientes/ses-mg/projetos/_template/sd-schema.json. O portão do download
# é o contrato da carga (CONTRATO-JSON-V2.md), implementado em exporta_sd.py.
jsonschema==4.26.0
+53
View File
@@ -0,0 +1,53 @@
"""Caminhos canônicos do repositório — o único lugar que sabe onde a árvore está.
Mudança de estrutura é mudança aqui, e em mais lugar nenhum. A lição veio da
reorganização V0: eram 103 referências de caminho espalhadas por scripts,
Makefile e harness, e mover uma pasta quebrava tudo entre a mudança e a
reescrita.
"""
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
CLIENTE = RAIZ / "clientes" / "ses-mg"
CONTRATO = CLIENTE / "contrato"
GOVERNANCA = CLIENTE / "governanca"
PROJETOS = CLIENTE / "projetos"
ENTRADA = CLIENTE / "_entrada"
RELATORIOS = CLIENTE / "relatorios"
COMUNICACOES = CLIENTE / "comunicacoes"
METODOS = RAIZ / "metodos"
# _derivados/ é resultado, nunca fonte: gitignored, regenerável por make derivar.
DERIVADOS = RAIZ / "_derivados"
ITENS = CONTRATO / "itens.yaml"
# Visões GERADAS a partir das SDs (inversão da V0, 13/08/2026). Quem lê
# portfólio, carteira ou catálogo lê o resultado de `make derivar` — os
# mantidos à mão estão congelados em governanca/levantamentos/.
PORTFOLIO = DERIVADOS / "portfolio.yaml"
CARTEIRA = DERIVADOS / "carteira.yaml"
CATALOGO = DERIVADOS / "catalogo.md"
NUMERACAO = GOVERNANCA / "numeracao.yaml"
TRANSVERSAL = GOVERNANCA / "transversal.yaml"
TEMPLATE_SD = PROJETOS / "_template" / "SD.md"
# Modelo Word do material de leitura prévia (`make leitura-previa`): layout da
# nota técnica com logo no cabeçalho. Vive em metodos/modelos/ — cópia neutra;
# a original é evidência da P5·SD24 e evidência não é dependência de script.
MODELO_NOTA_TECNICA = METODOS / "modelos" / "nota-tecnica.docx"
# Projeção financeira: gerada em _derivados/, congelada datada em comunicacoes/.
PROJECAO_HTML = DERIVADOS / "projecao-executiva.html"
APRESENTACOES = COMUNICACOES / "apresentacoes"
def sds_reais():
"""Todos os SD.md sob projetos/*/sds/.
O template vive em projetos/_template/, fora de qualquer */sds/* — é o que
o mantém fora desta varredura por construção, em todos os consumidores de
uma vez. Era ele contaminando a varredura de faturamento na árvore antiga.
"""
return sorted(PROJETOS.glob("*/sds/*/SD.md"))
+711
View File
@@ -0,0 +1,711 @@
#!/usr/bin/env python3
"""
exporta_sd.py — gera o JSON de carga do sistema de gestão a partir do SD.md.
O SD.md é a fonte. Este script deriva tudo o que é derivável e monta o payload
que `npm run import:sd` consome — o contrato está em CONTRATO-JSON-V2.md, e
este arquivo é a implementação dele na origem.
A regra que governa tudo aqui: NUNCA sai JSON com pendência aberta. Um arquivo
que o importador vai rejeitar não é uma exportação, é um problema adiado para
onde não há contexto para corrigi-lo. Então montar() ou devolve o payload
inteiro, ou levanta ExportacaoReprovada com a lista de pendências — cada uma
apontando o campo e o entregável, para que a tela abra o formulário só do que
falta e a CLI diga exatamente o que mudar no .md.
O que é DERIVADO aqui, nunca lido do arquivo:
· semanas — max(1, ceil(dias/7)) sobre inicio/fim. As DATAS mandam: o
`semanas` do .md é conferido contra isto e reprova se divergir.
· status — do `estado` da SD, nos rótulos exatos da §6 do contrato
· código, alocações (matriz de perfis de itens.yaml), prazo, titulo_literal
O que é derivado PELO IMPORTADOR, e por isso sai `null` daqui (decisão da
gestão em 2026-09-03, aceita pelo time do sistema — ver
PROPOSTA-CONTRATO-V2-derivacao.md): `ust`, `valor_unitario_ust` e
`valor_entregavel`. Time-box por item × tipo e tarifa vigente são tabelas do
sistema de gestão; copiá-las aqui era manter um retrato que envelhecia a cada
reajuste. As chaves continuam no JSON, na mesma posição — só o valor é `null`.
`horas_semanais` e `memoria_calculo`, que só existiam para explicar a conta,
saem `null` pelo mesmo motivo.
O que NÃO é derivado, por decisão da gestão (2026-09-03): `ordem_servico.linha`.
OS e linha são cadastro do sistema de gestão e mudam durante o ano; um de-para
local envelheceria em silêncio. Quem emite a SD declara `linha_os` no
entregável, copiado da tela da OS; aqui só o formato `{OS}-L{n}` é conferido,
e ausente é pendência. Existência, item e status quem confere é o banco, na
carga — que recusa nomeando o que falta.
O que este script NÃO faz, por decisão da gestão (2026-09-03): não confere os
tetos de texto da §2/§3/§4 (nome ≤ 160, objetivo ≤ 4000 etc.) — o importador
rejeita e informa. Campos extras à estrutura do contrato (codigo, memoria_calculo,
_servico, totais...) continuam saindo: o importador os ignora, e a ESTRUTURA do
JSON não muda — só os valores.
Cuidado: `totais.prazo_calendario_semanas` é duração de CALENDÁRIO da SD, decimal
de propósito, e NÃO usa a regra de semanas do entregável. São grandezas
diferentes — ver o comentário no cálculo do prazo antes de tentar unificar.
Uso:
python3 scripts/exporta_sd.py sds/P2-SD8-mvp-vacina-em-dia/SD.md
python3 scripts/exporta_sd.py sds/*/SD.md --dir _derivados/export
Contrato de saída (para quem chama de make/CI): cada SD é exportada de forma
independente — uma SD com pendência não impede as outras. Código 0 = todas
saíram; 1 = alguma ficou pendente, as demais saíram normalmente, e as que
faltaram vão nomeadas em stderr com as pendências. Nunca trate 1 como "nada
foi gerado".
"""
from __future__ import annotations
import argparse
import glob
import json
import re
import sys
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from regras_sd import (
Canonico,
ESTADO_SD_PARA_ENTREGAVEL,
ESTADO_SD_PARA_STATUS_SD,
ESTADOS_SD,
JANELA_MAXIMA_DIAS,
PERFIS_DO_CADASTRO,
TIPOS_IMPORTAVEIS,
TIPOS_QUE_PARAM_A_CARGA,
carregar_canonico,
ITENS_FORA_DA_CARGA,
checar_dependencias,
como_data,
como_iso,
conferir_formato_linha_os,
enquadramento_tr,
janela_dias,
ler_sd_arquivo,
perfil_do_cadastro,
secao,
semanas_por_datas,
)
ABREV_TIPO = {"Descoberta": "D", "Design": "DE", "Arquitetura": "A", "Construção": "C"}
# "P2·SD8" — projeto, separador canônico, número global da SD.
_SD_ID = re.compile(r"^\s*(?P<proj>[^·\s]+)\s*·\s*SD(?P<num>\d+)\s*$")
_VERSAO = re.compile(r"^V\d+$")
# Convenção do repositório para "não sei": o _template/SD.md proíbe datas como
# "não formalizada" pelo mesmo motivo. Um PO assim não existe no cadastro.
_PENDENTE = re.compile(r"^\s*pendente\b", re.I)
# ---------------------------------------------------------------------------
# Pendências
# ---------------------------------------------------------------------------
@dataclass
class Pendencia:
"""O que impede o JSON de sair, apontando onde se corrige.
`escopo` diz de onde o campo vem: "sd" (frontmatter, nível da SD),
"entregavel" (frontmatter, `entregaveis[indice]`) ou "corpo" (a prosa —
hoje só o objetivo, Seção 1). `campo` é a CHAVE DO SD.md, não a do JSON:
é no .md que a pessoa corrige, e é ele que o formulário edita.
`indice` é a posição na lista, e não o `n`: `n` pode faltar ou repetir —
e as duas coisas são pendências que precisam apontar para algum lugar.
`opcoes` fecha a lista quando ela é fechada (estado, tipo, item, linha);
`derivado` é o valor que a regra calculou, para a tela sugerir.
"""
escopo: str
campo: str
mensagem: str
indice: int | None = None
n: object = None
opcoes: list | None = None
derivado: object = None
@property
def chave(self) -> str:
"""Nome do campo no formulário — uma pendência, um controle."""
if self.escopo == "entregavel":
return f"e{self.indice}__{self.campo}"
return f"{self.escopo}__{self.campo}"
@property
def onde(self) -> str:
if self.escopo == "entregavel":
rot = f"entregável {self.n}" if self.n not in (None, "") else f"entregável #{self.indice + 1}"
return f"{rot} · {self.campo}"
if self.escopo == "corpo":
return f"Seção do corpo · {self.campo}"
return f"SD · {self.campo}"
def __str__(self) -> str:
return f"{self.onde}: {self.mensagem}"
class ExportacaoReprovada(ValueError):
"""montar() não produziu JSON. `pendencias` diz por quê, campo a campo."""
def __init__(self, pendencias: list[Pendencia]):
self.pendencias = list(pendencias)
super().__init__("\n".join(f" · {p}" for p in self.pendencias))
# ---------------------------------------------------------------------------
# Peças reaproveitadas
# ---------------------------------------------------------------------------
def alocacoes_do_tipo(canon: dict, tipo: str) -> list[dict]:
"""Matriz de perfis do TR 4.1.1.7.1 aplicada ao tipo do entregável.
`null` na matriz significa que o perfil NÃO CONSTA da tabela daquela sprint
no TR — diferente de constar com 0%. Perfil ausente não entra na lista.
O nome sai como o CADASTRO do cliente o conhece (regras_sd.PERFIL_ALIASES),
e não como o itens.yaml o transcreve do TR.
"""
matriz = ((canon.get("composicao_perfis") or {}).get("matriz")) or {}
saida = []
for perfil, pcts in matriz.items():
pct = pcts.get(tipo)
if pct is None or pct == 0:
continue
saida.append({
"perfil": perfil_do_cadastro(perfil),
"quantidade": pcts.get("quantidade", 1),
"percentual_alocacao": pct,
})
return saida
def _json_default(o):
"""Último recurso do json.dumps para o que o YAML devolve e o JSON não conhece.
Os campos DERIVADOS passam por como_iso(). Os blocos que o exportador copia
inteiros do frontmatter — aceites_documento, os_mae — não passam por nada,
e uma data crua ali chegava ao json.dumps e estourava
`TypeError: Object of type date is not JSON serializable`. Seis das doze SDs
redigidas não geravam JSON nenhum por isso.
Fica no ponto de saída, não no campo: qualquer data nova num bloco copiado
passa a funcionar sem mexer no exportador. E SÓ data — um `default=str`
genérico viraria texto silencioso qualquer objeto que não devia estar no
payload, exatamente a divergência silenciosa que este script existe para
impedir.
Usa str() e não .isoformat() de propósito: para date os dois são idênticos,
e str() reproduz byte a byte o contorno já validado contra o script oficial.
"""
if isinstance(o, date): # datetime é subclasse de date; ambos caem aqui
return str(o)
raise TypeError(
f"{type(o).__name__} não é serializável em JSON (valor: {o!r}). "
"Se for campo derivado, passe por como_iso(); se for bloco copiado do "
"frontmatter, o tipo não deveria estar lá.")
def serializar(payload: dict) -> str:
"""Texto exato do arquivo .json. Ponto único, para que a CLI e a aplicação
web produzam os MESMOS BYTES — se divergirem, deixaram de ser a mesma régua.
"""
return json.dumps(payload, ensure_ascii=False, indent=2, default=_json_default) + "\n"
def bloqueador_como_texto(b) -> str:
"""Achata um bloqueador do frontmatter para a string que o payload carrega.
O sistema de destino recebe `_governanca.bloqueadores` como lista de TEXTOS.
No SD.md a forma estruturada (o_que/dono/aberto_em/status/destrava/nota)
continua sendo a preferida, e é o exportador que resolve a diferença.
A régua: `o_que` INTEIRO + " — " + `dono` truncado no primeiro " — ".
· o_que nunca é truncado: "Gate 3 — perímetro LGPD..." tem o conteúdo
todo depois do travessão — cortar ali viraria "Gate 3", que não diz nada.
· dono é truncado porque o sufixo dele é anotação de trabalho, rastreável
no SD.md; no payload interessa QUEM destrava.
Bloqueador `resolvido` também é exportado: filtrar seria decisão de negócio
que o exportador não toma sozinho.
"""
if not isinstance(b, dict):
return " ".join(str(b).split())
o_que = " ".join(str(b.get("o_que") or "").split())
dono = " ".join(str(b.get("dono") or "").split())
dono = dono.split(" — ", 1)[0].strip()
return f"{o_que} — {dono}" if dono else o_que
def _texto(v) -> str:
return " ".join(str(v).split()) if v is not None else ""
# ---------------------------------------------------------------------------
# Montagem
# ---------------------------------------------------------------------------
def analisar(sd: dict, corpo: str, canon: Canonico) -> tuple[dict, list[Pendencia]]:
"""(prévia, pendências) numa passada só — para a tela.
A prévia é o payload como ele ESTÁ, com os buracos que as pendências
apontam (linha None, ust None, po None). Serve para mostrar o que a
exportação vai conter; NUNCA para serializar — quem exporta chama montar().
"""
pend: list[Pendencia] = []
payload = _montar(sd, corpo, canon, pend)
return payload, pend
def conferir(sd: dict, corpo: str, canon: Canonico) -> list[Pendencia]:
"""Só as pendências, sem levantar."""
return analisar(sd, corpo, canon)[1]
def montar(sd: dict, corpo: str, canon: Canonico) -> dict:
"""O payload completo — ou ExportacaoReprovada com as pendências.
Uma passada só: a mesma função que monta é a que confere, e por isso não
existe caminho em que o JSON sai com um campo que a conferência reprovaria.
"""
pend: list[Pendencia] = []
payload = _montar(sd, corpo, canon, pend)
if pend:
raise ExportacaoReprovada(pend)
return payload
def _montar(sd: dict, corpo: str, canon: Canonico, pend: list[Pendencia]) -> dict:
itens_canon: dict = canon.itens
itens_validos = sorted(itens_canon.get("itens") or {})
itens_importaveis = [i for i in itens_validos if i not in ITENS_FORA_DA_CARGA]
def falta_sd(campo, msg, **kw):
pend.append(Pendencia("sd", campo, msg, **kw))
# -- identificação ------------------------------------------------------
sd_id = _texto(sd.get("sd"))
m = _SD_ID.match(sd_id)
if m:
proj, num = m.group("proj"), m.group("num")
else:
proj, num = "", ""
falta_sd("sd", f"identificador {sd_id!r} fora da notação `P{{n}}·SD{{n}}` (ex.: P2·SD8) — "
"dele saem sd.numero_sequencial e sd.projeto.codigo")
titulo = _texto(sd.get("titulo"))
if not titulo:
falta_sd("titulo", "a SD precisa de título (sd.nome é obrigatório na carga)")
po = _texto(sd.get("po_responsavel"))
if not po:
falta_sd("po_responsavel", "PO responsável em branco — a carga exige um nome que exista "
"no cadastro de POs do sistema")
elif _PENDENTE.match(po):
falta_sd("po_responsavel", f"PO responsável {po!r} é a convenção do repositório para "
"\"não sei\", não um nome do cadastro de POs")
versao = _texto(sd.get("versao"))
if not _VERSAO.match(versao):
falta_sd("versao", f"versão {versao!r} fora do formato `V{{n}}` (ex.: V1) — "
"_governanca.versao_documento é obrigatório")
estado = sd.get("estado")
if estado not in ESTADO_SD_PARA_STATUS_SD:
falta_sd("estado", f"estado {estado!r} desconhecido — não dá para derivar sd.status nem o "
"status dos entregáveis", opcoes=list(ESTADOS_SD))
status_ent = ESTADO_SD_PARA_ENTREGAVEL.get(estado)
status_sd = ESTADO_SD_PARA_STATUS_SD.get(estado)
data_abertura = None
if sd.get("data_abertura") not in (None, ""):
try:
data_abertura = como_iso(como_data(sd.get("data_abertura")))
except ValueError:
falta_sd("data_abertura", f"data de abertura {sd.get('data_abertura')!r} ilegível — "
"formato AAAA-MM-DD")
# -- entregáveis ----------------------------------------------------------
lista = sd.get("entregaveis")
if not isinstance(lista, list) or not lista:
falta_sd("entregaveis", "a SD não tem entregáveis — a carga exige pelo menos um. "
"Corrija no SD.md e envie de novo.")
lista = []
entregaveis, seq = [], {}
itens_da_sd: set = set()
datas_ini, datas_fim = [], []
ns_vistos: dict = {}
for i, e in enumerate(lista):
n = e.get("n") if isinstance(e, dict) else None
def falta(campo, msg, **kw):
pend.append(Pendencia("entregavel", campo, msg, indice=i, n=n, **kw))
if not isinstance(e, dict):
falta("n", f"entrada #{i + 1} de `entregaveis` não é um mapa — é {type(e).__name__}. "
"Corrija no SD.md e envie de novo.")
continue
if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
falta("n", f"`n` {n!r} precisa ser inteiro maior que zero")
elif n in ns_vistos:
falta("n", f"`n` {n} repetido — já é o entregável #{ns_vistos[n] + 1}; "
"`n` é único no arquivo")
else:
ns_vistos[n] = i
nome = _texto(e.get("nome"))
if not nome:
falta("nome", "entregável sem nome (titulo é obrigatório na carga)")
tipo, item = e.get("tipo"), e.get("item")
tipo_ok = tipo in TIPOS_IMPORTAVEIS
if not tipo_ok:
if tipo in TIPOS_QUE_PARAM_A_CARGA:
falta("tipo", f"tipo {tipo!r} não entra por esta carga (Regra 11) — entregável "
"desse tipo é cadastrado pela tela do sistema",
opcoes=list(TIPOS_IMPORTAVEIS))
else:
falta("tipo", f"tipo {tipo!r} inválido", opcoes=list(TIPOS_IMPORTAVEIS))
item_ok = item in itens_importaveis
if not item_ok:
if item in itens_validos:
falta("item", f"item {item} não gera UST (licença) e não entra por esta carga",
opcoes=itens_importaveis)
else:
falta("item", f"item {item!r} não existe no contrato canônico",
opcoes=itens_importaveis)
# -- datas: as que mandam ------------------------------------------------
a = b = None
for campo in ("inicio", "fim"):
bruto = e.get(campo)
if bruto in (None, ""):
falta(campo, f"`{campo}` ausente — data_inicio e data_prevista_termino são "
"obrigatórias, e delas saem semanas, UST e valor")
continue
try:
d = como_data(bruto)
except ValueError:
falta(campo, f"`{campo}` {bruto!r} ilegível — formato AAAA-MM-DD, dia real")
continue
if campo == "inicio":
a = d
else:
b = d
semanas = None
if a and b:
dias = janela_dias(a, b)
if dias < 0:
falta("fim", f"fim {b} anterior ao início {a}")
elif dias > JANELA_MAXIMA_DIAS:
falta("fim", f"janela de {dias} dias ({a} → {b}) passa do teto de "
f"{JANELA_MAXIMA_DIAS} dias da carga — quebre em mais de um "
"entregável no SD.md, ou encurte a janela")
else:
semanas = semanas_por_datas(a, b)
declarado = e.get("semanas")
if declarado not in (None, "") and declarado != semanas:
falta("semanas", f"{dias} dias ({a} → {b}) dão {semanas} semana(s) pela regra "
f"da carga, mas o SD declara {declarado} — UST e valor não "
"fechariam na importação. Ajuste as datas ou as semanas.",
derivado=semanas)
# UST não se declara: o sistema a deriva de datas, item e tipo na carga.
# Valor digitado aqui só pode divergir da conta, sem ninguém notar.
if e.get("ust") not in (None, ""):
falta("ust", f"o SD declara {e.get('ust')} UST, mas UST é derivada pelo sistema "
"na carga (time-box × semanas das datas). Apague a declaração.")
# -- linha de OS -----------------------------------------------------------
# Declarada no SD.md por quem emite a SD, e só o formato é conferido
# aqui. OS e linha são cadastro do sistema de gestão e mudam durante o
# ano; quem sabe se existem é o banco, na carga. Não há de-para local.
linha = None
queixa = conferir_formato_linha_os(e.get("linha_os"))
if queixa:
falta("linha_os", queixa)
else:
linha = str(e.get("linha_os")).strip()
# -- listas filhas -----------------------------------------------------------
for campo in ("atividades", "criterios_aceite"):
for k, x in enumerate(e.get(campo) or [], 1):
if not _texto(x):
falta(campo, f"item {k} de `{campo}` está vazio — descricao é obrigatória")
artefatos = []
nomes_vistos = set()
for k, art in enumerate(e.get("artefatos") or [], 1):
if not isinstance(art, dict):
art = {"nome": art}
nome_art = _texto(art.get("nome"))
if not nome_art:
falta("artefatos", f"artefato {k} sem nome — `documentacao[].nome` é obrigatório")
continue
if nome_art in nomes_vistos:
falta("artefatos", f"artefato {nome_art!r} repetido — o nome é a chave que a "
"reimportação usa para reconhecer documento já entregue")
nomes_vistos.add(nome_art)
data_art = None
if art.get("data") not in (None, ""):
try:
data_art = como_iso(como_data(art.get("data")))
except ValueError:
falta("artefatos", f"artefato {nome_art!r}: data {art.get('data')!r} ilegível")
artefatos.append({"nome": nome_art, "data": data_art, "status": "PREVISTO"})
alocacoes = None
if e.get("alocacoes_desvio"):
alocacoes = []
for k, al in enumerate(e["alocacoes_desvio"], 1):
if not isinstance(al, dict):
falta("alocacoes_desvio", f"alocação {k} não é um mapa perfil/quantidade/percentual")
continue
perfil = perfil_do_cadastro(al.get("perfil"))
if perfil not in PERFIS_DO_CADASTRO:
falta("alocacoes_desvio", f"perfil {al.get('perfil')!r} não existe no cadastro do "
f"cliente (válidos: {', '.join(PERFIS_DO_CADASTRO)})")
qtd = al.get("quantidade", 1)
if not isinstance(qtd, int) or qtd <= 0:
falta("alocacoes_desvio", f"alocação {k} ({perfil}): quantidade {qtd!r} precisa "
"ser inteiro > 0")
pct = al.get("percentual_alocacao", al.get("percentual"))
if not isinstance(pct, (int, float)) or not 0 <= pct <= 100:
falta("alocacoes_desvio", f"alocação {k} ({perfil}): percentual {pct!r} fora de 0..100")
alocacoes.append({"perfil": perfil, "quantidade": qtd, "percentual_alocacao": pct})
# -- montagem do entregável ------------------------------------------------
if not (tipo_ok and item_ok):
continue # sem tipo/item não há código, time-box nem tarifa para montar
chave = (item, tipo)
seq[chave] = seq.get(chave, 0) + 1
codigo = f"{proj}-SD{num}-{item.replace('-', '')}-{ABREV_TIPO[tipo]}-{seq[chave]}"
ini, fim = como_iso(a), como_iso(b)
if ini:
datas_ini.append(ini)
if fim:
datas_fim.append(fim)
ent = {
"n": n,
"codigo": codigo,
"titulo": nome or None,
"tipo_entrega": tipo,
"item": item,
"ordem_servico": {"cadeia": None, "linha": linha},
"data_inicio": ini,
"data_prevista_termino": fim,
"numero_semanas": semanas,
"status": status_ent,
# Derivados pelo importador a partir de datas, item e tipo — ver
# docstring do módulo. As chaves ficam; o valor é null de propósito.
"horas_semanais": None,
"ust": None,
"valor_unitario_ust": None,
"valor_entregavel": None,
"memoria_calculo": None,
"alocacoes": alocacoes if alocacoes is not None else alocacoes_do_tipo(itens_canon, tipo),
"backlog": [{"ordem": k, "descricao": _texto(x), "status": "Pendente"}
for k, x in enumerate(e.get("atividades") or [], 1)],
"criterios_aceite": [{"ordem": k, "descricao": _texto(c), "status": "Pendente"}
for k, c in enumerate(e.get("criterios_aceite") or [], 1)],
"documentacao": artefatos,
}
if e.get("resumo"):
ent["descricao"] = _texto(e["resumo"])
if e.get("paralelo_com"):
ent["paralelo_com"] = e["paralelo_com"]
if e.get("sprint"):
ent["sprint"] = e["sprint"]
srv = {"codigo": e.get("servico")} if e.get("servico") else None
if srv:
srv["nome_interno"] = e.get("nome")
if e.get("discriminador"):
srv["discriminador"] = e["discriminador"]
if e.get("justificativa_enquadramento"):
srv["justificativa_enquadramento"] = _texto(e["justificativa_enquadramento"])
ent["_servico"] = srv
entregaveis.append(ent)
itens_da_sd.add(item)
inicio = min(datas_ini) if datas_ini else None
fim_sd = max(datas_fim) if datas_fim else None
prazo = None
if inicio and fim_sd:
# NÃO use semanas_por_datas() aqui. Isto é duração de CALENDÁRIO da SD
# inteira, decimal de propósito — grandeza diferente das semanas
# faturáveis do entregável. Com paralelismo os dois divergem muito: a
# P2·SD8 tem 26 semanas de time-box em 4,6 de calendário, e o 4,6 está
# citado nominalmente no schema (totais.prazo_calendario_semanas).
# Trocar round(...,1) por max(1, ceil(...)) viraria 5 e quebraria esse
# contrato. Já houve quem tentasse unificar as duas fórmulas.
prazo = round((date.fromisoformat(fim_sd) - date.fromisoformat(inicio)).days / 7, 1)
projeto_txt = str(sd.get("projeto") or "")
proj_nome = projeto_txt.split("·", 1)[1].strip() if "·" in projeto_txt else projeto_txt
# -- corpo ------------------------------------------------------------------
objetivo = secao(corpo, "1")
if not objetivo:
pend.append(Pendencia("corpo", "objetivo",
"a Seção 1 (Objetivo) do SD.md está vazia ou não existe — "
"sd.objetivo é obrigatório na carga (coluna NOT NULL)"))
contexto = secao(corpo, "2")
enquadr = secao(corpo, "5")
fora = secao(corpo, "6")
bloco_sd = {
"codigo": sd_id,
"numero_sequencial": int(num) if num else None,
"rotulo_exibicao": f"SD{num} · {proj} · {titulo}",
"nome": titulo or None,
"projeto": {"codigo": proj, "nome": proj_nome},
"po_responsavel": {"nome": po or None},
"itens_contratuais": sorted(itens_da_sd),
"inicio": inicio,
"fim": fim_sd,
"status": status_sd,
}
if sd.get("subtitulo"):
bloco_sd["subtitulo"] = sd["subtitulo"]
if sd.get("frente"):
f = str(sd["frente"])
cod, _, nome_f = f.partition("·")
bloco_sd["frente"] = {"codigo": cod.strip(), "nome": nome_f.strip() or cod.strip()}
bloco_sd["objetivo"] = objetivo
if contexto:
# Só o conteúdo original da seção 2 — sem espelhar o Enquadramento no TR.
# O Enquadramento vai apenas na forma estruturada, em
# _governanca.enquadramento_tr.
bloco_sd["contexto"] = contexto
if fora:
bloco_sd["escopo_nao_contemplado"] = fora
if sd.get("resumo_executivo"):
bloco_sd["resumo_executivo"] = _texto(sd["resumo_executivo"])
formal = sd.get("formalizacao") or {}
if not isinstance(formal, dict):
formal = {}
sei = formal.get("processo_sei")
if isinstance(sei, str) and not re.search(r"\d", sei):
sei = None # "PENDENTE — não consta" não é número de processo
gov = {
"estado_sd": estado,
"versao_documento": versao or None,
"data_abertura": data_abertura,
"formalizacao": {"processo_sei": sei, "os_mae": formal.get("os_mae") or {}},
}
# `enquadr` é o recorte que secao() já fez acima. Emitido só quando há
# tabela: a P2·SD10 tem a seção vazia e a P4·SD20 a escreve em prosa livre,
# e nas duas a chave simplesmente não vai.
try:
blocos_tr = enquadramento_tr(enquadr, itens_canon["itens"])
except ValueError as exc:
blocos_tr = []
pend.append(Pendencia("corpo", "secao5", f"{exc} Corrija a Seção 5 no SD.md e envie de novo."))
if blocos_tr:
gov["enquadramento_tr"] = blocos_tr
dep = sd.get("dependencias") or {}
queixa_dep = checar_dependencias(dep) if dep else None
if queixa_dep:
pend.append(Pendencia("sd", "dependencias", queixa_dep + " Corrija no SD.md e envie de novo."))
dep = {}
if dep:
gov["dependencias"] = {k: dep.get(k) or [] for k in ("ses_mg", "isis", "terceiros")}
if sd.get("bloqueadores"):
gov["bloqueadores"] = [bloqueador_como_texto(b) for b in sd["bloqueadores"]]
if sd.get("aceites_documento"):
gov["aceites_documento"] = sd["aceites_documento"]
# `totais` é só sanidade para o importador (§9), e a UST agora é dele. As
# chaves ficam pela estrutura; `valor_total`, que já era condicional, não vai.
totais = {
"ust_total": None,
"ust_por_item": {},
"prazo_calendario_semanas": prazo,
"moeda": "BRL",
}
if prazo is None:
# Removido DEPOIS de montado, e não montado condicionalmente, para não
# mexer na ordem das chaves: `moeda` passaria à frente de
# `prazo_calendario_semanas` e os JSONs mudariam de bytes.
del totais["prazo_calendario_semanas"]
return {"$schema": "../_template/sd-schema.json", "sd": bloco_sd,
"entregaveis": entregaveis, "totais": totais, "_governanca": gov}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
# O resumo usa '→' (U+2192), que não existe em cp1252. Com stdout
# redirecionado o Python cai no cp1252 do locale e o print estoura DEPOIS
# de o JSON já estar em disco. errors="replace" garante que relatar nunca
# derruba o que já funcionou.
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser(
description="Exporta SD.md para o JSON de carga do sistema (CONTRATO-JSON-V2). "
"SD com pendência não gera arquivo: as pendências saem em stderr.")
ap.add_argument("caminhos", nargs="+")
ap.add_argument("--dir", help="diretório de saída (padrão: ao lado do SD.md)")
args = ap.parse_args()
canon = carregar_canonico()
# sorted(): a ordem do glob é a do filesystem, e o relatório de lote ficava
# irreproduzível entre execuções. Mesmo critério de caminhos.sds_reais().
arquivos = sorted(Path(p) for c in args.caminhos for p in (glob.glob(c) or [c]))
falhas = []
for f in arquivos:
try:
sd, corpo = ler_sd_arquivo(f)
rotulo = str(sd.get("sd") or f)
payload = montar(sd, corpo, canon)
# Serializa numa variável ANTES de escrever: se o payload não
# serializa, o arquivo anterior fica intacto em vez de truncado.
texto = serializar(payload)
destino = Path(args.dir) if args.dir else f.parent
destino.mkdir(parents=True, exist_ok=True)
saida = destino / f"{str(sd.get('sd', 'sd')).replace('·', '-')}.json"
# newline="\n": sem isso o Windows traduz para CRLF e o MESMO
# serializar() deixa de produzir os mesmos bytes em disco.
saida.write_text(texto, encoding="utf-8", newline="\n")
except ExportacaoReprovada as exc:
print(f" PENDENTE {f} — {len(exc.pendencias)} pendência(s), nenhum JSON gerado:",
file=sys.stderr)
print(str(exc), file=sys.stderr)
falhas.append(f)
continue
except Exception as exc:
print(f" ERRO {f}: {exc}", file=sys.stderr)
falhas.append(f)
continue
print(f" {rotulo} → {saida} {len(payload['entregaveis'])} entregável(is) "
f"({', '.join(payload['sd']['itens_contratuais'])})")
if falhas:
# Isolar sem nomear é pior que o estouro que substitui: o lote
# terminaria "com sucesso" faltando SDs.
print(f"\n {len(falhas)} de {len(arquivos)} SD(s) sem JSON:", file=sys.stderr)
for f in falhas:
print(f" · {f}", file=sys.stderr)
return 1 if falhas else 0
if __name__ == "__main__":
sys.exit(main())
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env python3
"""
regras_sd.py — leitura do SD.md, cálculo e dicionários do contrato que o exportador usa.
Por que existe: as mesmas cinco regras estavam escritas duas vezes, uma em cada
script, e já tinham começado a divergir. A pior era a localização da Seção 5 —
o validador perguntava `"## 5." not in corpo` e o exportador casava o regex
`^##\\s+5\\.\\s+`. Um corpo com `## 5.Enquadramento` passava na validação e fazia
o exportador dropar a seção em silêncio: duas respostas para a mesma pergunta,
e a que some é justamente a seção que sustenta a SD contra reclassificação.
Regra deste módulo: aqui mora o CÁLCULO e a LEITURA. Quem decide a severidade
(ERRO, AVISO, exceção) é o chamador — o validador reporta, o exportador levanta,
e a aplicação web mostra na tela. Misturar as duas coisas foi o que produziu
mensagens divergentes para a mesma regra.
"""
from __future__ import annotations
import math
import re
import sys
from datetime import date
from pathlib import Path
from typing import NamedTuple
try:
import yaml
except ImportError:
sys.exit("Falta pyyaml. pip install pyyaml")
from caminhos import ITENS as ITENS_YAML
# ---------------------------------------------------------------------------
# Canônico
# ---------------------------------------------------------------------------
class Canonico(NamedTuple):
"""O YAML de contrato, carregado uma vez.
`itens` dá a lista de itens do contrato e a matriz de perfis do TR
4.1.1.7.1, que alimenta `alocacoes`. É o ÚNICO arquivo de dados de que o
exportador depende. Time-box e tarifa saíram daqui em 2026-09-03: são
tabelas do sistema de gestão, e é ele que deriva UST e valor na carga.
O que NÃO está aqui, de propósito: OS e linhas de OS. Elas são cadastro do
sistema de gestão e mudam durante o ano; qualquer cópia local envelhece em
silêncio. O exportador exige `linha_os` declarado no SD.md e só confere o
formato — existência, item e status quem confere é o banco, na carga.
"""
itens: dict
def carregar_canonico() -> Canonico:
if not ITENS_YAML.exists():
raise FileNotFoundError(f"Arquivo canônico ausente: {ITENS_YAML}")
itens = yaml.safe_load(ITENS_YAML.read_text(encoding="utf-8"))
return Canonico(itens)
# ---------------------------------------------------------------------------
# Leitura do SD.md
# ---------------------------------------------------------------------------
def ler_sd_texto(texto: str, *, preservar_comentarios: bool = False) -> tuple[dict, str]:
"""Separa o frontmatter YAML da prosa. Um parser só, para os dois scripts.
Devolve (frontmatter, corpo). O frontmatter contém APENAS chaves do
frontmatter: nada de `_corpo` injetado dentro dele. A versão antiga do
validador fazia essa injeção, e ela era inofensiva enquanto o mapa só era
lido. Deixou de ser: a aplicação web regrava esse mesmo objeto no disco, e
um `_corpo` ali dentro escreveria o markdown inteiro como escalar YAML
dentro do frontmatter — corrupção silenciosa de um SD.md do repositório.
`preservar_comentarios=True` devolve um CommentedMap do ruamel em vez de um
dict comum. É subclasse de dict, então validar e exportar funcionam sem
saber a diferença; o que muda é que o round-trip de escrita preserva
comentários e ordem. Os comentários do frontmatter carregam decisão de
contrato — ver o campo `estado` da P2·SD10, quatro linhas de correção
datada que um safe_dump apagaria sem deixar rastro.
"""
linhas = texto.splitlines()
# Split por LINHA exatamente igual a "---", nunca por substring: comentários
# de separação (# ------) dentro do frontmatter contêm "---" e truncariam o
# bloco silenciosamente, produzindo "a SD não tem entregáveis".
if not linhas or linhas[0].strip() != "---":
raise ValueError("SD sem frontmatter YAML — a primeira linha deve ser '---'")
fim = next((i for i, l in enumerate(linhas[1:], start=1) if l.strip() == "---"), None)
if fim is None:
raise ValueError("frontmatter YAML não fechado — falta a linha '---' de fecho")
bruto = "\n".join(linhas[1:fim])
corpo = "\n".join(linhas[fim + 1:])
if preservar_comentarios:
from ruamel.yaml import YAML
dados = YAML().load(bruto)
else:
dados = yaml.safe_load(bruto)
if not isinstance(dados, dict):
raise ValueError("frontmatter não é um mapeamento YAML")
return dados, corpo
def ler_sd_arquivo(caminho: Path, *, preservar_comentarios: bool = False) -> tuple[dict, str]:
return ler_sd_texto(Path(caminho).read_text(encoding="utf-8"),
preservar_comentarios=preservar_comentarios)
def secao(corpo: str, numero: str) -> str | None:
"""Extrai uma seção '## N. Título' do corpo, sem o cabeçalho.
Única resposta para "esta seção existe?" e "qual é o texto dela?". O
validador pergunta a primeira, o exportador a segunda, e antes deste módulo
cada um usava um critério diferente.
"""
pad = re.compile(rf"^##\s+{re.escape(numero)}\.\s+.*$", re.M)
m = pad.search(corpo)
if not m:
return None
resto = corpo[m.end():]
prox = re.search(r"^##\s+\d", resto, re.M)
texto = (resto[:prox.start()] if prox else resto).strip()
return texto or None
# Títulos das seções do corpo, como o _template/SD.md os escreve. Usados só
# quando definir_secao() precisa CRIAR a seção — uma existente mantém o título
# que tem.
TITULOS_SECAO = {
"1": "Objetivo",
"2": "Contexto e escopo da demanda",
"3": "Quadro de entregáveis",
"4": "Detalhamento dos entregáveis",
"5": "Enquadramento no Termo de Referência",
"6": "Fora de escopo",
}
def definir_secao(corpo: str, numero: str, texto: str) -> str:
"""O inverso de secao(): devolve o corpo com a seção N valendo `texto`.
Se a seção existe, só o conteúdo entre o cabeçalho dela e o próximo `## N`
muda — o cabeçalho fica como estava. Se não existe (a P2·SD10 não tem
Seção 1: vai do título direto à 5), o cabeçalho é criado antes da primeira
seção de número maior, ou no fim do corpo quando não há nenhuma. O mesmo
regex de secao() localiza o cabeçalho: duas respostas para "onde fica a
Seção N" é exatamente o defeito que este módulo existe para não repetir.
"""
corpo = corpo or ""
texto = (texto or "").strip()
pad = re.compile(rf"^##\s+{re.escape(numero)}\.\s+.*$", re.M)
m = pad.search(corpo)
if m:
resto = corpo[m.end():]
prox = re.search(r"^##\s+\d", resto, re.M)
fim = m.end() + (prox.start() if prox else len(resto))
return corpo[:m.end()] + "\n\n" + texto + "\n\n" + corpo[fim:].lstrip("\n")
cabecalho = f"## {numero}. {TITULOS_SECAO.get(numero, '')}".rstrip()
bloco = f"{cabecalho}\n\n{texto}\n\n"
# Antes da primeira seção de número MAIOR — mantém a ordem 1..6 do template.
for outro in re.finditer(r"^##\s+(\d+)\.\s+.*$", corpo, re.M):
if int(outro.group(1)) > int(numero):
ini = outro.start()
return corpo[:ini].rstrip("\n") + "\n\n" + bloco + corpo[ini:]
return corpo.rstrip("\n") + "\n\n" + bloco
# Rótulos da tabela da Seção 5, exatamente como o _template/SD.md os escreve.
_ROTULO_TR = re.compile(
r"^\|\s*\*\*(Item do TR|Descrição do item|Aderência desta SD)\*\*\s*\|(.*?)\|?\s*$")
_CODIGO_ITEM = re.compile(r"\bI-\d{2}\b")
_BULLET = re.compile(r"^\s{0,3}[-*]\s+(.*)$")
_QUEBRA_HTML = re.compile(r"<br\s*/?>", re.I)
# Marcador de lista no INÍCIO do texto: '•', ou '-'/'*' seguido de espaço.
_MARCADOR = re.compile(r"^(?:•|[-*](?=\s))\s*")
def _texto_de_celula(v: str) -> str:
"""Uma linha só, sem o marcador de bullet que a célula às vezes traz.
O '*' só conta como marcador quando vem seguido de espaço — nunca o '**' de
negrito. Um lstrip('•*- ') ingênuo comia a abertura do negrito e deixava o
fecho órfão: os cinco bullets da P5·SD24 começam com '**Análise das
Necessidades...**' e saíam como 'Análise das Necessidades...**'. Texto
remendado é pior que texto ausente, porque parece certo.
"""
return _MARCADOR.sub("", " ".join(str(v).split())).strip()
def _bullets_da_prosa(linhas: list[str]) -> list[str]:
"""Bullets markdown de um bloco, com a indentação pendurada recolada.
Os bullets da Seção 5 quebram em várias linhas com recuo de dois espaços
(ver a P7·SD31). Sem colar a continuação, cada bullet chegava truncado na
primeira quebra de linha — e truncado no meio de uma frase de enquadramento
é pior que ausente, porque parece completo.
"""
saida: list[str] = []
atual: str | None = None
for l in linhas:
m = _BULLET.match(l)
if m:
if atual is not None:
saida.append(_texto_de_celula(atual))
atual = m.group(1)
elif atual is not None:
if l.strip() and l[:1] in (" ", "\t"):
atual += " " + l.strip()
else:
# Linha em branco ou parágrafo à margem fecha a lista: em SD31 e
# SD24 o que vem depois dos bullets é prosa de fecho ("Nenhum
# entregável foge do item padrão..."), não aderência.
saida.append(_texto_de_celula(atual))
atual = None
if atual is not None:
saida.append(_texto_de_celula(atual))
return [b for b in saida if b]
def enquadramento_tr(texto: str | None, itens_canon: dict) -> list[dict]:
"""A tabela da Seção 5, como dado. Uma entrada por item que a SD toca.
Recebe o texto JÁ recortado por secao(corpo, "5"), nunca o corpo inteiro:
quem responde "onde fica a Seção 5?" é secao(), e um segundo ponto fazendo a
mesma pergunta é exatamente como nasceu a divergência descrita na docstring
deste módulo.
Esta é a ÚNICA saída da Seção 5. Houve uma fase em que ela também subia como
texto cru colado em `sd.contexto` — o conteúdo chegava, o dado não; esse
espelho foi removido, e `sd.contexto` hoje recebe só a Seção 2. O schema já
reservava `_governanca.enquadramento_tr` para a forma estruturada desde a
v2.0.
O acervo escreve a aderência de duas maneiras, e as duas são aceitas:
· bullets '•' dentro da própria célula, separados por <br> (P2·SD8);
· a célula como ponteiro ("ver bullets abaixo") e os bullets em prosa
logo depois da tabela (P7·SD31, P7·SD32 e as demais).
Não julga o TAMANHO da lista. Quem reprova aderência fora de 3..5 é o
schema, e o número real precisa aparecer no JSON para o defeito ser visível:
a P7·SD32 tem um bloco com 2 bullets, e emitir 2 é o que faz alguém escrever
o terceiro. Bloco sem aderência nenhuma sai com lista vazia, de propósito —
omitir a entrada seria dropar em silêncio a seção que sustenta a SD.
"""
if not texto:
return []
linhas = texto.splitlines()
aberturas = [i for i, l in enumerate(linhas)
if (m := _ROTULO_TR.match(l.strip())) and m.group(1) == "Item do TR"]
if not aberturas:
# Seção 5 sem a tabela: a P4·SD20 é prosa livre com `###`, e tem duas
# tabelas de OUTRO assunto que o recorte de secao() traz junto. Ancorar
# em "Item do TR" — e não em "linha de tabela" — é o que as ignora.
return []
saida = []
for ini, fim in zip(aberturas, aberturas[1:] + [len(linhas)]):
bloco = linhas[ini:fim]
campos: dict[str, str] = {}
for l in bloco:
m = _ROTULO_TR.match(l.strip())
if m and m.group(1) not in campos:
campos[m.group(1)] = " ".join(m.group(2).split())
cod = _CODIGO_ITEM.search(campos.get("Item do TR", ""))
if not cod:
continue
item = cod.group(0)
if item not in itens_canon:
raise ValueError(
f"Seção 5: item {item!r} na linha `Item do TR` não existe no contrato "
f"canônico (válidos: {', '.join(sorted(itens_canon))}). "
"Ver clientes/ses-mg/contrato/itens.yaml")
entrada = {
"item": item,
# Derivado, nunca transcrito: a célula funde código e título, e o
# acervo tem três formatos para ela — com aspas, sem aspas, e com
# negrito mais CATMAS pelo meio. O título literal mora no canônico,
# e o próprio schema diz "LITERAL de contrato/itens.yaml".
"titulo_literal": itens_canon[item]["titulo_literal"],
}
if campos.get("Descrição do item"):
entrada["descricao_item"] = campos["Descrição do item"]
celula = campos.get("Aderência desta SD", "")
partes = [_texto_de_celula(p) for p in _QUEBRA_HTML.split(celula)]
partes = [p for p in partes if p]
if len(partes) >= 2:
ader = partes # bullets dentro da célula
else:
ader = _bullets_da_prosa(bloco) or partes
entrada["aderencia"] = ader
saida.append(entrada)
return saida
# ---------------------------------------------------------------------------
# Conversões
# ---------------------------------------------------------------------------
def como_iso(v) -> str | None:
if v is None:
return None
if isinstance(v, date):
return v.isoformat()
return str(v)
def como_data(v) -> date | None:
"""Converte para date, ou levanta ValueError. Quem reporta é o chamador.
Sem efeito colateral de propósito: a versão antiga do validador escrevia o
ERRO no relatório de dentro da conversão, o que a tornava inutilizável fora
dele. O exportador precisava da mesma conversão e acabou com a sua própria.
"""
if v is None:
return None
if isinstance(v, date):
return v
return date.fromisoformat(str(v))
# ---------------------------------------------------------------------------
# Regras derivadas
# ---------------------------------------------------------------------------
def semanas_por_datas(inicio: date, fim: date) -> int:
"""Semanas faturáveis entre duas datas — a MESMA regra do sistema de destino.
max(1, ceil(dias/7)), com `dias` exclusivo (subtração de datas, como a
diferença de getTime() no sistema): 0-7d → 1, 8-14d → 2, 15-21d → 3,
22-28d → 4.
NÃO serve para prazo_calendario_semanas: aquele é duração de CALENDÁRIO,
decimal de propósito — a P2·SD8 tem 26 semanas de time-box em 4,6 de
calendário, valor que o schema cita nominalmente. Grandezas diferentes,
fórmulas diferentes, de caso pensado. Já houve quem tentasse unificar as
duas; ver o comentário no cálculo do prazo em exporta_sd.montar().
"""
return max(1, math.ceil((fim - inicio).days / 7))
def conferir_semanas(e: dict, ini: str | None, fim: str | None) -> tuple[str, str] | None:
"""Confere as semanas DECLARADAS contra as datas.
Devolve (severidade, mensagem) ou None se estiver coerente. Severidade
'DIVERGE' = erro comprovado (declarado ≠ calculado); 'SEM-DATA' = não deu
para conferir. Só a primeira reprova em --estrito.
`numero_semanas` é o único fator de duração declarado à mão — no schema não
tem x-derivado, é integer de 1 a 4. Só que o sistema de destino RECALCULA as
semanas a partir das datas na importação, por max(1, ceil(dias/7)). Quando
os dois discordam, a UST e o valor faturado do entregável divergem do que o
sistema vai calcular, e ninguém percebe: 4 dos 45 entregáveis datados
divergiam, com oscilação de -R$ 22 mil a +R$ 43 mil POR LINHA (o líquido de
+R$ 20 mil esconde isso, e faturamento é por linha, não por saldo).
Só CONFERE, nunca corrige, e é de propósito: semanas é dado declarado, e o
teto de 4 da R4 vem de documento formalizado (P2·SD12 V2). A P2·SD8 n8 tem
31 dias, que dariam 5 — derivar emitiria JSON fora do próprio schema
(numero_semanas.maximum = 4). Reconciliar é decisão humana, não do script.
Vive aqui, e não no exportador, porque a conferência precisa acontecer na
Fase 1: enquanto ela só rodava na exportação, a divergência aparecia DEPOIS
de o usuário já ter confirmado os dados.
"""
dec = e.get("semanas")
if not (ini and fim):
# Sem data não há o que conferir. Reportar como não verificável em vez
# de assumir 1: a P4·SD20 tem cinco entregáveis assim, e silenciar aqui
# carimbaria como conferido o que ninguém conferiu.
return ("SEM-DATA",
f"entregável {e.get('n')}: sem datas — semanas declaradas ({dec}) não verificáveis")
try:
a, b = date.fromisoformat(str(ini)), date.fromisoformat(str(fim))
except ValueError:
return ("SEM-DATA",
f"entregável {e.get('n')}: datas ilegíveis ({ini!r} → {fim!r}) — não verificáveis")
can = semanas_por_datas(a, b)
if can != dec:
return ("DIVERGE",
f"entregável {e.get('n')}: {(b - a).days} dias => {can} semanas pela regra do "
f"sistema, mas o SD declara {dec} — UST e valor vão divergir na importação")
return None
def checar_dependencias(dep) -> str | None:
"""`dependencias` precisa ser um mapa por dono. Devolve a queixa, ou None.
A P4·SD20 trazia uma lista solta aqui e derrubava o exportador com um
AttributeError sem dono. Uma mensagem só para os dois scripts: antes o
validador dizia uma coisa e o exportador outra sobre a mesma regra.
"""
if dep is None or isinstance(dep, dict):
return None
return (f"`dependencias` precisa ser um mapa com ses_mg/isis/terceiros, não "
f"{type(dep).__name__}. Sem a separação por dono não dá para dizer "
"quem tem poder de veto sobre o cronograma. "
"Ver clientes/ses-mg/projetos/_template/SD.md")
# ---------------------------------------------------------------------------
# Contrato da carga — CONTRATO-JSON-V2.md (`npm run import:sd`, epic 92)
# ---------------------------------------------------------------------------
# O que está abaixo é o que a carga do sistema de gestão exige do JSON, escrito
# uma vez para o exportador e para quem mais quiser conferir. Grafia EXATA, com
# acento e na caixa indicada: a carga não normaliza ("Em execucao" reprova por
# decisão, §6). Rótulo, e não código do banco — o parser do importador traduz
# "Em Execução" para EM_EXECUCAO; confirmado com a gestão em 2026-09-03.
STATUS_SD = ("Planejado", "Em andamento", "Em execução", "Entregue", "Aceito")
# Doze valores, no masculino desde a epic 82. "Planejado" NÃO vale para
# entregável — o esqueleto da §11 do contrato o usa por engano.
STATUS_ENTREGAVEL = (
"Rascunho", "Emitido", "Em Execução", "Documentado", "Aguardando Validação",
"Em Revisão", "Aprovado", "Glosado", "Encerrado", "Cancelado",
"Aguardando Pagamento", "Pago",
)
TIPOS_IMPORTAVEIS = ("Descoberta", "Design", "Arquitetura", "Construção")
# I-01 é a licença SaaS: preço fixo anual, sem UST. Não entra por esta carga
# (Regra 11 da epic 92) — está no itens.yaml para o exportador saber reprovar.
ITENS_FORA_DA_CARGA = ("I-01",)
# Traduzem, mas PARAM a carga na aritmética (Regra 11): não entram por JSON.
TIPOS_QUE_PARAM_A_CARGA = ("Manutenção", "Licença")
# Teto da janela de um entregável, em dias — 4 semanas. Regra da carga, não do
# sistema: janela maior tem de ser quebrada em mais de um entregável no SD.md.
JANELA_MAXIMA_DIAS = 28
# O repositório mantém o estado na SD; o sistema, no entregável — e vai além do
# aceite, até o pagamento. Estado desconhecido NÃO cai num default: o exportador
# reporta, com esta lista como opções.
ESTADO_SD_PARA_ENTREGAVEL = {
"rascunho": "Rascunho",
"planejada": "Rascunho",
"parada": "Rascunho",
"bloqueada": "Rascunho",
"emitida": "Emitido",
"em_execucao": "Em Execução",
"documentada": "Documentado",
"aguardando_validacao": "Aguardando Validação",
"validado": "Aprovado",
}
ESTADO_SD_PARA_STATUS_SD = {
"rascunho": "Planejado",
"planejada": "Planejado",
"parada": "Planejado",
"bloqueada": "Planejado",
"emitida": "Em andamento",
"em_execucao": "Em andamento",
"documentada": "Em andamento",
"aguardando_validacao": "Em andamento",
"validado": "Entregue", # decisão da gestão em 2026-09-03 — não "Concluído"
}
ESTADOS_SD = tuple(ESTADO_SD_PARA_STATUS_SD)
# Nomes de perfil como estão no CADASTRO do cliente no sistema de gestão. O
# itens.yaml escreve dois deles como o TR escreve; o cadastro, não. O importador
# traduz esses dois aliases (§4.1), mas emitir já o nome do cadastro tira a
# dependência de uma tradução alheia. itens.yaml fica como está — é transcrição
# do TR, e o TR é a fonte dele.
PERFIL_ALIASES = {
"Especialista de Inteligência (Cientistas dados/Processos)":
"Especialista de Inteligência (Cientista de Dados)",
"Especialista de infraestrutura": "Especialista de Infraestrutura",
}
PERFIS_DO_CADASTRO = (
"Especialista de Inteligência (Cientista de Dados)",
"Analista de Negócio/Processo",
"Especialista de Negócio (Saúde)",
"Especialista de Tecnologia / Arquiteto",
"Gerente de Projeto",
"Desenvolvedor / Engenheiro de Dados",
"Especialista de Infraestrutura",
"Scrum Master",
)
def perfil_do_cadastro(nome) -> str:
"""O nome do perfil como o cadastro do cliente o conhece."""
nome = " ".join(str(nome or "").split())
return PERFIL_ALIASES.get(nome, nome)
# Formato de `ordem_servico.linha` (CONTRATO-JSON-V2 §5): "{código-da-OS}-L{n}".
_LINHA_OS = re.compile(r"^\d+-L\d+$")
def conferir_formato_linha_os(linha) -> str | None:
"""A queixa contra o `linha_os` declarado, ou None.
Só FORMATO. Existência da OS, da linha, item e status são cadastro do
sistema de gestão e é ele que os confere na carga (resolução em dois passos
no banco, §5) — a carga recusa nomeando o que falta e nada é gravado. Não
há cópia local disso aqui de propósito: OS e linhas novas entram durante o
ano, e um de-para em arquivo apontaria linha velha sem ninguém notar.
Decisão da gestão em 2026-09-03.
"""
if linha in (None, ""):
return ("`linha_os` ausente — ordem_servico.linha é obrigatório na carga e só quem "
"emite a SD sabe qual OS a lastreia. Copie da tela da OS no sistema, no "
"formato {OS}-L{n} (ex.: 1090-L1)")
if not _LINHA_OS.match(str(linha).strip()):
return f"linha de OS {linha!r} fora do formato `{{OS}}-L{{n}}` (ex.: 1090-L1)"
return None
def janela_dias(inicio: date, fim: date) -> int:
"""Dias entre as datas, exclusivo — a mesma subtração que a carga faz."""
return (fim - inicio).days
+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/env python3
"""
teste_e2e.py — o fluxo inteiro, do upload ao download, sem servidor rodando.
Usa o test client do Flask: exercita exatamente as rotas de app/servidor.py,
sem porta aberta e sem depender de o servidor estar de pé.
O que esta suíte existe para provar, na ordem:
1. upload NUNCA baixa nada — o download só sai do /exportar, depois de um
clique, e só quando o exportador não tem pendência;
2. SD com pendência é recusada no servidor mesmo por POST direto, e a tela
mostra formulário SÓ com os campos pendentes;
3. a correção feita no formulário chega ao JSON — e o JSON é byte-idêntico
ao do exporta_sd.py na linha de comando sobre um SD.md com a mesma
correção;
4. campo não pendente não é tocado por um Salvar (o formulário só manda o
que está pendente, e o servidor só toca no que vem).
Roda assim:
python3 scripts/teste_e2e.py
0 = tudo passou; 1 = alguma checagem falhou
"""
from __future__ import annotations
import html as H
import json
import re
import subprocess
import sys
import tempfile
from io import BytesIO
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RAIZ / "scripts"))
sys.path.insert(0, str(RAIZ / "app"))
from caminhos import PROJETOS # noqa: E402
from regras_sd import ler_sd_texto, secao # noqa: E402
import servidor # noqa: E402
FALHAS: list[str] = []
CLIENTE = servidor.app.test_client()
SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md"
SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md"
SD20 = PROJETOS / "P4-cancer-de-mama/sds/SD20-siscan-d1/SD.md"
SD24 = PROJETOS / "P5-portal-rmds/sds/SD24-mvp-institucional/SD.md"
SD31 = PROJETOS / "P7-assistencia-farmaceutica/sds/SD31-fundacao-da-base-de-dispensacao/SD.md"
def ck(cond, desc):
print(f" {'ok ' if cond else 'FALHA'} {desc}")
if not cond:
FALHAS.append(desc)
def post(rota, dados=None, **kw):
return CLIENTE.post(rota, data=dados or {}, follow_redirects=True, **kw)
def texto(resp):
return resp.get_data(as_text=True)
def enviar(caminho: Path, nome: str | None = None, conteudo: bytes | None = None):
dados = {"arquivo": (BytesIO(conteudo if conteudo is not None else caminho.read_bytes()),
nome or caminho.name)}
return post("/upload", dados, content_type="multipart/form-data")
def pagina():
return texto(CLIENTE.get("/revisar", follow_redirects=True))
def reiniciar():
post("/reiniciar")
def campos_do_form(pag: str) -> dict:
"""Relê o formulário de pendências como o navegador o reenviaria."""
campos = {}
for m in re.finditer(r'<input[^>]*name="([^"]+)"[^>]*>', pag):
tag = m.group(0)
if any(t in tag for t in ('type="radio"', 'type="checkbox"', 'type="file"')):
continue
v = re.search(r'value="([^"]*)"', tag)
campos[m.group(1)] = H.unescape(v.group(1)) if v else ""
for m in re.finditer(r'<textarea[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', pag, re.S):
campos[m.group(1)] = H.unescape(m.group(2))
for m in re.finditer(r'<select[^>]*name="([^"]+)"[^>]*>(.*?)</select>', pag, re.S):
sel = re.search(r'<option value="([^"]*)"[^>]*selected', m.group(2))
campos[m.group(1)] = H.unescape(sel.group(1)) if sel else ""
return campos
def estado_na_tela(pag: str) -> str:
"""O estado que a tela DECLARA ("estado: <strong>…</strong>"). A trilha do
cabeçalho repete os nomes de todos os estados, então procurar o texto solto
daria positivo sempre."""
m = re.search(r"estado: <strong>([^<]+)</strong>", pag)
return m.group(1) if m else ""
def baixou(resp) -> bool:
return "attachment" in resp.headers.get("Content-Disposition", "")
def exportar_pela_cli(sd_md: Path) -> str:
with tempfile.TemporaryDirectory() as tmp:
r = subprocess.run([sys.executable, str(RAIZ / "scripts/exporta_sd.py"), str(sd_md),
"--dir", tmp], capture_output=True, text=True, encoding="utf-8")
saidas = list(Path(tmp).glob("*.json"))
if not saidas:
return f"(CLI não gerou JSON: {r.stderr.strip()})"
return saidas[0].read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
def preencher_linhas(pag: str, linha: str = "1090-L1") -> str:
"""Responde no formulário todas as pendências de `linha_os` com a mesma
linha — o que a pessoa faria copiando da tela da OS. Devolve a página."""
c = campos_do_form(pag)
for k in c:
if k.endswith("__linha_os"):
c[k] = linha
return texto(post("/revisar", c))
def sd_pronta():
print("\n1. SD31: só falta a linha de OS; preenchida, o clique exporta — e nada sai antes dele")
reiniciar()
r = enviar(SD31)
ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão")
pag = texto(r)
ck(estado_na_tela(pag) == "Com pendências" and "4 pendência(s)" in pag,
"estado: com pendências — as 4 linhas de OS que o SD.md não declara")
c = campos_do_form(pag)
ck(set(c) == {f"e{i}__linha_os" for i in range(4)},
f"o formulário traz SÓ os quatro campos de linha de OS, como texto ({sorted(c)})")
ck("<select" not in pag, "sem select de candidatas: não há de-para local, a pessoa copia da tela da OS")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada enquanto falta a linha")
pag = preencher_linhas(pag)
ck(estado_na_tela(pag) == "Pronta para exportar", "preenchidas, estado: pronta para exportar")
ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag,
"sem pendência não há formulário nenhum na tela")
ck("1090-L1" in pag, "a linha de OS declarada aparece no resumo")
ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:],
"o botão Exportar está habilitado")
r = post("/exportar")
ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue")
baixado = texto(r)
p = json.loads(baixado)
ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa")
ck(all(e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
"todo entregável saiu com a linha de OS preenchida")
ck(p["sd"]["status"] == "Planejado" and all(e["status"] == "Rascunho" for e in p["entregaveis"]),
"status nos rótulos do contrato")
ck(estado_na_tela(pagina()) == "JSON exportado", "a tela registra a exportação")
# A mesma régua da CLI: grava o SD.md corrigido (Salvar SD.md) e exporta
# esse arquivo pela linha de comando — tem de dar os mesmos bytes.
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists() and alvo.read_text(encoding="utf-8").count("linha_os: 1090-L1") == 4,
"o SD.md gravado leva as quatro linhas declaradas")
ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado")
def pendencia_de_semanas():
print("\n2. SD24: semanas declaradas ≠ datas → pendente; corrigido no formulário, exporta")
reiniciar()
pag = texto(enviar(SD24))
ck(estado_na_tela(pag) == "Com pendências", "estado: com pendências")
ck("18 dias" in pag and "3 semana" in pag and "declara 1" in pag,
"a pendência diz os dois números — derivado e declarado — e aponta o entregável")
c = campos_do_form(pag)
ck(set(c) == {"e3__semanas", "e3__inicio", "e3__fim"},
f"o formulário traz SÓ o entregável pendente: semanas + as datas dele ({sorted(c)})")
r = post("/exportar")
ck(not baixou(r) and "Exportação recusada" in texto(r),
"POST /exportar direto é recusado pelo servidor, com a razão")
c["e3__semanas"] = "3"
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidas as semanas, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e4 = p["entregaveis"][3]
ck(e4["numero_semanas"] == 3 and e4["ust"] is None and e4["valor_entregavel"] is None,
"o JSON leva 3 semanas; UST e valor saem null para o importador derivar")
ck(p["sd"]["nome"] == "MVP Institucional e Protótipos do Portal RMDS",
"o título — que NÃO estava no formulário — continua intacto")
# Apagar a declaração também resolve: em branco, deriva.
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__semanas"] = ""
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"semanas em branco = 'derive das datas' — também destranca")
def teto_de_28_dias():
print("\n3. SD8: janela de 31 dias e duas divergências de semanas")
reiniciar()
pag = texto(enviar(SD8))
ck("3 pendência(s)" in pag, "três pendências")
ck("31 dias" in pag and "teto de 28 dias" in pag, "o teto de 28 dias é nomeado, com os dias reais")
c = campos_do_form(pag)
ck(set(c) == {"e1__semanas", "e1__inicio", "e1__fim", "e2__semanas", "e2__inicio", "e2__fim",
"e7__inicio", "e7__fim"},
f"formulário só dos entregáveis 2, 3 e 8 — janela inteira de cada um ({sorted(c)})")
c["e1__semanas"] = "2"
c["e2__semanas"] = "1"
c["e7__fim"] = "2026-06-30" # 28 dias → 4 semanas, o que o SD já declarava
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidos os três, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e8 = p["entregaveis"][7]
ck(e8["data_prevista_termino"] == "2026-06-30" and e8["numero_semanas"] == 4 and e8["ust"] is None,
"o entregável 8 saiu com a data corrigida e 4 semanas; a UST é do importador")
ck(p["entregaveis"][1]["numero_semanas"] == 2 and p["entregaveis"][2]["numero_semanas"] == 1,
"os entregáveis 2 e 3 saíram com as semanas das datas")
ck(p["sd"]["status"] == "Em andamento" and e8["status"] == "Documentado",
"estado `documentada` → sd.status 'Em andamento', entregável 'Documentado'")
ck(p["entregaveis"][0]["alocacoes"][0]["perfil"] == "Especialista de Inteligência (Cientista de Dados)",
"o alias do perfil saiu traduzido para o nome do cadastro")
def po_e_objetivo():
print("\n4. SD10: PO em branco e Seção 1 ausente → formulário com os dois; corrigidos, exporta")
reiniciar()
pag = texto(enviar(SD10))
c = campos_do_form(pag)
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"} | {f"e{i}__linha_os" for i in range(5)},
f"PO, objetivo e as cinco linhas de OS no formulário — nada mais ({sorted(c)})")
ck("[a designar]" not in pag and "[a redigir" not in pag, "nenhum placeholder inventado na tela")
c["sd__po_responsavel"] = "Guilherme Bernardino"
c["corpo__objetivo"] = "Permitir à equipe do CRIE manter as regras de elegibilidade sem deploy."
for i in range(5):
c[f"e{i}__linha_os"] = "1090-L1" if i == 2 else "2587-L3" # n3 é I-02; os demais, I-05
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", "preenchidos, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
ck(p["sd"]["po_responsavel"]["nome"] == "Guilherme Bernardino", "o PO chega ao JSON")
ck(p["sd"]["objetivo"].startswith("Permitir à equipe do CRIE"), "o objetivo chega ao JSON")
corpo = servidor.ATUAL["sessao"].corpo
ck(secao(corpo, "1") is not None and corpo.index("## 1.") < corpo.index("## 5."),
"a Seção 1 foi criada no corpo, antes da Seção 5")
ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]]
== ["2587-L3", "2587-L3", "1090-L1", "2587-L3", "2587-L3"],
"cada entregável saiu com a linha que a pessoa declarou")
def sem_datas():
print("\n5. SD20: sem datas, e PO 'PENDENTE' — reprovada, nada baixa")
reiniciar()
pag = texto(enviar(SD20))
ck("11 pendência(s)" in pag, "onze pendências: PO + 5 × (início, fim)")
ck("convenção do repositório" in pag, "o PO 'PENDENTE — ...' é reconhecido como não-nome")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada")
def dado_invalido():
print("\n6. Dado inválido vira mensagem na tela, não traceback no console")
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__inicio"] = "31/13/2026"
pag = texto(post("/revisar", c))
ck("ilegível" in pag and "Traceback" not in pag, "data ilegível é pendência nomeada")
c = campos_do_form(pag)
c["e3__inicio"] = "2026-07-13"
c["e3__fim"] = "2026-07-10"
ck("anterior ao início" in texto(post("/revisar", c)), "fim antes do início é pendência nomeada")
def entrada_ruim():
print("\n7. Entrada ruim: uma mensagem específica para cada caso")
for nome, conteudo, esperado, desc in (
("notas.txt", b"nao sou markdown", "não é um arquivo .md", "extensão errada"),
("vazio.md", b" \n", "está vazio", "arquivo vazio"),
("semfm.md", "# só prosa\n".encode(), "primeira linha deve ser", "sem frontmatter"),
("aberto.md", "---\nsd: X\n\n# sem fecho\n".encode(), "não fechado", "frontmatter não fechado"),
("lista.md", "---\n- a\n- b\n---\n".encode(), "não é um mapeamento", "frontmatter que não é mapa"),
("latin.md", "---\nsd: Vacinação\n---\n".encode("latin-1"), "não está em UTF-8", "fora de UTF-8"),
):
reiniciar()
ck(esperado in texto(enviar(Path(nome), nome, conteudo)), desc)
reiniciar()
pag = texto(enviar(Path("minima.md"), "minima.md",
"---\nsd: X\nestado: voando\nentregaveis: []\n---\n# t\n".encode()))
ck("fora da notação" in pag and "não tem entregáveis" in pag and "voando" in pag,
"frontmatter mínimo: identificador, estado e entregáveis reprovados com nome")
ck("Este conserto é no" in pag, "pendência estrutural aponta para o SD.md, sem controle na tela")
def writeback():
print("\n8. Salvar SD.md: correção gravada, comentários preservados, prosa reanexada")
reiniciar()
c = campos_do_form(texto(enviar(SD10)))
c["sd__po_responsavel"] = "PO de teste"
c["corpo__objetivo"] = "Objetivo de teste."
post("/revisar", c)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists(), "arquivo gravado")
txt = alvo.read_text(encoding="utf-8")
ck("CORREÇÃO 17/08/2026" in txt and "protótipo navegável está EM CRIAÇÃO" in txt,
"o comentário datado do campo `estado` sobreviveu inteiro")
ck("_corpo" not in txt, "nenhuma chave _corpo dentro do frontmatter")
ck('po_responsavel: PO de teste' in txt or 'po_responsavel: "PO de teste"' in txt, "o PO foi gravado")
ck("## 1. Objetivo" in txt and "## 5. Enquadramento no Termo de Referência" in txt,
"a Seção 1 nova e a prosa original estão no arquivo")
d, _ = ler_sd_texto(txt)
ck(d["po_responsavel"] == "PO de teste" and len(d["entregaveis"]) == 5,
"o arquivo gravado reparseia com os mesmos entregáveis")
reiniciar()
enviar(SD10)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo)})
ck(not alvo.exists(), "sem a caixa de confirmação marcada, nada é gravado")
def linha_de_os_no_formulario():
print("\n9. Linha de OS: só o formato é conferido aqui; existência é do banco, na carga")
sd_md = ("---\n"
'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
"entregaveis:\n"
' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
' linha_os: "L1"\n inicio: 2026-09-01\n fim: 2026-09-11\n'
"---\n# T\n\n## 1. Objetivo\n\nAlgo.\n").encode("utf-8")
reiniciar()
pag = texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md))
ck("fora do formato" in pag and "1090-L1" in pag, "linha mal formada: pendência que mostra o formato")
ck('<input name="e0__linha_os"' in pag and "<select" not in pag, "campo de texto, sem lista local")
c = campos_do_form(pag)
c["e0__linha_os"] = "9999-L7"
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"linha bem formada que o repositório não conhece PASSA — quem confere existência é o banco")
p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "9999-L7", "o que a pessoa declarou vai no JSON")
reiniciar()
c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)))
c["e0__linha_os"] = ""
pag = texto(post("/revisar", c))
ck("`linha_os` ausente" in pag and not baixou(post("/exportar")),
"em branco não há fallback nenhum: continua pendente e não exporta")
ck("linha_os" not in servidor.ATUAL["sessao"].dados["entregaveis"][0],
"e a chave `linha_os` saiu do frontmatter, em vez de virar string vazia")
def editar_depois_de_exportar():
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
reiniciar()
preencher_linhas(texto(enviar(SD31)))
post("/exportar")
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
post("/revisar", {"sd__titulo": "outro título"})
pag = pagina()
ck(estado_na_tela(pag) == "Pronta para exportar",
"a edição volta a sessão para 'pronta' — o que foi exportado era outra versão")
ck("outro título" in pag, "e a edição foi aplicada")
def main() -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
sd_pronta()
pendencia_de_semanas()
teto_de_28_dias()
po_e_objetivo()
sem_datas()
dado_invalido()
entrada_ruim()
writeback()
linha_de_os_no_formulario()
editar_depois_de_exportar()
print()
if FALHAS:
print(f"{len(FALHAS)} falha(s):")
for f in FALHAS:
print(" ·", f)
return 1
print("ponta a ponta: tudo passou.")
return 0
if __name__ == "__main__":
sys.exit(main())
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env python3
"""
teste_fluxo.py — prova que o exportador cumpre o contrato da carga SEM mudar a
estrutura do JSON, e que a aplicação web e a CLI são a MESMA régua.
O que está fixado aqui, na ordem em que custa mais errar:
1. ESTRUTURA — o JSON novo tem exatamente as chaves, na mesma ordem, do JSON
que o importador já consome (scripts/backlog/sd/P7-SD31.json). A rotina
de importação depende disso; só VALORES podem mudar.
2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS
preenchida, UST = time-box × semanas(datas), valor = UST × tarifa, janela
≤ 28 dias, vocabulário exato da §6, unicidades da §8.
3. PENDÊNCIAS — as SDs que NÃO exportam ficam pendentes pelos motivos certos,
campo a campo, e nenhuma outra.
4. MESMA RÉGUA — o caminho da app (ruamel) e o da CLI (safe_load) produzem
as mesmas pendências e, quando exportam, os mesmos bytes.
5. As peças reaproveitadas de regras_sd continuam valendo (secao, Seção 5,
tipos do ruamel, definir_secao).
Roda sem framework de teste:
python3 scripts/teste_fluxo.py
0 = tudo bate; 1 = alguma checagem falhou
"""
from __future__ import annotations
import json
import re
import sys
from datetime import date
from caminhos import PROJETOS, RAIZ, sds_reais
from exporta_sd import ExportacaoReprovada, analisar, montar, serializar
from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD,
carregar_canonico, definir_secao, enquadramento_tr, ler_sd_arquivo,
ler_sd_texto, secao, semanas_por_datas)
FALHAS: list[str] = []
# O que cada SD do acervo deve produzir HOJE. Chave = pasta da SD; valor = as
# pendências esperadas como (escopo, indice, campo) — lista vazia = exporta.
# Não é silenciamento: a suíte segue vermelha para qualquer pendência NOVA ou
# para uma esperada que sumir sem o SD.md ter mudado.
def _sem_linha(n: int) -> list[tuple]:
"""SD cujos n entregáveis ainda não declaram `linha_os` no SD.md."""
return [("entregavel", i, "linha_os") for i in range(n)]
ESPERADO = {
"SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"),
("entregavel", 7, "fim")],
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")] + _sem_linha(5),
"SD18-integracao-core-rnds-samu": _sem_linha(4),
"SD20-siscan-d1": [("sd", None, "po_responsavel")]
+ [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")],
"SD24-mvp-institucional": [("entregavel", 3, "semanas")],
"SD27-design-thinking-oficina-superintendencias": _sem_linha(3),
"SD31-fundacao-da-base-de-dispensacao": _sem_linha(4),
"SD32-extracao-automatizada-d-1-do-sigaf": _sem_linha(4),
"SD33-painel-institucional-de-dispensacao": _sem_linha(3),
"SD34-pacientes-ativos-e-alerta-de-inativacao": _sem_linha(3),
"SD35-programacao-estoque-e-faltas": _sem_linha(2),
"SD36-analise-de-processos-ceaf-com-ia-piloto": _sem_linha(3),
}
# O que a pessoa escreveria hoje em `linha_os`, copiado da tabela §5 do contrato
# (banco de 2026-09-03). NÃO é de-para do exportador — ele não tem nenhum, por
# decisão da gestão. Serve só para os testes de contrato e de estrutura rodarem
# sobre um payload completo, como se a SD já estivesse preenchida.
LINHA_DE_TESTE = {"I-02": "1090-L1", "I-03": "2587-L1", "I-04": "2587-L2", "I-05": "2587-L3"}
def com_linhas(sd: dict) -> dict:
"""A SD como ficaria depois de a pessoa preencher `linha_os` no formulário."""
for e in sd.get("entregaveis") or []:
if isinstance(e, dict) and not e.get("linha_os"):
e["linha_os"] = LINHA_DE_TESTE.get(e.get("item"), "1090-L1")
return sd
# Regras do sd-schema.json que NÃO são do contrato da carga — a decisão da
# gestão (2026-09-03) é deixar o importador rejeitar texto fora do teto, e a
# régua de 3..5 bullets de aderência é redação do repositório, não da carga.
# O schema continua sendo conferido aqui, por estrutura; estas ficam nomeadas.
SCHEMA_CONHECIDO = {
"SD32-extracao-automatizada-d-1-do-sigaf":
"o bloco I-02 da Seção 5 tem 2 bullets de aderência e o schema pede 3..5 — regra de "
"redação do repositório, fora do contrato da carga. O exportador não bloqueia por ela.",
}
def checar(condicao: bool, descricao: str) -> None:
print(f" {'ok ' if condicao else 'FALHA'} {descricao}")
if not condicao:
FALHAS.append(descricao)
# ---------------------------------------------------------------------------
# 1. Estrutura
# ---------------------------------------------------------------------------
def _esqueleto(o, pref=""):
"""Todas as listas de chaves do JSON, por caminho — ordem incluída."""
saida = []
if isinstance(o, dict):
saida.append((pref, list(o.keys())))
for k, v in o.items():
saida += _esqueleto(v, f"{pref}/{k}")
elif isinstance(o, list) and o:
saida += _esqueleto(o[0], pref + "[]")
return saida
def _achatar(o, p=""):
if isinstance(o, dict):
for k, v in o.items():
yield from _achatar(v, f"{p}/{k}")
elif isinstance(o, list):
for i, v in enumerate(o):
yield from _achatar(v, f"{p}[{i}]")
else:
yield p, o
# O que o contrato PEDIU que mudasse de valor. Qualquer outro campo diferente
# entre o JSON antigo e o novo é regressão na promessa "estrutura e valores
# iguais, só o que o contrato exige muda".
VALORES_QUE_O_CONTRATO_MUDOU = re.compile(
r"/entregaveis\[\d+\]/(ordem_servico/linha|alocacoes\[\d+\]/perfil"
r"|horas_semanais|ust|valor_unitario_ust|valor_entregavel|memoria_calculo)$"
r"|^/totais/(ust_total|ust_por_item(/.*)?|valor_total)$")
# Chaves cuja PRESENÇA já era condicional no exportador antigo, ou que dependem
# do conteúdo: comparar a lista delas contra um JSON antigo não diz nada sobre
# estrutura. `valor_total` só saía com preço em todos os itens; `ust_por_item`
# tem uma chave por item da SD — hoje sai {} porque a UST é do importador.
CAMINHOS_DE_CONTEUDO = ("/totais", "/totais/ust_por_item")
def estrutura_intacta(canon) -> None:
print("\n· ESTRUTURA: o JSON novo tem as mesmas chaves, na mesma ordem, do que o importador já lê")
pares = 0
for f in sds_reais():
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
ref_path = RAIZ / "scripts/backlog/sd" / (str(sd.get("sd", "")).replace("·", "-") + ".json")
if not ref_path.exists() or analisar(com_linhas(sd), corpo, canon)[1]:
continue # sem JSON antigo para comparar, ou pendente por outro motivo
pares += 1
referencia = json.loads(ref_path.read_text(encoding="utf-8"))
novo = json.loads(serializar(montar(sd, corpo, canon)))
ref, atual = dict(_esqueleto(referencia)), dict(_esqueleto(novo))
for c in CAMINHOS_DE_CONTEUDO:
ref.pop(c, None), atual.pop(c, None)
divergem = [c for c in sorted(set(ref) | set(atual)) if ref.get(c) != atual.get(c)]
checar(set(novo["totais"]) <= {"ust_total", "ust_por_item", "prazo_calendario_semanas", "moeda", "valor_total"},
f"{f.parent.name}: totais só com as chaves de sempre")
checar(not divergem, f"{f.parent.name}: mesmas chaves, na mesma ordem, em "
f"{len(ref)} caminhos" + (f" — divergem: {divergem}" if divergem else ""))
a, b = dict(_achatar(referencia)), dict(_achatar(novo))
mudados = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
fora = [k for k in mudados if not VALORES_QUE_O_CONTRATO_MUDOU.search(k)]
# Os JSONs do backlog são regravados pelo próprio uso da app; quando a
# referência já é uma exportação nova, `mudados` fica vazio e isso é o
# esperado. O que reprova é diferença FORA do que o contrato pediu.
checar(not fora,
f"{f.parent.name}: nenhum valor mudou fora de linha de OS e nome de perfil "
f"({len(mudados)} campos diferentes)" + (f" — FORA: {fora[:5]}" if fora else ""))
checar(pares >= 6, f"{pares} pares antigo × novo comparados (P7-SD31 e demais do backlog)")
# ---------------------------------------------------------------------------
# 2. Contrato
# ---------------------------------------------------------------------------
def cumpre_contrato(nome: str, payload: dict, canon) -> None:
sd = payload["sd"]
ents = payload["entregaveis"]
problemas = []
if sd["status"] not in STATUS_SD:
problemas.append(f"sd.status {sd['status']!r}")
for chave in ("numero_sequencial", "nome", "objetivo", "inicio", "fim"):
if sd.get(chave) in (None, ""):
problemas.append(f"sd.{chave} vazio")
if not sd["po_responsavel"]["nome"]:
problemas.append("po vazio")
if not re.match(r"^V\d+$", payload["_governanca"]["versao_documento"] or ""):
problemas.append("versao_documento")
if not ents:
problemas.append("sem entregáveis")
ns = [e["n"] for e in ents]
if len(ns) != len(set(ns)):
problemas.append("n repetido")
for e in ents:
a, b = date.fromisoformat(e["data_inicio"]), date.fromisoformat(e["data_prevista_termino"])
dias = (b - a).days
if dias < 0 or dias > JANELA_MAXIMA_DIAS:
problemas.append(f"n{e['n']} janela {dias}d")
sem = semanas_por_datas(a, b)
if e["numero_semanas"] != sem:
problemas.append(f"n{e['n']} semanas {e['numero_semanas']} ≠ {sem}")
# Caminho A: UST, tarifa e valor são do importador. Saem null, sempre —
# um número aqui seria o exportador voltando a copiar tabela alheia.
for chave in ("ust", "valor_unitario_ust", "valor_entregavel", "horas_semanais", "memoria_calculo"):
if chave in e and e[chave] is not None:
problemas.append(f"n{e['n']} {chave} deveria ser null")
if not re.match(r"^\d+-L\d+$", e["ordem_servico"]["linha"] or ""):
problemas.append(f"n{e['n']} linha {e['ordem_servico']['linha']!r}")
if e["status"] not in STATUS_ENTREGAVEL:
problemas.append(f"n{e['n']} status {e['status']!r}")
if e["item"] not in sd["itens_contratuais"]:
problemas.append(f"n{e['n']} item fora de itens_contratuais")
for al in e["alocacoes"]:
if al["perfil"] not in PERFIS_DO_CADASTRO:
problemas.append(f"n{e['n']} perfil {al['perfil']!r}")
for lista in ("backlog", "criterios_aceite"):
ordens = [x["ordem"] for x in e[lista]]
if ordens != list(range(1, len(ordens) + 1)) or any(not x["descricao"] for x in e[lista]):
problemas.append(f"n{e['n']} {lista}")
nomes = [d["nome"] for d in e["documentacao"]]
if len(nomes) != len(set(nomes)) or any(not x for x in nomes):
problemas.append(f"n{e['n']} documentacao")
checar(not problemas, f"{nome}: cumpre o contrato" + (f" — {problemas}" if problemas else ""))
# ---------------------------------------------------------------------------
# 3 + 4. Pendências esperadas, mesma régua nos dois caminhos
# ---------------------------------------------------------------------------
def acervo(canon) -> None:
print("\n· PENDÊNCIAS: cada SD do acervo reprova (ou não) pelos motivos esperados")
print("· MESMA RÉGUA: app (ruamel) × CLI (safe_load), pendências e bytes iguais")
vistos = set()
for f in sds_reais():
nome = f.parent.name
vistos.add(nome)
sd_cli, corpo_cli = ler_sd_arquivo(f)
sd_app, corpo_app = ler_sd_arquivo(f, preservar_comentarios=True)
p_cli, pend_cli = analisar(sd_cli, corpo_cli, canon)
p_app, pend_app = analisar(sd_app, corpo_app, canon)
assinatura = [(p.escopo, p.indice, p.campo) for p in pend_app]
esperado = ESPERADO.get(nome)
if esperado is None:
checar(False, f"{nome}: SD nova no acervo — declare o esperado em ESPERADO")
else:
checar(sorted(assinatura, key=str) == sorted(esperado, key=str),
f"{nome}: pendências {assinatura or 'nenhuma'}")
checar([str(p) for p in pend_cli] == [str(p) for p in pend_app],
f"{nome}: mesmas pendências nos dois caminhos")
if pend_app:
try:
montar(sd_app, corpo_app, canon)
checar(False, f"{nome}: montar() gerou payload com pendência aberta")
except ExportacaoReprovada as exc:
checar(len(exc.pendencias) == len(pend_app), f"{nome}: montar() levanta as mesmas pendências")
# Como a SD fica depois de a pessoa preencher `linha_os`: se só faltava
# isso, o payload completo tem de cumprir o contrato e sair igual nos
# dois caminhos.
com_linhas(sd_cli), com_linhas(sd_app)
if analisar(sd_app, corpo_app, canon)[1]:
continue # pendente por outro motivo — coberto acima
s_cli = serializar(montar(sd_cli, corpo_cli, canon))
s_app = serializar(montar(sd_app, corpo_app, canon))
checar(s_cli == s_app, f"{nome}: mesmos bytes (com linha_os preenchido)")
cumpre_contrato(nome, json.loads(s_app), canon)
for nome in ESPERADO:
if nome not in vistos:
checar(False, f"{nome}: consta em ESPERADO mas não existe mais no acervo")
# ---------------------------------------------------------------------------
# Regras do contrato que o acervo não exercita
# ---------------------------------------------------------------------------
_CORPO_OK = "# T\n\n## 1. Objetivo\n\nFazer algo útil.\n\n## 5. Enquadramento\n\n"
def _sd(entregaveis: str, topo: str = "") -> tuple[dict, str]:
fm = ("---\n"
'sd: "P9·SD99"\n'
'projeto: "P9 · Teste"\n'
'titulo: "SD sintética"\n'
'po_responsavel: "Alguém do Cadastro"\n'
'versao: "V1"\n'
"estado: rascunho\n"
+ topo
+ "entregaveis:\n" + entregaveis + "---\n")
return ler_sd_texto(fm + _CORPO_OK, preservar_comentarios=True)
def _pend(canon, entregaveis: str, topo: str = "") -> list[tuple]:
sd, corpo = _sd(entregaveis, topo)
return [(p.escopo, p.indice, p.campo) for p in analisar(sd, corpo, canon)[1]]
ENT_SEM_LINHA = (' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
" inicio: 2026-09-01\n fim: 2026-09-11\n")
ENT_OK = ENT_SEM_LINHA + ' linha_os: "1090-L1"\n'
def regras_sinteticas(canon) -> None:
print("\n· CONTRATO: regras que o acervo de hoje não tripa, em frontmatter sintético")
checar(_pend(canon, ENT_OK) == [], "o caso-base sintético exporta sem pendência")
def caso(desc, entregaveis, esperado, topo=""):
got = _pend(canon, entregaveis, topo)
checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}")
caso("linha_os ausente", ENT_SEM_LINHA, [("entregavel", 0, "linha_os")])
caso("linha_os nula", ENT_SEM_LINHA + " linha_os: null\n", [("entregavel", 0, "linha_os")])
caso("linha_os fora do formato", ENT_SEM_LINHA + ' linha_os: "L1"\n', [("entregavel", 0, "linha_os")])
# Só formato: existência, item e status são do banco, na carga. Uma linha
# que o repositório não conhece PASSA aqui, de propósito.
checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [],
"linha_os bem formada que o repositório não conhece passa — quem confere é o banco")
caso("I-01 (Licença) não entra pela carga", ENT_OK.replace("I-02", "I-01"), [("entregavel", 0, "item")])
caso("item desconhecido", ENT_OK.replace("I-02", "I-07"), [("entregavel", 0, "item")])
caso("tipo Manutenção para a carga", ENT_OK.replace("Descoberta", "Manutenção"), [("entregavel", 0, "tipo")])
caso("tipo fora do dicionário", ENT_OK.replace("Descoberta", "Discovery"), [("entregavel", 0, "tipo")])
caso("`n` repetido", ENT_OK + ENT_OK.replace('nome: "Um"', 'nome: "Dois"'), [("entregavel", 1, "n")])
caso("`n` zero", ENT_OK.replace("n: 1", "n: 0"), [("entregavel", 0, "n")])
caso("sem nome", ENT_OK.replace('nome: "Um"', 'nome: ""'), [("entregavel", 0, "nome")])
caso("UST declarada no .md (é do importador)", ENT_OK + " ust: 80\n", [("entregavel", 0, "ust")])
caso("fim antes do início", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-08-30"), [("entregavel", 0, "fim")])
caso("dia inexistente", ENT_OK.replace("fim: 2026-09-11", 'fim: "2026-02-30"'), [("entregavel", 0, "fim")])
caso("janela de 29 dias", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-30"), [("entregavel", 0, "fim")])
checar(_pend(canon, ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-29")) == [],
"janela de exatamente 28 dias passa (4 semanas)")
caso("documentação com nome repetido",
ENT_OK + ' artefatos:\n - nome: "Doc"\n - nome: "Doc"\n', [("entregavel", 0, "artefatos")])
caso("documentação sem nome", ENT_OK + ' artefatos:\n - nome: ""\n', [("entregavel", 0, "artefatos")])
caso("item vazio no backlog", ENT_OK + ' atividades:\n - "a"\n - ""\n', [("entregavel", 0, "atividades")])
caso("alocacoes_desvio com perfil fora do cadastro",
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Mago", quantidade: 1, percentual: 50}\n',
[("entregavel", 0, "alocacoes_desvio")])
caso("alocacoes_desvio com percentual > 100",
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Scrum Master", quantidade: 1, percentual: 150}\n',
[("entregavel", 0, "alocacoes_desvio")])
sd, corpo = _sd(ENT_OK)
sd["estado"] = "voando"
checar([(p.escopo, p.campo, bool(p.opcoes)) for p in analisar(sd, corpo, canon)[1]]
== [("sd", "estado", True)], "estado desconhecido: pendência com a lista de opções")
sd, corpo = _sd(ENT_OK)
sd["versao"] = "1.0"
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "versao")],
"versão fora de V{n}")
sd, corpo = _sd(ENT_OK)
sd["sd"] = "SD99"
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "sd")],
"identificador fora de P{n}·SD{n}")
sd, corpo = _sd(ENT_OK)
sd["dependencias"] = ["solta"]
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "dependencias")],
"dependencias em lista solta")
corpo_ruim = _CORPO_OK + "| **Item do TR** | I-09 — não existe |\n"
sd, _ = _sd(ENT_OK)
checar([(p.escopo, p.campo) for p in analisar(sd, corpo_ruim, canon)[1]] == [("corpo", "secao5")],
"Seção 5 com item fora do canônico")
# O caminho positivo do que é raro no acervo: linha declarada (1091-L1, a
# segunda OS de I-02) e alocacoes_desvio normalizada.
sd, corpo = _sd(ENT_SEM_LINHA + ' linha_os: "1091-L1"\n'
' alocacoes_desvio:\n'
' - {perfil: "Especialista de infraestrutura", quantidade: 2, percentual: 50}\n')
e = montar(sd, corpo, canon)["entregaveis"][0]
checar(e["ordem_servico"]["linha"] == "1091-L1", "linha_os declarada vai como está para o JSON")
checar(e["alocacoes"] == [{"perfil": "Especialista de Infraestrutura", "quantidade": 2,
"percentual_alocacao": 50}],
"alocacoes_desvio sai com o nome do cadastro e a chave percentual_alocacao")
checar(e["numero_semanas"] == 2 and e["ust"] is None and e["valor_unitario_ust"] is None
and e["valor_entregavel"] is None and e["horas_semanais"] is None,
"11 dias → 2 semanas; UST, tarifa e valor saem null — o importador deriva (§11 do contrato)")
checar(list(e) == ["n", "codigo", "titulo", "tipo_entrega", "item", "ordem_servico", "data_inicio",
"data_prevista_termino", "numero_semanas", "status", "horas_semanais", "ust",
"valor_unitario_ust", "valor_entregavel", "memoria_calculo", "alocacoes",
"backlog", "criterios_aceite", "documentacao"],
"as chaves do entregável ficam, na mesma ordem, mesmo com os valores em null")
for estado, sd_status, ent_status in (("emitida", "Em andamento", "Emitido"),
("validado", "Entregue", "Aprovado"),
("bloqueada", "Planejado", "Rascunho")):
sd, corpo = _sd(ENT_OK)
sd["estado"] = estado
p = montar(sd, corpo, canon)
checar(p["sd"]["status"] == sd_status and p["entregaveis"][0]["status"] == ent_status,
f"estado {estado} → sd.status {sd_status!r}, entregável {ent_status!r}")
# ---------------------------------------------------------------------------
# Schema — por estrutura
# ---------------------------------------------------------------------------
def schema_valida(canon) -> None:
"""Todo JSON que sai precisa validar contra o sd-schema.json.
O schema deixou de ser o portão do download (o contrato da carga é), mas
continua descrevendo a ESTRUTURA — additionalProperties: false na raiz e em
_governanca. Se um campo novo aparecer, ele acusa aqui.
"""
print("\n· JSON exportado valida contra sd-schema.json (estrutura)")
try:
import jsonschema
except ImportError:
checar(False, "jsonschema não instalado — pip install jsonschema")
return
esquema = json.loads((PROJETOS / "_template" / "sd-schema.json").read_text(encoding="utf-8"))
for f in sds_reais():
nome = f.parent.name
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
if analisar(com_linhas(sd), corpo, canon)[1]:
continue # não exporta nem com linha_os preenchido — não há JSON a validar
queixa = None
try:
jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema)
except jsonschema.ValidationError as exc:
caminho = "/".join(str(p) for p in exc.absolute_path) or "(raiz)"
queixa = f"{caminho} — {exc.message[:120]}"
conhecida = SCHEMA_CONHECIDO.get(nome)
if queixa and conhecida:
print(f" aviso {nome}: {queixa}")
print(f" └ conhecido: {conhecida}")
elif queixa:
checar(False, f"{nome}: {queixa}")
elif conhecida:
checar(False, f"{nome}: a divergência conhecida foi resolvida — tire de SCHEMA_CONHECIDO")
else:
checar(True, f"{nome}: valida")
# ---------------------------------------------------------------------------
# 5. Peças de regras_sd
# ---------------------------------------------------------------------------
def tipos_do_ruamel() -> None:
print("\n· Tipos que o ruamel devolve (as regras dependem deles)")
amostra = ("---\n"
"redigida: false\n"
"formalizacao: {sd_emitida: false, os_mae: {I-02: true}}\n"
"semanas: 3\n"
"data_abertura: 2026-08-11\n"
"---\ncorpo\n")
d, corpo = ler_sd_texto(amostra, preservar_comentarios=True)
checar(d["redigida"] is False, "`redigida: false` continua sendo o singleton False")
checar(d["formalizacao"]["os_mae"]["I-02"] is True, "`os_mae: true` é o singleton True")
checar(isinstance(d["semanas"], int), "`semanas` passa no isinstance(int)")
checar(isinstance(d["data_abertura"], date), "data crua vira datetime.date")
checar(isinstance(d, dict), "o mapa é subclasse de dict")
checar(corpo.strip() == "corpo", "o corpo sai separado do frontmatter")
checar("_corpo" not in d, "o frontmatter NÃO carrega `_corpo`")
def secoes() -> None:
print("\n· secao() e definir_secao() são a régua única para as seções do corpo")
corpo_torto = "# t\n\n## 5.Enquadramento no TR\n\ntexto\n"
checar(secao(corpo_torto, "5") is None, "`## 5.Enquadramento` (sem espaço) não é Seção 5")
corpo_ok = "# t\n\n## 5. Enquadramento no TR\n\ntexto\n"
checar(secao(corpo_ok, "5") == "texto", "`## 5. Enquadramento` é reconhecido")
# A P2·SD10 não tem Seção 1: o objetivo entra antes da 5, e as demais ficam.
sd10 = "# Título\n\n## 5. Enquadramento\n\ntabela\n\n## 6. Fora de escopo\n\nfora\n"
novo = definir_secao(sd10, "1", "Objetivo escrito na tela.")
checar(secao(novo, "1") == "Objetivo escrito na tela.", "Seção 1 criada quando não existia")
checar(novo.index("## 1.") < novo.index("## 5."), "criada ANTES da Seção 5, na ordem do template")
checar(secao(novo, "5") == "tabela" and secao(novo, "6") == "fora", "as outras seções ficam intactas")
# Existente: só o conteúdo muda; o cabeçalho e o resto ficam.
com1 = "# T\n\n## 1. Objetivo da SD\n\nvelho\ntexto\n\n## 2. Contexto\n\nctx\n"
novo = definir_secao(com1, "1", "novo")
checar(secao(novo, "1") == "novo" and "## 1. Objetivo da SD" in novo and secao(novo, "2") == "ctx",
"Seção 1 existente: conteúdo trocado, cabeçalho e Seção 2 preservados")
def enquadramento_da_secao5(canon) -> None:
print("\n· enquadramento_tr() lê a tabela da Seção 5 nas formas do acervo")
itens = canon.itens["itens"]
def s5(corpo):
return enquadramento_tr(secao(corpo, "5"), itens)
cabeca = "## 5. Enquadramento no Termo de Referência\n\n| | |\n|---|---|\n"
corpo = (cabeca
+ "| **Item do TR** | I-02 — Desenvolvimento e implantação |\n"
+ "| **Descrição do item** | Conforme TR 1.6.4.1 |\n"
+ "| **Aderência desta SD** | • um<br>• dois<br>• três |\n")
b = s5(corpo)
checar(len(b) == 1 and b[0]["item"] == "I-02", "célula com <br>: uma entrada, item I-02")
checar(b[0]["aderencia"] == ["um", "dois", "três"], "os '•' viram lista, sem marcador residual")
corpo = (cabeca
+ "| **Item do TR** | I-05 — \"Serviços Técnicos\" |\n"
+ "| **Aderência desta SD** | ver bullets abaixo |\n\n"
+ "- primeiro bullet que quebra\n em duas linhas\n"
+ "- **Avaliação Técnica** — com negrito\n- terceiro\n\nFecho.\n")
b = s5(corpo)
checar(b[0]["aderencia"] == ["primeiro bullet que quebra em duas linhas",
"**Avaliação Técnica** — com negrito", "terceiro"],
"indentação pendurada recolada, negrito intacto, fecho fora")
corpo = cabeca + "| **Item do TR** | I-09 — item que não existe |\n"
try:
s5(corpo)
checar(False, "item fora do canônico levanta ValueError")
except ValueError as exc:
checar("I-09" in str(exc), "o erro nomeia o item inválido")
def main() -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
canon = carregar_canonico()
estrutura_intacta(canon)
acervo(canon)
regras_sinteticas(canon)
schema_valida(canon)
tipos_do_ruamel()
secoes()
enquadramento_da_secao5(canon)
print()
if FALHAS:
print(f"{len(FALHAS)} falha(s):")
for f in FALHAS:
print(f" · {f}")
return 1
print("tudo bate.")
return 0
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "93f53fd1c0352d3d7ec17b8f73d7351bf76f4821e55d123c9d2abc2c53ba48e4"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
@echo off
setlocal enabledelayedexpansion
cd /d "%~dp0"
echo Verificando porta 5000...
set "FOUND=0"
for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":5000 .*LISTENING"') do (
echo Processo encontrado na porta 5000 ^(PID %%P^). Encerrando...
taskkill /F /PID %%P >nul 2>&1
set "FOUND=1"
)
if "!FOUND!"=="0" (
echo Nenhum processo estava usando a porta 5000.
) else (
echo Porta 5000 liberada.
)
echo Iniciando servidor Flask...
".venv\Scripts\python.exe" app\servidor.py