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:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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 -"]
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"frontend-design": {
|
||||||
|
"source": "anthropics/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/frontend-design/SKILL.md",
|
||||||
|
"computedHash": "93f53fd1c0352d3d7ec17b8f73d7351bf76f4821e55d123c9d2abc2c53ba48e4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user