Compare commits
3
Commits
fcf31e8d48
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9897df05 | ||
|
|
1d11a5e57b | ||
|
|
0b05a1192c |
@@ -1,5 +1,231 @@
|
||||
# SGF Tempo — instruções locais
|
||||
|
||||
- Entrega11/11: facturação multilinha QA na mesma8085, docs/SGF_MULTI_LINE_INVOICE_DELIVERY.md/provaJSON. Editor1–25serviços/acrescido-incluído/cálculo comum vinculado/revisão temporal distinta/PDF, doisTEST-MULTI-2026/1–2 de1160/base1000/imposto160, doisrecebíveis abertos total2320, doisGL29→31/24contas intactas, mesmo writer. Zero novosdireitos/grants/users/contas. PropriedadeComercial preservada, dependências financeiras qualificadas; APIComercial exige móduloComercial.143dirigidas+122preservação/96conjuntos/trêsbindings; concorrência5,09s empresaFORUPDATE/transactionid503→409apóslibertação/replay200/umdocumento/contador1, sóregressão(fixturesemrecebívelGLidentificada). Falhasparciais rascunho/emissão rollbackrecuperaçãoúnica.22comandos8085+2resultado guardadosprivados: NÃO repetir. Resultado parcialv5/v6mesmamappingv4/31GL/subtotal2286,21/dezporclassificar; antigos19/27e23/29CSVidênticos. Períodoreadonly44factos/0pendentes/débitoscréditosiguais/aberto. Caixa950/activo1200-100-1100/RH/Stock/IVA4/seisregistos/cacheintactos. UI21simuladas/PDF25linhas5páginasinspeccionadasnãoénavegador; vigência/datacorrente/zeroisento/misturasdescontos/POS/Excel/fiscalidadelegalnãoincluídos. Sem demos/SGC/publicação/candidata/checkpoint/aceitação; matrizv30incompleta, visual/carga/implantação separados. Propostasanterioresporautorizar sãohistóricas.
|
||||
|
||||
|
||||
- Autorização expressa11/11: «Autorizar a facturação multilinha QA proposta», docs/SGF_MULTI_LINE_INVOICE_SCOPE.md/JSON. Implementar bloco completo UI/cálculo comum→emissãoQA/PDF→recebíveis→diário, dois documentos1160 cada/acrescido e incluído/regraQA16% existente, nova sérieTEST-MULTI2026 pelos mecanismos actuais;2recebíveis total2320/2GL29→31/24contas intactas/nenhum direito ou acesso novo. Direitos22nomes existentes SELECT-only verificados. Retira apenas a exclusão de emissão/dívida/GL deste recorte seleccionado, sem fiscalidade/operações reais/AT/envio/pagamentos/Stock/Caixa/RH/reabertura/publicação/checkpoint. Mesma8085/regressão, seisregistos/cache/todosartefactos anteriores preservados. Não pedir por campo/etapa; propostas anteriores por autorizar substituídas. Antes de instalar, provar todo bloco e preservar ramos antigos.
|
||||
|
||||
|
||||
- Próxima fronteira concreta11/11: docs/SGF_MULTI_LINE_INVOICE_SCOPE.md/JSON, POR AUTORIZAR/NÃO IMPLEMENTADA. ServiceCalculation/e emissão ainda bloqueiam váriaslinhas e preço incluído; âmbito anterior SGF_MULTI_LINE_INCLUDED_SCOPE autoriza apenas cálculo e exclui expressamente factura/dívida/GL. Proposta agrupada editor/cabeçalho/grelha/totais/cálculo→emissão QA/PDF→recebíveis→diário;2documentos1160 cada(400+600 acrescido,464+696 incluído), regraQA16% existente/vigente, série novaTEST-MULTI2026,2recebíveis total2320,2GL novos29→31/mantém24contas/sem dinheiro. Nenhum direito/concessão novo; SELECT-only22nomes já presentes nos dois livros. Usar cliente/serviços QA existentes autorizados sem seisregistos; novaspreparações pelos writersactuais. Não retirar guardas nem emitir/contabilizar antes da decisão. Sem novasdemos/SGC/PUBLIC/operações reais/AT/envio/publicação/Stock/Caixa/RH/fecho/checkpoint. Cadaartefacto antigo permanece imutável, visual/carga/implantação separados.
|
||||
|
||||
|
||||
- Entrega11/11: SGF-FIXED-ASSETS-INTEGRATED-QA-v1 na mesma8085, docs/SGF_FIXED_ASSETS_DELIVERY.md/prova JSON. Cadastro/plano/quota revistos, novoQA-ACTIVO-01 custo1200/residual0/vida12/admissão01Oct/quota31Oct100/líquido1100. Seis versões/recibos administrativos e doisGL novos(27→29), cinco contas(19→24); originais íntegros. Extensão tipada do MESMO writer/guardas SQL/pins autorizada, sem segundo diário. Correcção100→120/par atómico e abate120+1080; abate100+1100/fecho do recorte com harness financeiro isolado em rollback na regressão. Concorrênciaempresa FORUPDATE/transactionid5,24s503→409 pós-libertação/replay200/sem duplicação.141dirigidas+87preservação/78conjuntos/3bindings,13aritméticas reutilizadas. Cinco concessões QA por livro10total, nenhum perfil/user/PGrole/PUBLIC novo. Quatro comandos de correspondência/resultado novo:24contas catálogo/23com movimento/29GL/dez por classificar, corte01Jan–11Nov/subtotalconhecido286,21; antigo19/27CSV idêntico histórico.8085conferência read-only40factos/0pendentes/balanced; período aberto. Progresso/chaves privados assets-http-progress-private.json e assets-statements-http-progress-private.json: NÃO repetir12+4comandos nem origens antigas. Fonte financeira estável separada da revisão administrativa que invalida capturas pendentes. SSRlegado não terminou/não contado; novo15UI+HTTP/buildSHA aprovados, sem revisão visual. Matrizv29produto incompleto; legal/carga/implantação separados. Seis registos/cache/Caixa950/RH/Stock preservados, sem demos/SGC/publicação/candidata/checkpoint/aceitação.
|
||||
|
||||
|
||||
- Referências visuais11/11 recebidas:15capturas em DOCs/Ecrans, docs/SGF_BILLING_SCREEN_REFERENCES.md. Primeiras12 relevantes(menu/listas/cadastros/grelha facturação/totais);13–15 aplicações alheias e duplicados não são requisitos adicionais. Aproveitar campos/percurso, não transcrever dados pessoais/NUIT/contactos/valores nem adoptar taxas comolei. Preservar menu horizontal/dropdown, fontes/estados/direitos; não inferir POS/Excel/campos novos implementados. É orientação durante o ciclo de activos, não cancelamento/substituição da tarefa nem aceitação visual do SGF.
|
||||
|
||||
- Autorização expressa11/11: «Autorizar o ciclo integrado de activos QA», docs/SGF_FIXED_ASSETS_SCOPE.md/JSON. Implementar todo o ciclo agrupado com extensão tipada do MESMO núcleo/pins, não segundo writer.3direitos/5grants por livro10total aos15/16/17existentes,5contas artificiais por livro(19→24),2GL novos8085(27→29) mantendooriginais,4comandos de correspondências/resultado parcial novo preservandoold19/27snapshot. Cadastro/custódia/plano/depreciação/relatórios/conferência; abate/correcção técnicaquota e fecho rollback sóregressão. QAactivo1200/residual0/vida12/Outubro100/líquido1100. Sem novasdemos/SGC/contas reais/dinheiro/dívida/submissão/fechoreal/publicação/checkpoint; preservar6registos/cache/Caixa950/HP16/RM6/trabalhoalheio. Indicações anteriores por autorizar são históricas; não pedir por campo/operação/teste.
|
||||
|
||||
- Continuação11/11: painel fiscal reconciliado viaFiscalCoveragev3, docs/SGF_COVERAGE_RECONCILIATION.md. RH/cálculosinternosQA reconhecidos, legalIRPS-INSS continua pendente, demonstraçõesparciais existentes/activos ausentes explícitos.7metadata+8HTTP,66conjuntos/3bindings intactos/HP16/RM6/GL27, zero grants/comandos negócios/cache/demos. Próxima decisão concreta docs/SGF_FIXED_ASSETS_SCOPE.md/JSON, **por autorizar/não implementada**: activosQA1200/residual0/vida12, quotaOutubro100/líquido1100, admissão01Oct/quota31Oct no mesmo núcleo/períodoQA2026aberto.3direitos fixed_assets.view15/16/17 prepare15 review16:5grants por livro10total, sem novas identidades;5contas artificiais/livro(19→24),2GL novos8085(27→29) preservando27originais, quatro comandos adicionais de correspondência/resultado parcial com antigo snapshot preservado/histórico. Extensão TIPADA do escritor/pins originais exige esta autorização explícita, não contornar com segundo writer/INSERT. Custódia/cadastro/plano/revisão/relatórios; abate/correcção técnica/fecho rollback na regressão. Nova origemQA por classificar; sem capital/dívida presumidos ou duplicaçãoStock/custos. AssetDepreciation puro13checks ainda não instalado; direitos ausentes SELECT-only ambos livros. Não conceder/aplicar antes da decisão. Preservarseis registos/cache/Caixa950/fontes; sem demos/SGC/pagamentos/fiscalidadelegal/publicação/aceitação/checkpoint, visual/carga/implantação separados. Matrizv28incompleta.
|
||||
|
||||
|
||||
- Resumos legíveis RH entregues11/11, docs/SGF_HUMAN_RESOURCES_READABLE_DELIVERY.md, mesma8085 RH→pessoal→versão revista→Descarregar resumo legível desta versão. Derivação somente da versão congelada, CSV PT/UTF8BOM, formato v1/SHA próprio e referência ao original; originais preservados. Auth/contexto/área revalidados, tokens só header, cliente valida origem/formato/digest/contexto antes de guardar. Sem novos direitos/escritas de negócio8085/schema/perfis.58verificações dirigidas(17reg+11descarga+3handler simulado+27HTTP),66conjuntos+3bindings intactos/HP16/RM6/GL27/seis registos/cache preservados. Auditoria FUTURA de pessoal corrigida para contracts/attendance/leave e area; etiqueta anterior herdada de remunerações nos16eventos8085 não foi reescrita, histórico pleno continuaHPversions. Não repetir operações para testar rótulos: um evento de contrato apenas na regressão e revertido. Matrizv27incompleta; visual/carga/legal/implantação separados; sem demos/SGC/publicação/candidata/aceitação/checkpoint.
|
||||
|
||||
|
||||
- Contratos/assiduidade/férias entregues11/11, docs/SGF_HUMAN_RESOURCES_PERSONNEL_DELIVERY.md, mesma8085 RH autónomo→Contratos, assiduidade e férias.9direitos/15grants por livro existente30total, mesmos3perfis/pins sem novas identidades. QA-CONTRATO-01/QA-COLABORADOR-01, planos09/10Nov480min, declaração09=480/10explicitamentevaziaapósgozo, orçamento fictício960/reserva→gozo480/disponível480.6origens16versões/recibos; não repetir comandos (hp-http-progress-private.json privado).233dirigidas+56compat afectada+78preservação, concorrência5,09s503 hostempresa FOR UPDATE/transactionid pg_blocking_pids→409 apóslibertação/nova preparação409 por insuficiência/replay200/um pedido aprovado.11versões suporte só na regressão.62conjuntos originais egrants antigos iguais+15exactos,3bindings/GL27/contas19/Caixa950/RMsixversões-recibos-CSV intactos/seis registos/cache.27fixturespaginação+26saldos+foreignunit/falha parcial/contrato salarial/recusa revertidos. Saldo actual separado do resultado congelado, leitura conjunta4SQL25/100 sem medição carga; recusa de proposta inviável permite NOVA conferência explícita com fontes/saldoactuais, nunca executa proposta. Ligação contratual opcional só em novaspreparações RM, prova regressão, sem retroatribuir contrato nem gerar novo salário8085. UI/MIME/SHA confirmados sem navegador/visual; flags root off/privadaQA on, sem demos/SGC/publicação/candidata/checkpoint/aceitação. Matrizv26produto amplo incompleto: legislação laboral/fiscal, IRPS/INSS/modelos/turnos gerais/benefícios/efeitos financeiros ainda pendentes; visual/carga/armazenamento/implantação separados. Propostas por autorizar anteriores são históricas.
|
||||
|
||||
|
||||
- Autorização expressa11/11: «Autorizar o bloco e as30 concessões QA apresentadas», docs/SGF_HUMAN_RESOURCES_PERSONNEL_SCOPE.md/JSON. Implementar todo o bloco contratos/assiduidade/férias/conferência e ligação contratual explícita às novas preparações salariais apenas em regressão.9direitos/15grants por livro existente/30total, mesmos3perfisRH, sem novas identidades/pins. Preservar6versões/recibos salariais8085,seis registos/cache/GL27/contas19 e exemplos. Sem demos/SGC/efeitos financeiros/modelos legais/publicação/checkpoint. Indicações anteriores por autorizar substituídas; não pedir por campo/operação/teste.
|
||||
|
||||
- Pedido11/11 para contratos/assiduidade/férias no RH autónomo. Âmbito completo preparado `docs/SGF_HUMAN_RESOURCES_PERSONNEL_SCOPE.md`/JSON: contrato QA10000, planos09/10Nov480min, presença09, orçamento fictício960min/pedido-gozo10de480/saldo480, conferência parcial por pessoa e ligação contratual explícita só em futuras preparações salariais (prova regressão), preservando6versões/recibos salariais8085. Novos9direitos human_resources.contracts/attendance/leave × view/prepare/review; consultas3perfis, preparehr_prepare/reviewhr_review,15grants/livro30total nos perfis existentes, **por autorizar/não concedidos**; SELECT-only confirma ausência nos dois livros. Zero novas identidades/roles/pins alterados. PersonnelTime puro local22checks intervalos/desconhecidos/conflitos/saldo; não instalado, sem writers/schema/dados alterados. Não declarar funções novas entregues. Regra de novos poderes requer decisão antes de conceder; pedido de implementar não contém lista nova aprovada. Preservar8085/seis registos/cache/provas/GL27/contas19/Caixa950/fontes; sem demos/SGC/dívida/pagamento/GL/salário/contrato real/modelos legais/publicação/checkpoint. Não presumir férias legais960min nem classificar faltas automaticamente. Após aprovação, implementar todo bloco sem pedir por campo/rubrica/teste.
|
||||
|
||||
|
||||
- RH autónomo entregue11/11, `docs/SGF_HUMAN_RESOURCES_DELIVERY.md`, mesma8085 `/sgf`→Recursos Humanos. Três perfis próprios QA/livro,11grants/livro22total, namespace human_resources.remunerations.*, registo separado SHA, sem alterar10perfis/pins anteriores; antigosgrants Financeiros remunerations.* permanecem ausentes. Cadastro/regras/processamento de QA-COLABORADOR-01/2026-11 revistos por pessoas distintas: bruto10000, descontos300+800, encargo400, líquido8900/custo10400. São parâmetros artificiais, não cálculo legal IRPS/INSS.6versões/6recibos na8085; não repetir comandos, progresso privado hr-http-progress-private.json.40reg perfisHR reais+23fronteiras finais+16handlers API-simulada reutilizados+43HTTP+10leituras finais=132, mais concorrência5,20s503 hostempresa FOR UPDATE/transactionid pg_blocking_pids→409 pós-libertação/replay200 sem duplicação, e71preservação/ACL.55conjuntos originais iguais+grants antigos iguais com11aprovados,3bindings/GL27/contas19/Caixa950/seis registos/cache preservados.27fixtures paginação+1estrangeira/falha parcial revertidas;6versões suporte concorrência ficaram só na regressão. JSmain/lazy/shared MIME/SHA, flags root off/privada8085 on, companycontract só1:2 incluiRH. Matrizv25 tem7módulos; produto amplo incompleto. Contratos/assiduidade/férias/legalIRPS-INSS/ligação salarial ainda faltam. Sem dívida/pagamento/GL/salário real/modelo oficial/demos/SGC/publicação/candidata/checkpoint/aceitação; visual/carga/implantação separados. Indicações históricas de acessos/instalação por autorizar foram substituídas.
|
||||
|
||||
|
||||
- Autorização expressa11/11: «Autorizar os perfis QA próprios de RH e as concessões apresentadas», `docs/SGF_HUMAN_RESOURCES_AUTONOMY_SCOPE.md`/JSON. Criar hr_prepare/hr_review/hr_read,11grants por livro QA existente/22total, sem conceder aos Financeiros. Concluir todo o percurso sintético autorizado no módulo autónomo RH na mesma8085, provas dirigidas positivas/isolamento/concorrência/recuperação e preservação. Não pedir por campo/rubrica. Preservar perfis/pins anteriores,27GL,seis registos/cache; sem demos/SGC/dívida/pagamento/salário real/GL novo/modelos legais/publicação/checkpoint. Indicações anteriores por autorizar foram substituídas.
|
||||
|
||||
- Directiva posterior11/11: RH deve ser AUTÓNOMO, perfis/permissões distintos. `docs/SGF_HUMAN_RESOURCES_PREPARATION.md`: código/rotas/menu local preparado (não copiado8085), motor em App/Modules/HumanResources, proprietário human_resources, namespace human_resources.remunerations.*, registo HR separado SHA sem alterar os10perfis/pins aceites. Retiradas integralmente as7concessões novas a Financeiros13–17 por livro/14total, com auditoria; nenhum processamento8085 persistido. Três tabelas rm_* técnicas já criadas sob primeira autorização, versões/recibos vazios. Matriz v24 conserva6entregues e7ºRH planeado, produto incompleto. Próxima decisão `docs/SGF_HUMAN_RESOURCES_AUTONOMY_SCOPE.md`/JSON: hr_prepare/hr_review/hr_read novos, view3+prepare1+review1 e company.view/unit.view para cada,11grants/livro22total/seisidentidades QA; **por autorizar/não concedidos/não criados**. Regra de acessos exige decisão da nova lista, autonomia do módulo já foi ordenada. Instalador novo falha sem aprovação, antigo bloqueado por status superseded.16handlersAPI-simulada,11preauth/cálculo em cada livro,65preservação/56conjuntos+3bindings intactos.40funcionais iniciais são anteriores à separação, fixtures revertidas; não equivalem a integração RH nova. Percurso efectivo/concorrência/falha parcial/HTTP RH pendentes até autorização; não declarar concluído. Contratos/assiduidade/férias/legalIRPS-INSS/ligação salarial ainda faltam.27GL/seis registos/cache preservados; sem demos/SGC/publicação/candidata/checkpoint/aceitação. Indicações anteriores de Financeiro e concessões autorizadas13–17 são históricas e foram substituídas pela directiva de autonomia.
|
||||
|
||||
|
||||
- Autorização expressa11/11: «Autorizar o bloco QA e os três direitos apresentados», `docs/SGF_REMUNERATIONS_SCOPE.md`/JSON. Implementar todo o percurso sintético descrito: cadastro/regras/processamento/revisão/correcção/cancelamento/mapa/SHA/recuperação. remunerations.view13–17/prepare13/review14, sete concessões por livro existente/14total. Não pedir por rubrica. Sem salários/dívida/pagamento/GL/modelos legais novos; preservar27GL/seis registos/cache/mesma8085, sem demos/SGC/publicação/checkpoint. A indicação histórica por autorizar foi substituída por esta decisão.
|
||||
|
||||
- Próxima decisão11/11 preparada `docs/SGF_REMUNERATIONS_SCOPE.md`/JSON: processamento interno completo de remunerações QA, cadastro fictício/regras versionadas/10000 bruto, parâmetros artificiais3%/4%/8%, descontos300+800, líquido8900/custo10400, revisão distinta/mapa conservado/SHA/correcção/cancelamento/recibos. Não são taxas legais nem salários reais; parâmetros em falta não viram zero. Três direitos remunerations.view13–17/prepare13/review14, sete concessões por QA existente/14total, **por autorizar/não implementados/não concedidos**, SELECT-only confirmou ausência nos dois livros. Sem novos GL/contas/pagamentos/dívida/modelos oficiais/submissão/SGC/demos; preservar27GL/fontes/seis registos/cache. Não inferir poder salarial dos direitos de Caixa/IVA/GL; não pedir por rubrica após autorização do bloco.
|
||||
|
||||
|
||||
- Ordens por equipamento entregues11/11, `docs/SGF_WORKSHOP_EQUIPMENT_ORDERS_DELIVERY.md`, mesma8085 Oficinas→Equipamentos. Escolha explícita, metadados actuais, filtro SQL antes25/100, pesquisa/estado/período, CSV/página e ordem exacta; regresso conserva catálogo. Reutiliza direitos/contexto/atribuição; fonte catálogo e ordens na mesma leitura consistente, sem writer/mutex/schema/grants novos.23reg (28ordens/um equipamento fixtures SQL revertidas),12handlers simulados,17HTTP efectivo=52.56conjuntos/binding intactos incluindo fs_* recentes e27GL; seis registos/cache/provas/trabalho alheio preservados. JSprincipal/lazy MIME/SHA, cópias anteriores privadas. Matriz v23 amplo produto incompleto; visual/carga/implantação separados. Sem demos/SGC/publicação/candidata/checkpoint/aceitação.
|
||||
|
||||
|
||||
- Balanço/resultados internos parciais QA entregues11/11, `docs/SGF_FINANCIAL_STATEMENTS_DELIVERY.md`, mesma8085 Contabilidade.19 correspondências explícitas propostas preparadas15/revistas16, nove por classificar; resultado10/11 conservado19linhas sobre27lançamentos, quatro versões/recibos/eventos, um snapshot/SHA. Activos2155/fornecedor100/rendimentos2586,21/gastos2200/subtotal386,21; capital/resultado completo indeterminados, sem modelo legal. Motor/writer/contas/lançamentos originais intactos; original trialBalance por páginas25/100, grupos exactos/linhas conservadas, CSV por lotes100 e tempstream1MiB, não a cache bloqueada. Consulta/expor antigo preserva bytes, fonte nova marca histórico, razão recebe conta/datas. Dois direitos aprovados prepare15/review16, duas concessões por QA existente/quatro total, sem SQL/PUBLIC/roles/contas reais.152 verificações centrais mais65preservação/ACL/concorrência:38reg (livro vazio sem inserir GL),17fronteiras (double128linhas),4falha parcial(snapshot19linhas revertido sem contas/GL),23handlersUI,46HTTP sobre19contas/27GL efectivos,11descarga,13entrada/assets finais. Não somar passagens18/19UI ou16fronteiras substituídas. Concorrência503 após5,13s hostempresa FOR UPDATE/transactionid e pg_blocking_pids; pós-libertação409 e replay200/uma revisão só na regressão. Retoma corrigiu descarga API sem X-SGF-Context: botão com leitura autenticada/geração/SHA antes de guardar, token nunca URL; guardas originais mantidas. Mesmo atalho do mapa IVA corrigido, cinco compatibilidade e22UI existentes reexecutadas (prova antiga SHA igual), sem recriar mapa/decisões.49 conjuntos iguais; linhas antigas de permissões/auditoria nativa iguais+2grants/+4eventos, três bindings/GL27/contas19/Caixa950/IVA4/cálculos5/fo/pd/stock/seis registos/cache preservados. Progresso/chaves privados: não repetir quatro comandos8085. Config root desligada, privada8085 habilitada; JSmain/lazy/partilhado MIME/SHA conferidos. Sem demos/SGC/efeitos financeiros/fecho/reabertura/publicação/candidata/aceitação/checkpoint. Matriz v22 produto amplo incompleto; visual/carga/implantação separados. Proposta anterior por autorizar é histórica.
|
||||
|
||||
|
||||
- Autorização expressa11/11: «Autorizar o bloco QA e os dois direitos apresentados», `docs/SGF_FINANCIAL_STATEMENTS_SCOPE.md`/JSON. Implementar bloco inteiro de correspondências revistas e balanço/resultados internos parciais QA, prepare15/review16, duas concessões por QA existente/quatro total.19 contas propostas/nove por classificar, sem capital fabricado; snapshots/SHA/histórico/recibo/provas. Reutilizar motor/writer/fontes originais; preservar27lançamentos/dados/seis registos/cache, sem demos/SGC/modelo legal/publicação/candidata/checkpoint/aceitação. Não pedir por campo/teste; indicação anterior por autorizar é histórica.
|
||||
|
||||
|
||||
- Antiguidade de fornecedores entregue10/11, `docs/SGF_PAYABLE_AGING_DELIVERY.md`, mesma8085 Financeiro. Referência explícita, saldo original/condição administrativa revista da página, faixas0/1–30/31–60/61–90/>90/desconhecido; moeda/centavos exactos, desconhecido separado e sem reconhecimento/indeterminado/liquidado excluídos das faixas monetárias. Original due_on/days_overdue preservados.52 verificações (26reg/12UI/14HTTP), JS main/lazy MIME/SHA;51 conjuntos/binding intactos incluindo fo_* quatro versões/recibos recentes, seis registos/cache/provas/trabalho alheio. Zero comandos/direitos/schema/índices novos; mesmos mutex/paginação25/100, sem totais globais/corte histórico/carga/implantação alegados. Matriz v21 produto amplo incompleto; visual separado; sem demos/SGC/publicação/candidata/aceitação/checkpoint. Próxima decisão preparada `docs/SGF_FINANCIAL_STATEMENTS_SCOPE.md`/JSON: correspondências explícitas revistas para balanço/resultados internos parciais QA,19 contas/27lançamentos existentes, nove contas por classificar e nenhum capital fabricado. Dois direitos general_ledger.statements.prepare15/review16, duas concessões por QA existente/quatro total, **por autorizar/não concedidos**, SELECT-only confirma. Consulta reutiliza general_ledger.view; sem alterar motor/writer/contas/lançamentos ou declarar modelos legais.
|
||||
|
||||
|
||||
- Obrigações/prazos administrativos QA entregues10/11, `docs/SGF_FISCAL_OBLIGATIONS_DELIVERY.md`, mesma8085 Contabilidade. Uma origem mapa IVA já revisto, referência/responsável explícitos, prazo artificial15/12, preparação13/revisão14 e entrega APENAS simulada13/14 com referência/SHA exportação; quatro versões/recibos/eventos. Correcção, desconhecido, recusa, cancelamento, histórico/impacto/recibo e SQL25/100/filtros/CSV. Três direitos aprovados view13–17/prepare13/review14, sete concessões por QA existente/14total, sem SQL/PUBLIC/roles/contas reais.41 regressão/19 fronteiras/22 handlers simulados/29 HTTP/sete entrada=118, mais concorrência503 após5,64s pelo FOR UPDATE empresa (pg_blocking_pids/transactionid), pós-libertação409/replay200 e fixture cancelada por dois comandos adicionais só na regressão. Reg havia livro revisto mas não mapa persistente: dois comandos necessários prepararam/reviram esse mapa, sem repetir livro/origem financeira;8085 mapa intacto.61 preservação/ACL:47 conjuntos iguais, linhas antigas de permissões iguais+sete concessões, três bindings/diário27/Caixa950/cálculos5/IVA4/condições/artigos/seis registos/cache preservados. Auditoria comum usa allowlist existente/refversão; impacto integral em fo_versions append-only. Config root desligada, privada8085 habilitada; JS main/lazy MIME/SHA conferidos. Sem demos/SGC/efeitos financeiros/AT/prazo legal/modelo oficial/publicação/candidata/aceitação/checkpoint. Matriz v20 produto amplo incompleto; visual/carga/implantação separados. Proposta anterior por autorizar é histórica; não repetir quatro comandos da8085 (progresso/chaves privados).
|
||||
|
||||
|
||||
- Autorização expressa10/11: «Autorizar o bloco QA e os três direitos apresentados», `docs/SGF_FISCAL_OBLIGATIONS_SCOPE.md`/JSON. Implementar bloco inteiro administrativo ligado ao mapa IVA revisto, fiscal_obligations.view13–17/prepare13/review14, sete concessões por QA existente/14total. Prazo15/12 artificial, entrega simulada por referência/SHA do export interno; sem prazos legais/AT/imposto/dívida/pagamento/lançamentos. Preservar fontes, seis registos/cache e demais limites; não pedir por campo/teste. Proposta anterior por autorizar é histórica.
|
||||
|
||||
|
||||
- Relatório por abertura de ordens entregue10/11, `docs/SGF_WORKSHOP_PERIOD_REPORT_DELIVERY.md`, mesma8085 Oficinas→Relatórios. Datas explícitas, dia local Africa/Johannesburg convertido em UTC início incluso/fim exclusivo, SQL antes25/100; apontamentos/identificação actuais, não fecho histórico. CSV só página/aplicação de filtros/escapagem, zero comandos/direitos/schema.21 regressão30fixtures revertidas/13 UI/15 HTTP=49, MIME/SHA de main/lazy;48 conjuntos actuais/binding intactos, diário27/Caixa950/cálculos5/condições de vencimento/seis registos/cache preservados. Provas anteriores reutilizadas, não repetir workflows; leitor sem mutex anterior preservado sem nova carga alegada. Matriz v19 produto amplo incompleto; visual/carga/implantação separados. Sem demos/SGC/publicação/candidata/checkpoint/aceitação. Próximo bloco concreto `docs/SGF_FISCAL_OBLIGATIONS_SCOPE.md`/JSON: controlo administrativo de prazo/obrigação/declaração de entrega simulada ligado ao mapa IVA interno revisto, três direitos novos fiscal_obligations.view/prepare/review, sete concessões por QA existente/14total, **por autorizar/não concedidos** (SELECT-only confirma ausência). Sem calendário legal, imposto/dívida/pagamento/submissão externa; prazo15/12 artificial explicitamente. Não confundir agenda de Oficina já implementada com esta lacuna fiscal.
|
||||
|
||||
|
||||
- Vencimentos administrativos de fornecedores entregues10/11, `docs/SGF_PAYABLE_DUE_DELIVERY.md`, mesma8085 Financeiro→Posições→Condições/histórico. Preparação13/revisão14, evidência/fundamento/prévia/recusa/correcção desconhecido/versões/recibo; origem/saldo/versão/direitos/contexto/pessoa revalidados. Dois direitos aprovados, duas concessões por QA existente/quatro total, sem SQL/PUBLIC/roles novos. Exemplo obrigação100/data artificial30/11, duas versões/recibos e dois eventos; original pa_obligations.due_on/factura/saldo/diário intactos.40 regressão final (não contar original40 outra vez)/nove identidade/quatro paginação29fixtures revertidas/seis lista/23HTTP/10finais/21handlers simulados=113; concorrência503 após5,12s pelo FOR UPDATE empresa, pg_blocking_pids/transactionid observado, pós-libertação409 e replay200/uma revisão apenas na regressão.58 preservação/ACL:43 conjuntos iguais, dois com linhas antigas iguais e append autorizado2, três bindings/diário27/Caixa950/cálculos5/seis registos/cache intactos. Identidade humana gravada em preparações novas; duas versões iniciais8085 preservadas sem reescrever. Lista projecta condições aprovadas da página e CSV distingue origem/administrativa; referência explícita no detalhe, não saldo histórico. Config root desligada, QA seleccionada habilitada. Matriz v18 produto amplo incompleto; visual/carga/implantação separados. Sem demos/SGC/efeitos financeiros/acessos reais/publicação/candidata/aceitação/checkpoint. Proposta anterior por autorizar é histórica.
|
||||
|
||||
|
||||
- Autorização expressa10/11: «Autorizar o percurso QA e os dois direitos apresentados», `docs/SGF_PAYABLE_DUE_SCOPE.md`/JSON. Implementar percurso inteiro de vencimentos administrativos revistos, due.prepare13/due.review14, duas concessões por QA existente/quatro total, sem pedir por campo/teste. Exemplo100MZN/data artificial30/11, sidecar sem mutação de factura/obrigação/saldo/diário, demais limites do âmbito mantidos. A indicação anterior por autorizar é histórica.
|
||||
|
||||
|
||||
- Próxima decisão concreta: `docs/SGF_PAYABLE_DUE_SCOPE.md`/JSON, vencimento administrativo revisto de obrigação individual, sidecar append-only sem alterar pa_obligations/factura/saldo. Dois novos poderes due.prepare13/due.review14 sob supplier_payables, duas concessões por livro existente/quatro total: por autorizar/não concedidas. SELECT-only confirma ausência nas duas bases. Exemplo proposto obrigação100MZN com evidência artificial e data30/11, atraso apenas por referência explícita; sem juros/pagamentos/propagação. Leitura não confere estes poderes. Preservar todos os exemplos, seis registos/cache; sem demos/SGC/publicação/candidata/checkpoint/aceitação/implantação.
|
||||
|
||||
|
||||
- Posições de fornecedores entregues10/11, `docs/SGF_PAYABLE_POSITIONS_DELIVERY.md`, mesma8085 Financeiro. SQL25/100 por factura/fornecedor e estado documental, engine.positionsPage só IDs da página; fonte ambígua409, saldo desconhecido não zero, moedas separadas/resumo e CSV só página. Detalhe conserva document_id explícito e recusa403 sem fallback; command financeiro original idêntico após normalizar finais de linha.23 regressão/15 UI/19 HTTP=57, assets JS SHA/MIME incluídos; zero POSTs de negócio/direitos/schema.45 conjuntos actuais preservados incluindo catálogo novo/recibos, diário27/Caixa950/cálculos5; seis registos/cache/provas/trabalho alheio preservados. Vencimento/atraso continuam desconhecidos pelo motor original; não inferir prazos. Leitor composto conserva mutex; não alegar carga/optimização integral, visual ou implantação. Matriz v17 produto amplo incompleto; sem demos/SGC/publicação/candidata/checkpoint/aceitação.
|
||||
|
||||
|
||||
- Cadastro de artigos entregue10/11, `docs/SGF_STOCK_CATALOGUE_DELIVERY.md`, mesma8085 Stock → Artigos e revisão do catálogo. Intenção/revisão distintas; criação no catálogo canónico sem saldo/movimento; código/unidade imutáveis e correcção de designação só dos novos, histórico/recibo atómicos. Duas concessões aprovadas por QA existente (stock.items.prepare2/review12), quatro total; sem novas concessões SQL/PUBLIC/roles.34 regressão/11 limites/22 HTTP/15 DOM→HTTP sem confirmações adicionais/três navegação=85, mais concorrência503 em5,22s→409/um artigo e replay200; dois checksums finais. Um QA-CATALOGO-INTEGRADO/UN, duas versões/recibos;38 conjuntos originais/três bindings e diário27/Caixa950/cálculos5 intactos, seis registos/cache preservados. Descoberta falha transversal: scripts novos devolviam HTML200 no router. Corrigida entrega de dez nomes exactos só8085, dependência sgf-stock-final incluída;17 limites/comparação corpo original e13 HTTP MIME/SHA/404. Ver `docs/SGF_CLIENT_ASSET_DELIVERY.md`. API/DOM anteriores não provavam entrega de scripts nem visual; não declarar ecrã validado por esses níveis. Router partilhado conserva comportamento das outras composições. Sem reinício/cache/demos/SGC/novos efeitos financeiros/publicação/candidata/checkpoint/aceitação/implantação; visual/carga separados. Matriz v16 produto amplo incompleto.
|
||||
|
||||
- Autorização expressa10/11: «Autorizar o cadastro QA e os dois direitos apresentados», `docs/SGF_STOCK_CATALOGUE_SCOPE.md`/JSON. Criar/rever artigo e corrigir designação apenas dos novos, código/unidade imutáveis; stock.items.prepare QA2 e stock.items.review QA12, duas concessões por livro QA existente/quatro total. Um QA-CATALOGO-INTEGRADO/UN sem saldo inicial na8085. Sem artigos anteriores/movimentos/lançamentos/lotes/preços/SGC/demos/acessos reais/seis registos/cache/publicação/candidata/checkpoint/aceitação/implantação. Proposta anterior por autorizar é histórica; implementar/provar percurso inteiro sem pedir por campo/teste.
|
||||
|
||||
- Continuação10/11: `docs/SGF_RECEIVABLE_POSITIONS_DELIVERY.md`, Financeiro → Posições de clientes/vencimentos na mesma8085. SQL25/100, pesquisa/estado/vencimento, efeitos confirmados actuais e totais por obrigação, proposta sem saldo reconhecido, desconhecido sem atraso zero; referência não é corte histórico. Passagem ao detalhe conserva ID explícito e não substitui por outro se indisponível; CSV da página e marcador entre páginas.30 regressão/16 UI/15 HTTP, correcções de vencimento revertidas, zero POST de negócio8085/direitos novos; dados/binding/sr_* intactos, diário27/Caixa950/cálculos5/seis registos/cache preservados. Cobertura fiscal v2 corrige descrições antigas sem activar motores. Matriz v15 produto incompleto. Próxima decisão concreta: cadastro/revisão de artigos, `docs/SGF_STOCK_CATALOGUE_SCOPE.md`/JSON; stock.items.prepare QA2 e stock.items.review QA12, duas concessões por livro existente/quatro total, **ausentes/por autorizar**, nada concedido. Escrita SQL nativa existente não é autorização de negócio; ligação SELECT-only permanece igual. Sem demos/SGC/acessos reais/publicação/candidata/checkpoint/aceitação/implantação; visual/carga separados.
|
||||
|
||||
- Continuação10/11, `docs/SGF_ACCOUNTING_REPORTS_DELIVERY.md`, mesma8085: Contabilidade tem submenus distintos e Análise balancete/razão com datas explícitas, conta escolhida por linha,25/100, saldo progressivo/totais originais e CSV apenas da página/escapagem. Mudança de marcador entre páginas exige nova leitura; impressão é nova consulta, não arquivo imutável. Adaptador HTTP corrige conta ausente500→403 e argumento inválido→422; engine/writers nativos intactos. Cinco regressão/19 HTTP/14 UI,32 conjuntos/binding preservados; zero direitos/efeitos financeiros/SGC/demos/seis registos/cache/publicação/candidata/checkpoint/aceitação/implantação. Código/testes antes de actualizar apresentação; não acoplar cada passo à8085. Leitor composto conserva mutex anterior; sem alegação de optimização de carga. Matriz v14 produto amplo incompleto, visual separado.
|
||||
|
||||
- Continuação executiva10/11: início operacional e lista paginada de ordens entregues na mesma8085; `docs/SGF_OPERATIONAL_INFORMATION_DELIVERY.md`. Entrada descreve seis módulos; Início mostra percursos/evidências/funções autorizadas e consulta explícita das fontes existentes, sem totais inventados ou consultas entre módulos. Oficinas painel/equipa/relatórios usam novo GET `/api/workshop/orders`, SQL25/100 por pesquisa/estado, cliente/equipamento/responsável, apontamentos actuais/minutos, direito existente workshop.view e atribuição actual. Sem mutex de escrita durante esta leitura; teste regressão terminou0,093s enquanto primeira transacção mantinha empresa bloqueada. Writers/triggers e detalhe antigo preservados (detalhe ainda materializa universo).50 HTTP fontes/28 UI/11 entrada/três assets finais;29 regressão/11 HTTP/12 UI de ordens, fixtures27 revertidas, sem ordens novas na8085.32 conjuntos originais/binding intactos, diário27/Caixa950/cálculos5; zero concessões/efeitos financeiros/SGC/demos/seis registos/cache/publicação/candidata/checkpoint/aceitação/implantação. Matriz v13 mantém produto amplo incompleto, visual/carga próprios.
|
||||
|
||||
- Cálculo QA por linhas/imposto incluído entregue10/11, `docs/SGF_MULTI_LINE_INCLUDED_DELIVERY.md`, mesma8085 `/fiscal/rules`.1–25linhas, modalidade/regra explícitas, imposto truncado por linha/base incluída residual, exactos/diferenças preservados. Três cálculos novos1000+160=1160 (sem/incluído) e86,21+13,79=100; dois antigos intactos.34 regressão/três recuperação/29 HTTP/16 DOM→HTTP=80 verificações, mais concorrência mesma chave503 após5,17s→200 replay/um cálculo; retirada de regra recusa409.26 conjuntos originais/binding e concessões intactos; diário27/Caixa950/IVA4/regras4/bancos7/indicadores4+2. Zero direitos/esquema/efeitos financeiros novos. Config root desligada, apenas QA aprovado na8085. Sem emissão/dívida/pagamento/SGC/demos/seis registos/cache/publicação/candidata/checkpoint/aceitação/implantação; visual/carga/fiscalidade geral separados. Indicações anteriores por autorizar/não implementado são históricas.
|
||||
|
||||
- Autorização expressa posterior10/11: «Autorizar a modalidade QA e a política apresentada», `docs/SGF_MULTI_LINE_INCLUDED_SCOPE.md`/JSON.1–25linhas, preço sem/com imposto, truncagem do imposto por linha, base documental incluída=bruto−imposto documental, exactos/diferenças racionais preservados, soma documental de linhas.100 incluído16%→86,21+13,79, diferenças9/2900; base não-cent no preço acrescido e bruto não-cent incluído ficam pendentes. Reutilizar fiscal_rules.view/prepare/review sem concessões; só cálculo/mapa interno nas mesmas8085/regressão, sem emissão/dívida/pagamento/lançamento/activação real/SGC/demos. Preservar27 lançamentos/Caixa950/IVA4/regras4/dois cálculos antigos/bancos7/indicadores4+2/seis registos/cache/provas/trabalho alheio. Não pedir aprovação por campo/teste do bloco. Sem aceitação/checkpoint/publicação/implantação. Indicação anterior por autorizar é histórica.
|
||||
|
||||
- Próxima decisão preparada após indicadores: `docs/SGF_MULTI_LINE_INCLUDED_SCOPE.md`/JSON, preço por1–25linhas e modo imposto incluído, reutilizando direitos fiscais existentes (zero concessões). Propõe truncagem de imposto por linha, base documental incluída=bruto−imposto documental, exactos/diferenças racionais preservados e soma de linhas. Casos400+600→base1000/IVA160/1160;464+696 incluídos idem;100 incluído→86,21+13,79 com diferenças9/2900. **Política/modalidade por autorizar, não implementada no endpoint**; protótipo matemático sem base apenas demonstra números. Não inferir esta decomposição do antigo caso de base simples. Sem emissão/dívida/pagamento/lançamento/SGC/demos/acessos reais/seis registos/cache, preservar todos os exemplos.
|
||||
|
||||
- Indicadores QA entregues10/11, `docs/SGF_FINANCIAL_INDICATORS_DELIVERY.md`, mesma8085 `/finance/indicators`: quatro eventos de critério13/14 e dois resultados conservados,950/100=19/2→9,50vezes;50/1000=1/20→5,00%. Fonte composta efectiva Caixa fechada/diário e saldo credor explícito, sem valor absoluto/critério automático/parecer real.41 regressão/12 SQL e fronteiras (origem artificial só no harness),29 HTTP efectivo/16 DOM→HTTP zero confirmações =98 verificações, mais concorrência503→409/uma revisão.23 conjuntos originais/binding e27 lançamentos/Caixa950/IVA4/regras4+cálculos2/bancos7 intactos. Sete concessões por QA exactas aplicadas. Rootflag desligada. Matriz v11 mantém critérios reais/rácios gerais/séries/modelos/motores legais pendentes. Sem novas transacções/efeitos financeiros/SGC/demos/acessos reais/seis registos/cache/reabertura/publicação/candidata/aceitação/checkpoint/implantação; progresso HTTP guardado e provas válidas reutilizadas.
|
||||
|
||||
- Autorização expressa posterior10/11: «Autorizar os indicadores QA e os direitos apresentados», `docs/SGF_FINANCIAL_INDICATORS_SCOPE.md`/JSON de acesso. Três direitos financial_analysis.view/prepare/review, sete concessões por QA existente aos perfis13–17. Critérios13/revisão14/cálculo explícito/fontes/histórico/relatório dos indicadores limitados950/100=9,5 e50/1000=5%; não rácios gerais ou recomendação real. Origem artificial apenas no harness da regressão, HTTP8085 fontes efectivas. Implementar/provar/disponibilizar limitadamente no mesmo livro sem pedir por campo/teste. Preservar27 lançamentos/Caixa950/IVA4/regras4+cálculos2/bancos7/seis registos/cache/trabalho alheio. Sem novas transacções/efeitos financeiros, critérios/acessos reais, SGC/demos/bases/portas, reabertura/publicação/candidata/checkpoint/aceitação/implantação. Indicação anterior por autorizar é histórica.
|
||||
|
||||
- Próxima lacuna preparada após bancos: `docs/SGF_FINANCIAL_INDICATORS_SCOPE.md`/JSON de acesso, critérios/indicadores QA950/100=9,5 vezes (Caixa/credor QA-FORNECEDOR) e50/1000=5% (despesa/entradas da Caixa), retratos/fontes/revisão distinta/histórico. Três direitos financial_analysis.view/prepare/review, sete concessões por QA existente aos perfis13–17: **por autorizar/não aplicados**. Não presumir poder de configurar critérios dos direitos de Caixa/bancos/fiscal/GL, nem adequação contabilística real ou rácio geral de liquidez/margem. Preservar27 lançamentos/Caixa950/IVA4/regras4+cálculos2/bancos7/seis registos/cache/demos; sem SGC/novos efeitos financeiros/operações ou acessos reais/reabertura/publicação. Origem artificial no harness da regressão explicitada; HTTP8085 provará fontes reais do QA.
|
||||
|
||||
- Conferência bancária QA entregue10/11, `docs/SGF_BANK_RECONCILIATION_DELIVERY.md`, mesma8085 `/finance/bank`: um CSV artificial0−20+20=0, duas linhas de extracto, sete eventos conservados, correspondências13→14 individuais com linhas GL4/5 dos diários2/3 já existentes. Leitor nativo sem fallback; não declara QA-LIQUIDACAO conta bancária real.37 regressão/11 SQL e fronteiras (origem artificial injectada só no harness),28 HTTP nativo/15 DOM→HTTP zeroPOSTs/três relatório =94 verificações, mais concorrência503→409/uma revisão.20 conjuntos originais/binding e27 lançamentos/Caixa950/IVA4/regras4+cálculos2 intactos. Oito concessões por QA exactas aplicadas. Rootflag desligada, binding privado. Extracto finalizado sem reabertura, relatório derivado/checksum; importador geral/correcção de extracto recusado/arquivo binário legal não entregues. Matriz v10 mantém produto amplo incompleto. Sem novas bases/portas/demos/SGC/pagamentos/lançamentos/acessos reais/seis registos/cache/publicação/candidata/aceitação/checkpoint/implantação. Progresso HTTP guardado; não repetir confirmações válidas.
|
||||
|
||||
- Autorização expressa posterior10/11: «Autorizar a conferência bancária QA e os direitos apresentados», `docs/SGF_BANK_RECONCILIATION_SCOPE.md`/JSON de acesso. Quatro direitos bank_reconciliation.view/import/prepare/review, oito concessões por QA existente aos perfis13–17. Extracto artificial0−20+20=0, importação/revisão distinta/correspondências individuais com duas linhas QA existentes20/20 na8085/relatório. Origem artificial no harness da regressão explicitada, leitor de produção sem fallback; HTTP8085 prova fontes efectivas. Implementar/provar/disponibilizar limitadamente sem pedir por campo/teste. Preservar27 lançamentos/Caixa950/IVA4/regras4+cálculos2/seis registos/cache/trabalho alheio. Sem novos pagamentos/lançamentos, natureza bancária real da conta de liquidação, SGC/bancos/APIs/credenciais reais, demos/bases/portas, reabertura, publicação/candidata/checkpoint/aceitação/implantação. Indicação anterior por autorizar é histórica.
|
||||
|
||||
- Próxima lacuna preparada após regras: `docs/SGF_BANK_RECONCILIATION_SCOPE.md`/JSON de acesso, conferência de extracto artificial0−20+20=0 pelas duas linhas de liquidação já existentes (diários2/3, contaQA-LIQUIDACAO; não se declara conta bancária real). Quatro direitos bank_reconciliation.view/import/prepare/review, oito concessões por QA existente aos perfis13–17: **por autorizar/não aplicados**. Importação, revisão distinta, correspondências individuais/fundamento e relatório; sem novo pagamento/lançamento ou associação automática. Preservar27 lançamentos/Caixa950/IVA4/regras4+cálculos2/seis registos/cache/demos; sem SGC/bancos reais/reabertura/publicação. Não inferir estes poderes de Caixa/recebíveis/regras.
|
||||
|
||||
- Núcleo comum de regras QA entregue10/11, `docs/SGF_FISCAL_RULES_COMMON_DELIVERY.md`, mesma8085 `/fiscal/rules`. Quatro eventos de regra e dois cálculos conservados:1000×16%=160/1160 em30/11 e sucessora artificial17%=170/1170 em01/12; sem alteração legal. Avaliador declarativo do portal reutilizado localmente por SHA, limites24/8 e base16/8, sem dependência runtime. Só dois DAGs QA fixados executam; referências legais pendentes não se activam.39 regressão/oito paridade/14 SQL e fronteiras/24 HTTP/14 DOM→HTTP/três entrada,102 verificações, mais concorrência503→409/uma revisão.17 conjuntos originais/binding e27 lançamentos/Caixa950/quatro versões IVA intactos. Sete concessões exactas por QA aplicadas após aprovação. Rootflag desligada; sourceSHA privada. Sem demos/SGC/novos efeitos financeiros/acessos reais/seis registos/cache/publicação/candidata/aceitação/checkpoint/implantação. Reutilizar provas, não repetir confirmações; progresso HTTP guardado. Restantes motores fiscais/modelos/calendário/remunerações/reconciliação/rácios pendentes na matriz v9.
|
||||
|
||||
- Autorização expressa posterior10/11: «Autorizar o núcleo comum QA e os direitos apresentados», `docs/SGF_FISCAL_RULES_COMMON_SCOPE.md`/JSON de acesso. Três direitos fiscal_rules.view/prepare/review, sete concessões por QA existente aos perfis13–17 discriminados. Catálogo/revisão distinta/cálculo explicável por versão/vigência, caso artificial1000×16%=160 até30/11 e sucessor artificial17%=170 desde01/12, sem mudança legal presumida. Implementar/provar/disponibilizar limitadamente na mesma8085/regressão; sem novo pedido por campo/fronteira/teste do contrato. Preservar27 lançamentos/Caixa950/quatro versões IVA/documentos/seis registos/cache/trabalho alheio. Sem outros motores legais/folha salarial/acessos reais/novos efeitos financeiros/SGC/demos/bases/portas/publicação/candidata/checkpoint/aceitação ou capacidade de implantação. Indicação anterior por autorizar é histórica.
|
||||
|
||||
- Próximo bloco preparado após IVA QA: `docs/SGF_FISCAL_RULES_COMMON_SCOPE.md`/JSON de acesso, núcleo comum de fontes/regras versionadas e cálculo artificial1000×16%=160 até30/11, sucessor artificial17%=170 desde01/12, sem afirmação legal ou alteração de documento existente. Três direitos fiscal_rules.view/prepare/review, sete concessões por QA existente aos perfis13–17: **por autorizar e não aplicados**. Não inferir da autorização de livro/mapa poder de manter regras transversais. A proposta não conclui motores IRPS/IRPC/ISPC/ICE/INSS/modelos oficiais/remunerações. Mesma8085/regressão, preservar27 lançamentos/Caixa950/quatro versões IVA/seis registos/cache, sem demos/SGC/novos efeitos financeiros/publicação.
|
||||
|
||||
- Livro/mapa IVA QA entregue10/11, `docs/SGF_IVA_ASSESSMENT_DELIVERY.md`, mesma8085 `/fiscal/iva`: FR existente1000/base862,07/IVA137,93 escolhido, livro/mapa preparados13 e revistos14; quatro versões conservadas/CSV congelado/SHA, dedução e imposto a pagar indeterminados.38 regressão revertidas, concorrência503→409/uma revisão (harness POST corrigido, evidência inicial conservada só na regressão), dez SQL/paginação revertidas,23 HTTP/quatro confirmações e14 DOM→HTTP/zero confirmações.15 conjuntos originais e27 lançamentos intactos; Caixa950 fechada/período aberto.14 concessões exactas por QA aplicadas conforme aprovação; rootflag desligada. Matriz v8 continua produto amplo incompleto: mapa interno não é Modelo A oficial, IVA geral/IRPS/IRPC/ISPC/ICE/INSS/calendário/reconciliação/rácios pendentes. Sem seis registos/cache/demos/SGC/novos efeitos financeiros/direitos reais/publicação/candidata/checkpoint/aceitação ou implantação. Não repetir provas válidas nem confirmações; progresso HTTP guardado.
|
||||
|
||||
- Autorização expressa posterior10/11: «Autorizar o livro/mapa IVA QA e os direitos apresentados», conforme `docs/SGF_IVA_ASSESSMENT_NEXT_SCOPE.md`/JSON de acesso. Seis direitos fiscal_assessments.view/prepare/review e fiscal_forms.view/prepare/review,14 concessões por base QA existente aos perfis13–17 discriminados. Ciclo completo origem FR1000/base862,07/imposto137,93 → livro revisto distintamente → mapa interno QA preparado/revisto/recuperável, sem apuramento completo/declaração oficial. Implementar/provar e disponibilizar limitadamente na8085/regressão existentes, sem repetir autorização por campo/teste; preservar27 lançamentos, Caixa950 fechada, seis registos/cache/provas/trabalho alheio. Sem novos motores fiscais, lançamentos, acessos/operações reais, SGC, demos/bases/portas, publicação/candidata/checkpoint/aceitação ou capacidade de implantação. A indicação anterior «por autorizar» é histórica.
|
||||
|
||||
- Continuação10/11: conferência do documento de impostos identifica backlog amplo FIS-SGF13–19 por concluir, separado do fecho QA dos seis módulos. `docs/SGF_FISCAL_COVERAGE_DELIVERY.md`: painel de cobertura no leitor fiscal existente, protecção de respostas atrasadas/contexto e rodapé corrigido; duas funções Financeiras já autorizadas visíveis no dropdown Contabilidade, sem concessões.17 regressão/10 HTTP/sete renderização/três contexto/três entrada;11 tabelas e diário27 intactos. Inventário12 fontes do portal por hash sem transferência/activação legal nem dependência runtime. Matriz v7 explicita motores/modelos/reconciliação/rácios pendentes. Próximo bloco `docs/SGF_IVA_ASSESSMENT_NEXT_SCOPE.md`, seis direitos/14 concessões por base QA existente conforme JSON de acesso: **por autorizar, não aplicado**. Sem novos direitos/schema/demos/SGC/cache/associações/publicação/checkpoint. Uma tentativa da aba integrada por URL conhecido terminou em timeout30s; não repetir ou contornar, visual pendente.
|
||||
|
||||
- Continuação executiva10/11: relatório Produtos sem movimento entregue na mesma8085, `docs/SGF_STOCK_INACTIVITY_DELIVERY.md`. Corte UTC/dias explícitos, pares com história conhecida, quantidade exacta, filtro literal/armazém e SQL25/100; cinco SELECTs de projecção, revisão/contexto600s.23 provas de regressão (fixtures SQL de relatório, não workflows),15 HTTP SELECT-only e sete cliente; metadados/movimentos/diário27 da8085 intactos. Sem novos direitos/roles/bases/demos/índices/writers/SGC/cache/publicação/candidata/aceitação/checkpoint. Seis registos preservados. Carga/visual/implantação separados. Matriz v6 acrescenta consulta sem alterar aceitação histórica.
|
||||
|
||||
- Caixa QA entregue na mesma8085 após aprovação, `docs/SGF_CASH_DELIVERY.md`: abertura0, custódia explicitamente classificada do recebimento existente1000, despesa50, contagem/revisão/fecho950; nove eventos append-only, declarante13/revisor14 distintos. Nenhuma factura/recebível/afectação nova ou modificada. Contabilidade15/16 reclassifica1000 e reconhece50 em duas origens individuais no mesmo writer; diário27 equilibrado e25 anteriores idênticos.23 verificações revertidas, concorrência503→409/uma classificação só na regressão, sete fronteiras/intervalos,21 HTTP incluindo duas páginas25+2 do diário e recibo recuperado noutra sessão,14 DOM→HTTP exclusivamente consulta/recuperação (zero POSTs), oito de período com38 factos/zero pendentes e fecho em rollback; Caixa conservada fechada, período QA aberto sem reabertura. Quatro direitos/oito concessões aplicados exactamente nas duas bases QA existentes aos perfis13/14/15/16/17. Configuração root desligada; só8085 seleccionada disponibilizada após provas. Backups/dados privados fora do repositório, comparação inclui documentos/movimentos/SR/binding. Seis registos/cache/demos/trabalho alheio intactos; sem SGC, dinheiro/acessos reais, bancos/POS, ajuste automático/reabertura/publicação/candidata/checkpoint/aceitação ou capacidade de implantação. Referências de que Caixa aguarda autorização/não implementada são históricas. Visual/carga/conformidade/implantação continuam separados.
|
||||
|
||||
- Autorização expressa posterior10/11/2026: «Autorizar o ciclo Caixa QA e os direitos apresentados», `docs/SGF_CASH_NEXT_SCOPE.md`/`docs/integration/sgf-cash-access-proposal.json`. Quatro direitos cash.view/prepare/review/close, oito concessões por base QA existente aos perfis13/14/15/16/17 discriminados; sem nova função nativa/PGrole/acesso real. Ciclo0+classificação explícita de recebimento fictício existente1000−despesa50=contagem/fecho950, preparação/revisão distintas, duas origens no mesmo diário e contas QA-CAIXA/QA-DESPESA-CAIXA. Implementar/provar e disponibilizar limitadamente na mesma8085; preservar25 lançamentos, seis registos/cache/trabalho alheio. Sem novas demos/bases/portas, dinheiro real/banco/SGC, ajuste automático, reabertura, publicação/candidata/checkpoint/aceitação. Não repetir pedido por campo/teste deste bloco.
|
||||
|
||||
- Continuação executiva10/11/2026: registo unificado entregue na8085, `docs/SGF_BILLING_REGISTER_DELIVERY.md`. Comercial → Todos os documentos/Facturas inclui seis famílias autorizadas, factura-recibo sem duplicar factura técnica, filtros anteriores no servidor, páginas25/100, bases canónicas e saldos apenas da página/com direito financeiro; sem somar documentos heterogéneos.33 verificações em regressão com127 metadados e uma fixture estrangeira revertidos;16 HTTP, diário25 intacto, nenhum comando de negócio/direito/schema/índice novo. Componentes carregados diferidamente; visual não presumido. Próxima lacuna identificada: Caixa, não há motor/concessões existentes. `docs/SGF_CASH_NEXT_SCOPE.md`/`docs/integration/sgf-cash-access-proposal.json` apresentam quatro direitos/oito concessões QA por base e ciclo0+1000−50=950, duas origens no mesmo diário; **aguarda autorização, não implementado nem concedido**. Não presumir aprovação a partir da directiva executiva; apresentar novos direitos antes de aplicar. Mesma8085/regressão, seis registos/cache/provas/trabalho alheio preservados; sem demos/SGC/publicação/candidata/checkpoint/aceitação.
|
||||
|
||||
- Continuação após as dez consultas autorizadas10/11/2026: `docs/SGF_SERVICE_DOCUMENTS_DELIVERY.md` entregue na mesma8085. Recibo ligado à afectação efectiva400, crédito nominal100 sobre factura nova1000 deixando500, factura-recibo de outra origem1000/recebimento/afectação atómicos deixando0; PDFs congelados/SHA e três versões por documento. Mantém emissor/recebíveis originais, declarante13 e executor14 distintos sem trocar identidade. Diário25 equilibrado (cinco origens novas),29 factos/zero pendências no período; fecho apenas em rollback, instalação aberta. Anulação técnica preparada/revista distintamente: reverte afectação ou redução sem apagar factura/PDF, sem reembolso; recusa período fechado, novos efeitos exigem seus lançamentos.17 verificações de reversão em rollback,20 documentos/diário, concorrência503→409/uma emissão,21 HTTP,19 DOM→HTTP com confirmação simulada e oito de período. Exemplos emitidos conservados; zero anulações confirmadas na8085. Dez consultas aplicadas só nos dois perfis QA das duas bases existentes; bloqueio de direitos anterior ultrapassado. Originais18/clientes/facturas/movimentos/binding comparados intactos; seis registos/cache não tocados. Sem SGC, nova demo, publicação/candidata, direitos/operações reais, aceitação/checkpoint ou capacidade de implantação presumidos. Visual/carga/conformidade real permanecem separados. Notas anteriores de documentos candidatos/bloqueio são históricas.
|
||||
|
||||
- Autorização posterior10/11/2026: «Autorizar as10 concessões de consulta QA», conforme `docs/integration/sgf-document-read-access-proposal.json`. Apenas os perfis Financeiros QA13/14 existentes nas duas bases QA existentes, dez concessões/seis nomes de consulta discriminados, sem edição fiscal/novos perfis/acessos reais/PUBLIC. Retomar e validar a factura-recibo do bloco já autorizado; não voltar a pedir esta decisão. Mesma8085, sem novas demos/SGC/publicação, seis registos/cache preservados.
|
||||
|
||||
- Continuação10/11/2026, após a autorização do bloco: agenda/reagendamento e cadastro/reutilização de equipamentos entregues na8085 com direitos existentes; orçamentos Comercial1000/Oficina500 com revisão distinta, aceitação fictícia documentada e conversão explícita em rascunhos, sem emissão por aceitar. Ver `docs/SGF_WORKSHOP_AGENDA_EQUIPMENT.md`. Políticas Stock e devolução ao fornecedor entregues na8085, ver `docs/SGF_STOCK_POLICIES_SUPPLIER_RETURN.md`: artigo QA novo5, entrada10UN/200, saída2UN/40 pelo writer nativo, saldo8UN/160. Diário passa18→20 equilibrado;18 lançamentos/movimentos/clientes/facturas originais comparados e intactos.22 provas Stock revertidas, concorrência503 durante lock de tenant nativo e409 após libertação sem duplicação,11 HTTP e17 DOM→HTTP (confirmação simulada, prévia real revertida); não confundir DOM com visual. Direitos do bloco exacto aplicados apenas nas duas bases QA existentes. Preservar antigos livros/seis registos/cache e trabalho alheio; sem publicação, SGC, nova demo ou checkpoint/aceitação presumidos.
|
||||
|
||||
- Documentos adicionais desse bloco continuam candidatos **não instalados nem activados na8085**: Recibo400 e redução100 da nova factura1000 deixando500 passaram16 verificações em rollback na regressão, incluindo versões/permissões/duplicação/falha e recuperação; factura-recibo ainda sem prova positiva/HTTP/UI/ligação integral ao diário. Bloqueio concreto:10 concessões de consulta existentes ausentes nos perfis Financeiros13/14, fora da lista exacta anteriormente aprovada; ver `docs/SGF_DOCUMENT_READ_ACCESS.md` e `docs/integration/sgf-document-read-access-proposal.json` (approved=false). Não conceder implicitamente, não contornar as guardas documentais nem usar identidade de outro actor; implementação de finalização preparada ainda não validada e desligada. Revisão visual, carga sustentada e implantação continuam separados.
|
||||
|
||||
- Autorização executiva posterior10/11/2026 «Autorizar o bloco QA e os direitos discriminados», `docs/SGF_NEXT_BLOCK_RIGHTS.md`/`docs/integration/sgf-remaining-functions-access-proposal.json`: orçamentos Comercial/Oficina com revisão/aceitação documentada/conversão explícita, recibo/nota de crédito/factura-recibo de teste e respectivas ligações ao recebível/mesmo diário, políticas/mínimos Stock e devolução física ao fornecedor pelos motores originais.15 novos direitos host, service_invoices.issue adicional a finance_review, oito concessões nativas (seis nomes existentes) apenas nos perfis QA discriminados e empresa2/unidade2; sem warehouse genérico ao revisor. Novas fontes fictícias: quoteComercial1000/Oficina500; nova factura1000/recebimento400/crédito100/saldo500; outra factura-recibo1000; artigo novo10UN/taxa20/devolução2UN40/saldo8UN160. Mesma8085 e regressão existente, sem novas demos/bases/portas/perfis/rolesPG. Preservar18 lançamentos/exemplos anteriores/seis registos/cache/trabalho alheio; sem direitos/operações reais, fiscalidade certificada/AT/banco/envio de mensagens, reabertura, publicação/candidata/checkpoint/aceitação presumidos. Abrange implementação/provas e disponibilização limitada após testes; não pedir nova aprovação por cada campo/teste deste mesmo bloco.
|
||||
|
||||
- Directiva de navegação posterior10/11/2026: utilizador pediu módulos horizontalmente e «use o dropdown para melhor facilidade». Entrada8085 `/sgf` reorganizada em seis dropdowns, consulta directa e funções QA por módulo;17 perfis originais preservados em secção separada. ShellReact também tem dropdown por módulo com funções autorizadas, Início/opções gerais separados; escolha de função muda explicitamente para o módulo escolhido, inclusive cadastros partilhados, pelo mesmo ContextTransition/POST de contexto e guardas de formulários/confirmações. Sem novas concessões ou comandos de negócio. Utilizador disse «agora consigo entrar» após a dificuldade inicial; não equivale a aceitação visual dos cinco módulos ou dos novos dropdowns. Guião actualizado; conferir prova dirigida de menus, não reexecutar percursos financeiros/Compras por alteração de navegação.
|
||||
|
||||
- Consolidação final funcional10/11/2026, `docs/SGF_SIX_MODULE_CLOSURE_20261110.md` e `docs/SGF_GUIAO_APRESENTACAO.md`: Oficina–Stock/devolução/valorização100/50/25 e Oficina–Comercial500 efectivas na8085; trânsito1UN recebido0,4/0,6 distintamente, propriedade1,5UN/75 conservada; novo artigo de lotes4→3,5UN/70, diferença10 e entrada80 pelo mesmo diário.18 lançamentos/15 contas;16 anteriores idênticos, Stock145MZN. Fecho QA completo18factos/zero pendentes executado com rollback, período aberto sem reabertura.27 provas trânsito/lotes, concorrência dois processos503 durante bloqueio da linha de autor host→409 versão antiga após libertação→nova conferência restante sem duplicação;6 reconciliação,6 recuperação/retorno,12 preservação,20 fronteirasHTTP,10DOM→HTTP (confirmação simulada)/9 cliente. Não somar passagens repetidas ou antigas. Modo nativo de perfis QA activado com funções legadas preservadas, sete concessões e dois direitos apenas QA aprovados; sem warehouse genérico ao revisor, PGroles/PUBLIC/acessos reais. Arranque/reinício PHP29tabelas anterior reutilizado; não repetir. Utilizador confirmou «Abre e está legível» para novo Stock; isso não é revisão dos restantes ecrãs nem aceitação integral. Controlo de navegador interrompido por URL indeterminado, não contornar. Preservar seis registos/cache/Compras/provas/trabalho alheio; sem demos/SGC/publicação/candidata/checkpoint. Carga sustentada/implantação/reinícioOS e funcionalidades opcionais fora da matriz conservam limites próprios; não tratar as funcionalidades acima como por implementar.
|
||||
|
||||
- Autorização expressa de10/11/2026 «Autorizar este fecho QA de Stock», `docs/SGF_STOCK_FINAL_SCOPE.md` e `docs/integration/sgf-stock-final-access-proposal.json`: concluir trânsito com recepção distinta e lotes/contagem/ajuste valorizado na mesma8085 e regressão existente. Dois novos direitos SGF apenas QA (`stock.transfers.receive` destino1 e `stock.lots.review` novo artigo/armazém3) para workshop_verify, sete funções nativas discriminadas para actores6/3, sem função genérica warehouse para revisor. Transferir1UN mantendo propriedade1,5UN/75; novo artigo4UN→3,5UN, taxa20, valor80→70 e diferença10. Mesmo livro físico/diário, revisão distinta e fecho com rollback; preservar16 lançamentos anteriores, C01–C10, seis registos/cache/provas/trabalho alheio. Sem novas demos/bases/portas/rolesPG/acessos reais/SGC/publicação/candidataCompras/checkpoint. Autoriza implementação/concessões QA/dados/regras e disponibilização limitada após provas, sem nova aprovação por campo/teste. Visual continua separado e bloqueio do controlo de navegador não será contornado.
|
||||
|
||||
- Adenda da mesma continuação10/11/2026: catálogo de origens do diário8085 agora paginado25/100 no servidor, filtro por família e escolha individual, `ComposedLedgerCandidates`; três ficheiros instalados com backup.127 fixtures de metadados e uma estrangeira revertidas na regressão, oito páginas/dois SELECTs por página,275 asserções (não275 percursos), duas leituras HTTP da8085 aprovadas. Não repetir provas anteriores por rotina. Stock filtra tipo após página física, podendo ter página vazia. FechoQA/ligação física Oficinas–Stock/devolução/valorização seleccionada e demais limitações do relatório continuam pendentes; a autorização vigente abrange continuar esses trabalhos sem nova decisão por campo/teste.
|
||||
|
||||
- Continuação autorizada de10/11/2026, `docs/SGF_SINGLE_INTEGRATION_20261110.md`: entrada única8085 `/sgf`, dez perfis QA mais sete Compras preservados, flags SGF limitadas activadas após provas dirigidas. Comercial1000, recebível400/600/reversão400; obrigação nativa original100→80→100 pela afectação/reversão20; custo275→295 e industrial1800→1820, residual300 separado.13 lançamentos equilibrados no mesmo livro nativo, âmbito SGF1/2/2 explicitamente ligado à origem financeira1/0/0, sem dívida no host. Operações financeiras e13 lançamentos por HTTP8085 com identidades distintas; preparação das fontes pelos workflows, não revisão visual.92 verificações dirigidas indexadas; provas anteriores reutilizadas. Autoload privado corrigido;52 ficheiros/build privado instalados com backup, bootstrapSGC/pacote fixado Compras intactos. Stock operações disponível, mas Oficinas→mesmo livro físico/devolução e cenário2/-1/+0,5/valorização ainda pendentes; não presumir essa ligação nem fechoQA. Origens materiais artificiais separadas por perímetro privado fixado; desconhecidas/partilhadas bloqueiam. Catálogo de origens contabilísticas além25 e atalhos antigos ainda exigem continuidade, visual/arranque/implantação separados. Preservar seis registos/cache/exemplos/provas/trabalho alheio; sem novos acessos reais/rolesPG/bases/portas/demos/publicação/candidataCompras/SGC/checkpoint. A autorização integral anterior continua vigente; não pedir outra decisão por cada campo/teste do mesmo recorte.
|
||||
|
||||
- Autorização expressa de10/11/2026 «autorizo» ao recorte integral `docs/SGF_8085_NEXT_AUTHORIZATION.md` e matriz `docs/integration/sgf-8085-access-proposal.json`: dez perfis exclusivamente de aplicação QA na empresa2/unidade2; novos direitos Stock propostos e restantes direitos existentes discriminados, segundo armazém/artigo sintéticos; preparação dos documentos de teste; continuação da obrigação original100MZN com liquidação/afectação20, reversão e Contabilidade; cenários Comercial1000/400+600, custo275→295, industrial1800→1820 e stock2UN/100,-1UN/50,+0,5UN/25 na mesma8085. Um diário comum no livro financeiro nativo existente com âmbito SGF explícito, sem copiar dívida e sem serviço SGC. Contas/período/revisão/fecho QA e activação limitada após provas afectadas, sem presumir aceitação. Substitui as exclusões anteriores apenas neste recorte sintético; não pedir nova aprovação por cada campo/teste do mesmo recorte. Preservar seis registos, cache bloqueada, provas, exemplos anteriores e trabalho alheio; sem roles PostgreSQL/contas/acessos reais, dinheiro/banco/emissão fiscal, direitos públicos, reabertura, nova base/porta/demo, publicação ou candidata de Compras. Visual e capacidade de implantação exigem evidência própria.
|
||||
|
||||
- Pedido expresso de10/11/2026 «concluir e integrar os seis módulos na instalação8085»:70 tabelas aditivas instaladas no host existentecc50a276c10c por `scripts/sgf-compose-install.php`, com dump/cópias privados, preservação de linhas/sequências/permissões/associações;44 funções novas sem PUBLIC EXECUTE. Código/guardas instalados com `sgf_single_installation.enabled=false`; nenhuma conta/concessão/efeito financeiro criado. Stock código instalado inactivo145dfc19fd0971639778, acrescentando contagem sem lotes/aplicação de diferença/equipamento;38PG/15DOM e seis HTTP dirigidos, sem repetir percurso de negócio.32 provas de schema e nove de ligação Comercial→Oficinas reutilizadas; livro de regressãob409 também composto sem base/servidor novo.8085 estava parada e foi novamente iniciada na mesma porta; `/compras`200, recepção e obrigação100MZN preservadas. Build privado válido reutilizado, cache não limpa. Guardas SQL presas ao DB/role/cluster exactos; resets/destruição recusados por evento antes de executar comando. Ver `docs/proofs/sgf-six-module-composition-20261110.json`. **Pendente de decisão**, não presumir aprovação: `docs/SGF_8085_NEXT_AUTHORIZATION.md`/matriz `docs/integration/sgf-8085-access-proposal.json`: dez perfis de aplicação QA, dois direitos novosStock, segundo armazém/artigo de ensaio e continuação sintética financeira/contabilística da obrigação100MZN antes excluída. Diário único deve conservar obrigação nativa, sem copiar dívida para host ou exigir serviço SGC. Seis registos/cache/provas/direitos reais preservados; sem activação/publicação/checkpoint. Stock por lotes/devoluções, composição financeira, visual e capacidade de implantação não declarados concluídos.
|
||||
|
||||
- Continuação de10/11/2026: código de saídas/transferências SGF preparado, `docs/SGF_STOCK_OPERATIONS_DELIVERY.md`. Mesmo `StockWorkflow`/writer original fixado; prévia revertida, saldo/contexto/impacto revalidados, confirmação e recibo auditado atómicos, recuperação sem duplicação.30 verificações PG no livro existenteb4093c6f6707, todas as linhas repostas por rollback, sequências apenas QA podem avançar;11 DOM dirigidas, sem revisão visual. Nenhuma alteração/instalação/activação na8085, base/porta/demo nova ou acesso real. Dois direitos novos **apenas propostos**: `stock.operations.prepare` e `stock.operations.confirm`, por empresa/unidade/armazém; testados só em transacção revertida, sem concessão persistida. Flag `stock_operations.enabled=false`, mapa adicional opcional fixado por SHA sem alterar bindings Compras. Transferência imediata atómica não é recepção em trânsito. Contagens/ajustes/devoluções/selector de equipamento e composição integral continuam lacunas explícitas; teste concorrente de dois processos, carga/visual/implantação não declarados. Preservar seis registos/cache/provas/trabalho alheio e não retomar o incidente de navegador Portal como dependência do SGF.
|
||||
|
||||
- Directiva prioritária de10/11/2026: **concluir todos os seis módulos SGF e entregar uma solução autónoma para apresentar a potenciais clientes**, `docs/SGF_SOLUTION_DELIVERY.md` e contrato `docs/integration/sgf-solution-contract.json`. Não retomar SGC/conector como prioridade ou dependência do produto. Uma instalação, uma entrada, mesma empresa/unidade, Comercial/Compras/Stock/Oficinas/Financeiro/Contabilidade funcionais e percursos completos; menus/catálogos/fontes/testes isolados não equivalem a fecho. Baseline READ ONLY de8085 confirmou estruturas de host em falta em quatro módulos e UI/operações de Stock/Contabilidade incompletas; só a prova `sgf-solution-closure-baseline-final-20261110.json` é válida (primeira projecção tinha nomes de tabela/prova incorrectos, corrigidos contra declarações reais). `scripts/check-sgf-solution.mjs` é gate interno e devolve2 sem aceitação integral, nunca uma declaração automática de implantação. Prioridade: composição/schema/guardas/perfis → fechar funcionalidades → percursos integrados → visual/arranque/recuperação/guião → aceitação única. Preservar seis registos/cache/provas/trabalho alheio/direitos reais, sem novas demonstrações/publicação/activação implícita. Meta não é mais uma entrega parcial de conector nem apenas documentação.
|
||||
|
||||
- Continuação de10/11/2026 expressamente autorizada: sourceSGC ecrã/persistência auditados entregues, `docs/SGC_INVOICE_WORKSPACE_DELIVERY.md`; novo pacote inactivo3bb99e866f52965dbb7d (8SGF/15cliente-SGC), antigo preservado.20 casos únicos/142 asserções SQLite+14 verificações PG entre sourcebookb4093c6f6707 e receiverda77bb730751 existentes. Duas facturas pendentes por writer original, recuperação após commit/resposta perdida e concorrência uma decisão/envio+409; nenhum consumo/stock/pagamento extra. SóQA recebeu duas tabelas de rascunho/eventos; migraçãoSGC000001 de10/11 NÃO aplicada no piloto. Sourcewriter exclui documento reservado, intenção/decisão/cut persistidos antes da rede, lease120s e auditappend-only. Flagsworkspace/transportefalse, mapeamentos/principais não escolhidos, sem novos direitos reais/creds/demo/portas/bases.8085 código/estado/dados/sequências, seis registos/cache/provas/trabalho alheio preservados. Tipos/compilaçãoTSXprivada/Blade passaram. Não reabrir ecrã/persistência como lacuna: próximos passos são instalação exacta/configuração/activação aprovadas e ensaioTCP na mesma8085, visual/carga/implantação separados. Não declarar todoSGF concluído ou activar/publicar/criar checkpoint por inferência.
|
||||
|
||||
- Preparação expressa de10/11/2026 da ligação SGC→8085, ver `docs/SGC_8085_INTEGRATION_PREPARATION.md`: cliente portátil/gateway SGC desligados, mapeamentos/principal não escolhidos e pacote de revisão `d09aac9d09da98e61df5` fora da aplicação (oito ficheiros de endpoint/três do cliente). Não instalado/activado; `prepare-sgc-8085.mjs` só check/prepare, sem aplicar.16 Unit/46 asserções novas e seis verificações de contrato kernel no livro de regressão existente;78/63 provas anteriores reutilizadas. Nenhuma base/role/demo/porta/credencial/concessão nova nem escrita na instalação8085/pilotoSGC. Só proposta de origem `unregistered_draft`/autoridade única SGF; `Invoice` SGC existentes excluídas. Ecrã/persistência auditada da intenção/decisão/recibo, mapeamentos/contas privados aprovados, instalação desligada e teste de rede nessa mesma8085 continuam pendentes antes de uso. Preservar seis registos/cache/provas/trabalho alheio; sem publicação, candidata ou checkpoint presumidos.
|
||||
|
||||
- Pedido de10/11/2026: endpoint de facturas SGC implementado, ver `docs/SGC_INVOICE_ENDPOINT.md` e `docs/proofs/sgc-invoice-endpoint-20261110.json`. `bootstrap/app.php` monta apenas `routes/sgc.php`: GET da ordem/versão e POST preview/confirm. `config/sgc_invoice.php` permanece `enabled=false`, `principals=[]`; sem activação/credenciais/concessões reais. Middleware fixa pessoa/contexto por principal, revalida filiação/módulo/direitos existentes e usa sessão só em memória. Factura pendente pelo mesmo InvoiceController/ReceiptRuntime/workflow/writer; sem sincronização automática de fornecedor, espelho SGC, revisão/consumo/obrigação/pagamento/lançamento. UUID serializado por advisory lock, recibo nativo atómico, conteúdo/origem diferentes409, bloqueio5s503/recuperação200, operador nativo e manutenção revalidados também na repetição.78 verificações kernel HTTP/PostgreSQL/processos PHP (42+20+10+6), sem repetir suites antigas. Livro de regressão existente `da77bb730751`, sem novo servidor/porta/base/role/demo; fixture SQL de origem marcada como tal e quatro facturas pendentes pelo writer.8085: todos os dados/sequências e código preservados; seis registos/cache/provas/trabalho alheio intactos. Serviço/rotas legados de pagamentos/analytics não montados. Fonte continua QA-only; activação/TLS operacional/visual/carga/implantação separados. `node scripts/sgc-invoice-check.mjs` só lê a prova válida; não usar `--new` sem alteração/falha que justifique outro ensaio. Sem publicação/checkpoint.
|
||||
|
||||
- Pedido expresso de10/11/2026 (migrations/calendário/cache/teste SGC): ver `docs/SGC_PREPARATION_20261110.md`. Apenas migrations SGC000001/000002 de09/11 aplicadas em `sgf_tempo_dev`/55432, lotes18/19, com backup privado e86 tabelas anteriores de dados/concessões/cache idênticas. Migration antiga130000 continua pendente; não executar migrate global por consequência. Calendário `sgc:populate-dim-tempo` implementado,2026–2035/3652 dias, repetição0 inserções, quatro testes/17 asserções; feriados não certificados. `config:clear`/`route:clear` explicitamente pedidos e executados; não confundir com autorização de `cache:clear`/cache de dados. POST SGC404, rota não registada, payload e classes ausentes, autenticação/isolamento/idempotência incompletos: integração NÃO activada nem teste positivo declarado. Sem pagamentos, concessões explícitas novas, candidatas, publicação ou novas demos.8085/percurso nativo/seis registos/provas/trabalho alheio preservados; esta preparação não substitui o motor existente.
|
||||
|
||||
- Decisão expressa de04/11/2026: concluir Compras numa única instalação e **não criar mais demonstrações isoladas**. Instalação de trabalho escolhida e actualizada no lugar:8085, entrada `http://127.0.0.1:8085/compras`, estado privado `receipt-composition-cc50a276c10c`. Continuar aí; não abrir outra porta ou base de demonstração por incremento. Ver `docs/PURCHASING_COMPLETION.md` e `docs/COMPRAS_USO_LOCAL.md`. C01–C10 concluídos no percurso aquisição → obrigação: fornecedor novo/revisão, requisição/submissão/aprovação, três propostas/seis avaliações, adjudicação/aprovação, ordem, recepção, factura/validação, preparação/reconhecimento distinto. ExemploREQ-2026-000003/OC-2026-000001, facturaFACT-COMPRA-COMPLETA-20261104, principal/saldo100MZN e movimento1UN. Entrada com funções existentes/CSRF/guardas físicas, painel de documentos, prévias legíveis, retenção da origem ao trocar função e protecção de campos/confirmações em curso.63 verificações dirigidas:13 percurso UI→HTTP real,22 leituras/entrada,10 cliente,12 dados canónicos READ ONLY, duas comparações com dump inicial, três passagens de função e uma observação do mesmo movimento na API de Stock; sem repetir comandos concluídos ou suites anteriores. DOM simulado não é revisão visual. Writers/controllers/políticas/pacotes fixados preservados; orçamento do host e concessões idênticos ao backup. Código/dumps privados fora do repositório; sem novas bases/roles/portas/demos.8085 agora contém o exemplo concluído; isso substitui a sua condição inicial vazia, sem alterar8084/anteriores. Preservar seis registos/cache/trabalho alheio; sem implantação/concessões reais, pagamentos/contabilidade desta origem, emissão fiscal, candidata de Compras, publicação, aceitação/checkpoint presumidos. `scripts/abrir-compras.ps1` reutiliza a instalação; foi conferido com8085 já activa, não com reinício completo do sistema. Visual/carga sustentada/implantação permanecem separados.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-STOCK-PERIOD-REPORT-QA-v1` entregue; ver `docs/SGF_STOCK_PERIOD_REPORT.md`. Stock → Relatórios → Movimentos: resumo físico por artigo/armazém, abertura/entradas/saídas/fecho, período explícito por registo UTC, metadados actuais, sem total entre unidades. SQL/páginas25/100, referências/contexto/revisão, direitos e fonte SELECT-only existentes; flag desligada por omissão, sem direito novo.52 verificações (28 API/SQL,12 UI, duas de consultas, três guardas directas, sete demo).105 pares/114 movimentos iniciais são fixtures SQL de relatório, não operações do workflow; um append adicional só na regressão para revisão. Cinco SELECTs na projecção para25 e100 linhas, excluindo host/middleware/guardas; não é carga/memória/implantação. Demo8085 nova vazia;8084/anteriores, seis registos/cache/provas/trabalho alheio preservados. Sem writer/efeito financeiro novo, índices no destino, concessões reais, instalação, activação, publicação, aceitação/checkpoint. UTC da origem, unidades coerentes/histórico, exportação e restantes relatórios exigem seus recortes; visual/carga sustentada/implantação separados.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-STOCK-RECEIPT-TRACE-QA-v1` entregue; ver `docs/SGF_STOCK_RECEIPT_TRACE.md`. Movimento `receipt_in` pode abrir a recepção real após escolha explícita de Compras pelo mecanismo de contexto existente. Destino revalida `stock.view`/`purchasing_receipts.view`, perfil/tenant/armazém, linha/quantidade/artigo/fotografia e políticas originais; sem pais do browser, fallback ou comandos de negócio. Writer/runtime/leitores comuns/navegação aceite preservados, conta Stock continua SELECT em três tabelas, flag nova desligada por omissão.46 verificações:16 consulta/preservação (14 aprovadas reutilizadas após corrigir comparação de objectos no harness),20 UI e dez demo.31 tabelas host/111 nativas e sequências intactas na observação final, sem repetir origem ou suites anteriores. Demo8084 nova vazia, POST de contexto auditado pelo mecanismo existente; correlação positiva no livro QA concluído reutilizado só para leitura. Preservar8083/anteriores, seis registos/cache/provas/trabalho alheio. Sem novos direitos reais/nomes de direitos, movimento/efeito financeiro, instalação, activação, publicação, aceitação/checkpoint. Visual/carga sustentada/implantação separados; resolver conserva bloqueios do runtime de Compras.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-STOCK-COMPOSED-SOURCE-QA-v1` entregue; ver `docs/SGF_STOCK_COMPOSED_SOURCE.md`. Stock observa o mesmo livro físico da composição integrada Compras, sem copiar movimentos/saldos ou adquirir mutex nativo de escrita. Leitor/navegação/React/writer existentes preservados; adaptador novo, flag desligada por omissão, role SELECT-only em três tabelas, perfil por pessoa/empresa/unidade/armazéns, contexto/revisão revalidados. Direito existente `stock.view` anunciado e concedido só ao perfil de armazém das fixtures novas; trigger append-only apenas nesses livros QA.41 verificações (31 integração/dez demo, duas guardas directas), uma recepção/movimento1UN na regressão; fonte preparatória recuperada após recusa409 sem emitir segunda ordem. Demo8083 nova sem operações principais. Preservar8082/anteriores, seis registos/cache/provas/trabalho alheio; sem direitos reais, efeitos financeiros, instalação no destino, activação, publicação, aceitação/checkpoint. Visual/carga sustentada/implantação separados.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-OBLIGATION-EVIDENCE-WORKSPACE-QA-v1` entregue; ver `docs/SGF_OBLIGATION_EVIDENCE_WORKSPACE.md`. Paginar propostas/eventos conserva fundamento/prévia da decisão seleccionada. Proposta escolhida explicitamente, sem revisão própria/duplicação oferecida; operação em curso impede trocar selecção. Nova revisão/actor/permissões observados invalidam conferências antigas; resposta/prévia atrasada não as reactiva.26 verificações novas (20 DOM/seis demo);72 provas nativas reutilizadas, não repetidas nem somadas. Controller/runtime/kernel financeiros e bloco legado preservados. Nova demo8082 sem operações principais. Sem direitos/writers novos, confirmação financeira nova presumida ou protocolo de paginação novo. Preservar8081/anteriores, seis registos/cache/provas/trabalho alheio; sem activação, publicação, concessões reais, aceitação/checkpoint. Visual/carga/instalação/implantação continuam separados.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-INVOICE-MATCHING-PROGRESS-QA-v1` entregue; ver `docs/SGF_INVOICE_MATCHING_PROGRESS.md`. Quantidades por linha: encomendado/aceite/correcções/validado/pendente/restante, duas agregações de factos e estado observado uma vez antes da revisão/capacidades. Pendentes não reservam; discrepância avisa sem alterar documento ou decisão nativa. Histórico de factura protege contra resposta atrasada.47 verificações:14+16 em livros concluídos reutilizados só para leitura,11 DOM e seis demo. Todas as tabelas/sequências dos dois livros intactas; sem repetir comandos financeiros. Nova demo8081 sem operações principais. Estado original, preview/confirm e writers preservados; sem direitos novos. A revisão completa ainda materializa dependências: não declarar optimização integral de memória/paginação, carga ou implantação. Preservar8080/anteriores, seis registos/cache/provas/trabalho alheio; sem publicação, activação, concessões reais, aceitação/checkpoint. Visual/carga/instalação continuam separados.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-REQUEST-HISTORY-CURSOR-QA-v1` entregue; ver `docs/SGF_REQUEST_HISTORY_DELIVERY.md`. Histórico detalhado além25, páginas25/100 por cursor Laravel cifrado/vinculado a requisição/pessoa/contexto/tamanho, validade600s e acesso revalidado. Sem contagem global ou carregamento do universo; preserva filtro próprio da etapa e formulário não gravado.36 verificações:20 API/guardas/preservação,10 DOM, seis demo.127 eventos originais percorridos uma vez após nova entrada no topo;128 ao actualizar.126 notas são fixtures de auditoria, não revisões de negócio. Harness herdou ordem100MZN anterior ao histórico, preservada; essa preparação foi removida para futuras execuções.17 provas válidas reutilizadas. Nova demo8080 sem requisições principais. Escritor/preview/confirm e `ProcessHistory` preservados; sem direitos novos. Preservar8079/anteriores, seis registos/cache/provas/trabalho alheio; sem publicação, activação, concessões reais, aceitação/checkpoint. Cursor de leitura não é aprovação/snapshot MVCC persistente; visual/carga/implantação continuam separados.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-PURCHASING-USABLE-CONTINUITY-QA-v1` entregue; ver `docs/SGF_PURCHASING_USABLE_CONTINUITY.md`. Corrige ligação do formulário de recepção ao selector pesquisável e pesquisa inicial vazia nos catálogos de artigos/fornecedores; completa atalhos recepção/factura/obrigação conforme menu, sem nova autoridade. Históricos de requisição/propostas agora filtram eventos próprios na SQL antes de limite/paginação, sem devolver payloads de etapas posteriores ou apagar auditoria.55 verificações:17 DOM→HTTP real com recepção1UN/recuperação201→200 sem duplicação; oito fronteiras;12 de históricos com142 tabelas/sequências intactas; nove de interface e nove demo. Fixture adicional aberta1MZN só na regressão para selector. Nova demo8079 sem operações principais. Escritores/blocos preview/confirm preservados; sem direitos novos. Preservar8078/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação/checkpoint. Não é prova de navegador, concorrência nova, visual, carga sustentada ou implantação.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-PURCHASING-CONTEXTUAL-NAVIGATION-QA-v1` entregue; ver `docs/SGF_PURCHASING_CONTEXTUAL_NAVIGATION.md`. Dossiê abre origens exactas e continuações requisição/propostas/ordem preservam IDs. Sete interfaces usam parser comum estrito, apenas GET inicial, sem escolher primeiro registo ou trocar contexto; recepção/factura conferidas contra a ordem antes de apresentar o documento indicado.77 verificações; regressão reutiliza livro concluído sem repetir comandos,142 tabelas/sequências intactas. Demo8078 nova sem operações principais, mantendo anexos/percurso integrado. Sem direitos ou writers novos. Preservar8077/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação ou checkpoint. DOM simulado não é revisão visual; carga sustentada/instalação/implantação continuam separadas. Atalho não aprova nem determina prontidão da próxima etapa.
|
||||
|
||||
- Continuação de 03/11/2026: `SGF-RECEIPT-DOCUMENTS-QA-v1` entregue; ver `docs/SGF_RECEIPT_DOCUMENTS_DELIVERY.md`. Guias/qualidade/outros anexos no histórico de recepção e eventos no dossiê, pelo serviço/descarga nativos fixados. Prévia revertida, confirmação individual com fundamento/SHA/contexto/versão, páginas15, recuperação por chave e reutilização por recepção/tipo/SHA.58 verificações;16 documentos na regressão (um pela API e15 fixtures nativas), concorrência201/200, uma recepção/movimento1UN preservados. Demo8077 nova sem recepções/anexos/operações principais. Reutiliza `purchasing_receipts.view/confirm`, sem direitos novos. Não altera aceitação/qualidade/stock, nem cria efeitos financeiros. Preservar8076/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação ou checkpoint. Resultado de commit incerto conserva ficheiro; órfãos/retencão/quotas/carga/visual/implantação pendentes. Testes do componente usam DOM simulado; não são revisão visual.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-SUPPLIER-DOCUMENTS-QA-v1` entregue; ver `docs/SGF_SUPPLIER_DOCUMENTS_DELIVERY.md`. Anexos binários privados pelo controlador nativo: prévia revertida/ficheiro temporário removido, confirmação com SHA/contexto/versão, documento e auditoria únicos por chave, descarga íntegra auditada e paginação15. Anexar põe fornecedor pendente; revisão distinta da versão actual restabelece elegibilidade nas propostas, comprovada sem proposta automática.52 verificações;16anexos na regressão (duas confirmações API/14fixtures nativas), demo8076 nova sem anexos/operações. Reutiliza direitos `purchasing_suppliers.view/prepare`, sem direitos novos. Resultado de commit incerto conserva ficheiro; reconciliação de órfãos/retencão não declarada entregue. Preservar8075/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões/dados reais, activação, publicação, aceitação ou checkpoint. Visual/carga/armazenamento/implantação pendentes; formatos positivos ensaiados com PNG, regras PDF/JPEG nativas preservadas.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-RECEIPT-WORKSPACE-COMPLETION-QA-v1` entregue; ver `docs/SGF_RECEIPT_WORKSPACE_COMPLETION.md`. Catálogo de recepção por linha com pesquisa literal/páginas25/100, unidade/ligações conhecidas, escolha explícita e impressão de artigos conferida antes de nova confirmação; histórico navegável além25 com consulta própria.41 verificações;127artigos compatíveis,27recepções parciais/1UN na regressão (25como fixture pelo mesmo writer, duas pela API). Demo8075 integrada nova, sem operações principais; catálogo QA preparado, pedidos apenas nas linhas marcadas. Reutiliza direitos existentes e writer, sem nova valorização/conversões ou efeitos financeiros. Preservar8074/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação ou checkpoint. Ramos completos de abastecimento/peças não reexecutados; visual/carga/implantação mantêm limites próprios.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-PURCHASING-DOSSIER-QA-v1` entregue; ver `docs/SGF_PURCHASING_DOSSIER_DELIVERY.md`. Dossiê GET da requisição até à obrigação, páginas25 independentes e histórico projectado apenas das etapas autorizadas; etapas restritas não expõem linhas/contagens. Direito de entrada `purchasing_requests.view` e direitos nativos/de cada etapa, sem direitos novos.49 verificações: leitura reutiliza percurso QA já provado, em aplicação/sessões novas, sem repetir comandos;142tabelas e sequências intactas. Demo8074 nova e vazia, com fornecedores/orçamento QA preparados;8073 e anteriores preservadas. Não confundir dossiê com fila universal, decisão de prontidão ou autorização da operação seguinte; impressão só das páginas visíveis e obrigação paginada por facturas de origem. Sem alteração de seis registos/cache/dados/acessos reais, activação, publicação, aceitação ou checkpoint. Visual/carga/implantação permanecem pendentes.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-SOURCING-SUPPLIER-SEARCH-QA-v1` entregue; ver `docs/SGF_SUPPLIER_SEARCH_DELIVERY.md`. Resolve a selecção limitada aos100primeiros fornecedores: pesquisa literal nome/NUIT, páginas SQL25/100, escolha individual e impressão do fornecedor conferida na prévia/confirmação de nova proposta.51 verificações; demo8073 conserva composição integrada, com127linhas artificiais adicionais de directório e125fornecedores admissíveis, zero operações principais iniciais. Reutiliza `purchasing_sourcing.prepare`, origem/isolamento e controlador original; não são novos cadastros reais ou revisões comerciais executadas. Recibos existentes recuperam decisão histórica, sem nova execução. Preservar8072/anteriores, seis registos/cache/provas/trabalho alheio; sem novos direitos públicos/reais, índices no destino, pagamentos/lançamentos, activação, publicação, aceitação ou checkpoint. Visual, carga sustentada e capacidade de implantação continuam separados.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-PURCHASING-INTEGRATED-QA-v1` entregue; ver `docs/SGF_PURCHASING_INTEGRATED_DELIVERY.md`. Demo8072 reúne requisição nova → propostas/avaliações → adjudicação distinta → ordem → recepção → factura conferida → obrigação explícita no mesmo livro nativo, com cadastro de fornecedores e referência geográfica no mesmo host.68 verificações, sem repetir suites anteriores; navegação comum filtrada pelos direitos existentes, sem nomes de direitos novos. Começa sem operações principais/reservas/compromissos: três fornecedores e orçamento QA1000MZN preparados,20.407territórios e zero localizações. Um consumo100MZN, um movimento e uma obrigação100MZN; orçamento host intacto. Retira a limitação de fixtures separadas apenas para este percurso de Compras; não declara os seis módulos reunidos nem pagamentos/contabilidade desta origem. Preservar8071/anteriores, seis registos/cache/trabalho alheio; sem concessões reais, activação no destino, publicação, aceitação ou checkpoint. Visual, carga sustentada e capacidade de implantação mantêm pendências próprias.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-INVOICE-TO-PAYABLE-QA-v1` entregue; ver `docs/SGF_INVOICE_TO_PAYABLE_DELIVERY.md`. Preparação individual e reconhecimento distinto das facturas conferidas pelo mesmo adaptador original/núcleo/livro nativo, sem espelho de dívida no host.72 verificações; demo8071 começa sem ordens principais/recepções/facturas/propostas/obrigações, com adjudicação QA preparada. Um principal100MZN, vencimento desconhecido, prévia reversível, versão/contexto/pessoa, repetição com revalidação da origem, concorrência e recuperação atómica. Direitos `purchasing_obligations.view/prepare/validate` só nas novas fixtures55439; configurações desligadas por omissão, histórico desactivado. Preservar8070/anteriores, seis registos/cache/trabalho alheio. Sem pagamentos, lançamentos, migração histórica, concessões reais, activação no destino, publicação, aceitação ou checkpoint. Instalação única de todas as fixtures anteriores, correcções/anexos, visual, carga sustentada e capacidade de implantação permanecem separadas.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-SUPPLIER-INVOICE-MATCHING-QA-v1` entregue; ver `docs/SGF_SUPPLIER_INVOICE_MATCHING_DELIVERY.md`. Ordem efectivamente emitida → recepção aceite → registo pendente → conferência/validação distinta, pelos controladores/workflow originais e consumo no mesmo núcleo orçamental.77 verificações, demo8070 sem ordem/recepção/factura/consumo iniciais no contexto principal; adjudicação QA preparada e ordem estrangeira negativa separada. Direitos `purchasing_invoices.view/prepare/validate` só nas novas fixtures. Duas parciais40+60 consomem100MZN uma vez; documento sobreposto70 permanece pendente e preservado. Sem obrigação automática, pagamentos, lançamentos, emissão fiscal, correcção/rejeição/notas de crédito ou anexos binários. Não tratar esta conferência SGF como ausente; a ligação destas facturas ao reconhecimento da obrigação mantém passo próprio. Preservar8069/anteriores, seis registos/cache/pacotes/trabalho alheio; sem novas concessões reais, activação no destino, publicação, aceitação ou checkpoint. Visual, carga sustentada e implantação continuam separados.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-GEOGRAPHY-SEGMENTATION-QA-v1` entregue; ver `docs/GEOGRAPHY_SEGMENTATION_DELIVERY.md`. Selector comum ligado a empresa/unidade/cliente/fornecedor, catálogo/cascata/pesquisa paginados, prévia/decisão explícita, histórico e segmentação com filtros/cursor.83 verificações e demo8069 sem localizações iniciais;16 provas da preparação reutilizadas. Só localização para segmentação: não altera endereços fiscais/de entrega, revisão financeira de fornecedores ou dependentes. Reutiliza direitos existentes, sem novos direitos públicos; schema e concessões só nas novas fixtures55439. Cliente conserva associação modular explícita e fornecedor exige origem/unidade/autoridade nativas actuais, também nos relatórios. Carga20.407 privada e versão preservada; pendências da fonte impedidas de selecção. Circuito de correcção/publicação de versões, países adicionais, migração de localizações, visual e implantação mantêm limites próprios. Preservar8068/anteriores, seis registos/cache/trabalho alheio; sem publicação, activação no destino, aceitação ou checkpoint.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-REQUEST-TO-RECEIPT-QA-v1` entregue,103 verificações e demo8068; ver `docs/SGF_REQUEST_TO_RECEIPT_DELIVERY.md`. Novas requisições na interface SGF usam o mesmo `RequisitionWorkflow`/ciclo/orçamento nativo que propostas, ordens e recepções: rascunho, submissão/reserva única, aprovação distinta e rejeição/libertação única. Identidades QA próprias de solicitante, departamento, Compras, Finanças e Armazém; direitos `purchasing_requests.view/prepare/submit/approve` só nas fixtures novas. Origem auditada empresa/unidade, sem redigitar IDs ou duplicar orçamento no host. Demo fixada ao pacote efectivamente testado; alterações simultâneas de Compras preservadas. Não tratar novas requisições como ausentes; conversão de antigos `ir_requests` com reserva no host permanece pendente de protocolo próprio, sem transferência implícita. Preservar demos anteriores/seis registos/cache/trabalho alheio. Sem activação no destino, concessões reais, publicação, aceitação ou checkpoint.
|
||||
|
||||
- Referência fornecida em03/11/2026: `cartografia_moz.xlsx`, declarada pelo utilizador como validada pelo INE, preparada para segmentação comum de empresas/clientes/fornecedores; ver `docs/CARTOGRAPHY_REFERENCE_PREPARATION.md`.20.407 linhas, códigos de texto e versão SHA preservados,16 verificações; ambiguidades/designações ausentes/ascendência incompleta sinalizadas sem deduplicação ou associação automática. Original intacto, carga preparada privada, nenhuma ligação a bases ou localização atribuída. Não confundir preparação com catálogo/selector/formulários montados ou vigência independentemente verificada. Próxima integração usa catálogo comum, selecção explícita por versão/ID, precisão conhecida e histórico por entidade/contexto; preservar seis registos/cache/acessos.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-SOURCING-TO-RECEIPT-QA-v1` entregue; ver `docs/SGF_SOURCING_TO_RECEIPT_DELIVERY.md`. Interface SGF para propostas/descontos, avaliações individuais, comparação, escolha fundamentada e decisão distinta, ligada à ordem e recepção efectivas pelos controladores/serviços originais. Nova demo8066 parte de requisição nativa aprovada e fornecedores QA preparados, sem propostas/avaliações/adjudicação/ordem/recepção iniciais; preservar8064/8065. Direitos `purchasing_sourcing.view/prepare/evaluate/select/approve` somente nas novas fixtures. Prévia usa savepoint com rollback integral (sequências podem avançar); confirmação tem recibo append-only na mesma transacção. O manifesto fica fixado e a integridade de cada dependência é verificada antes do carregamento. Não tratar estas interfaces como ausentes; passagem de requisições internas SGF sem orçamento duplicado, cadastro/revisão de fornecedores SGF e regimes/revisões posteriores mantêm fronteiras próprias. Seis registos/cache/trabalho alheio preservados; sem concessões reais, publicação, activação no destino, aceitação ou checkpoint.
|
||||
|
||||
- Continuação de03/11/2026: `SGF-ORDER-TO-RECEIPT-QA-v1` entregue, ver `docs/SGF_ORDER_TO_RECEIPT_DELIVERY.md`. Adjudicação aprovada pelo serviço original → conferência SGF → emissão/compromisso100MZN únicos → recepção por Armazém → movimento1UN → histórico. Reutiliza `PurchaseWorkflow`, bibliotecas económicas originais fixadas e o mesmo workflow/writer de recepção. Nova demo8065 com emissão/recepção por decidir; preservar8064/anteriores. Direitos `purchasing_orders.view/issue` só nas fixtures novas, sem catálogo/concessões públicos. Não voltar a tratar emissão de ordens SGF como ausente; propostas/adjudicação na interface SGF e passagem de requisições SGF para essa origem mantêm lacunas próprias. Sem orçamento duplicado no host, pagamentos/contabilidade, instalação/activação no destino, publicação, aceitação ou checkpoint; preservar seis registos, cache e trabalho alheio.
|
||||
|
||||
- Continuação expressa de03/11/2026, «então faça isso e saia do loop»: recepção funcional SGF implementada em `SGF-RECEIPT-WORKFLOW-QA-v1`, ver `docs/SGF_RECEIPT_WORKFLOW_DELIVERY.md`. Ordem → conferência/prévia → confirmação pelo workflow original → writer físico único → histórico; proveniência SGF atómica, repetição/concorrência/recuperação. Nova demonstração8064 sem recepções/movimentos iniciais, com perfis sintéticos próprios; duas bases novas55439 e direitos `purchasing_receipts.view/confirm` somente nas fixtures, sem catálogo/grants públicos. Não tratar a interface de recepção SGF como ainda ausente; instalação geral de procurement, pesquisa completa de catálogo, criação/emissão de ordens SGF, revisão visual e implantação conservam pendências próprias. Preservar demos, seis registos, cache e trabalho alheio. Sem activação no destino, publicação, aceitação ou checkpoint presumidos.
|
||||
|
||||
- Orientação prática posterior de03/11/2026: concluir funcionalidades dos módulos em entregas funcionais antes de ampliar ligações, definir os contratos durante a construção e reutilizar provas válidas. Após integração, testar as fronteiras alteradas/percurso completo; não repetir suites ou abrir novos ensaios de optimização sem necessidade que substituam a conclusão funcional.
|
||||
|
||||
- Autorização expressa de03/11/2026: «Autorizar o ciclo sintético proposto», `COMMON-LEDGER-STOCK-VALUATION-SIM-v1`, conforme `docs/GENERAL_LEDGER_STOCK_VALUATION_SCOPE.md`: entrada2UN/100MZN, saída1UN/50, devolução0,5UN/25, saldo1,5UN/75, pelos workflows físicos efectivos e mesmo diário/relatórios/fecho QA. Taxa50/UN validada distintamente, origens exclusivas, revisão individual, idempotência, concorrência/recuperação; direitos existentes só em bases QA novas55439. Retira apenas a exclusão desta valorização sintética seleccionada; sem duplicar custos directos/industriais, alterar demonstrações/seis registos/cache, correcção de valor lançado, reabertura, operações/acessos reais, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
|
||||
|
||||
- Continua??o de03/11/2026: `SGF-SUPPLIER-REGISTRY-QA-v1` entregue; ver `docs/SGF_SUPPLIER_REGISTRY_DELIVERY.md`. Cadastro/altera??o e revis?o distinta, activa??o/suspens?o, filtros/pagina??o15, hist?rico e liga??o ?s propostas pelos controladores/valida??o/pol?tica originais. Nova demo8067 e64 verifica??es;8064/8065/8066 preservadas. Direitos `purchasing_suppliers.view/prepare/review` apenas nas novas fixtures; cadastro novo vis?vel por origem auditada empresa/unidade e admissibilidade tamb?m na API de propostas. N?o tratar cadastro/revis?o como ausente neste recorte QA. Anexos, selector integral al?m100, passagem de requisi??es internas com autoridade or?amental ?nica, regimes/correc??es posteriores e implanta??o mant?m pend?ncias. Seis registos/cache/trabalho alheio preservados; sem concess?es reais, activa??o no destino, publica??o, aceita??o ou checkpoint.
|
||||
|
||||
- Autorização expressa de03/11/2026: «Autorizar a correcção industrial sintética proposta», `COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1`, `docs/GENERAL_LEDGER_INDUSTRIAL_CORRECTION_SCOPE.md`. Workflows efectivos: taxa material50→51 para20KG, resultado1800→1820/10UN; trabalho500/CIF300 e residual300 separado. Mesmo writer, par atómico estorno1800/substituição1820, revisão distinta, relatórios/fecho QA, direitos existentes apenas em bases novas55439. Retira apenas a exclusão desta correcção industrial em período aberto; preservar8058/demos/seis registos/cache e trabalho alheio. Sem cadeias adicionais, stock/salários reais, correcção em período fechado, reabertura, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
|
||||
|
||||
- Autorização expressa de03/11/2026: «Autorizar o ciclo industrial sintético proposto», `COMMON-LEDGER-INDUSTRIAL-SIM-v1`, `docs/GENERAL_LEDGER_INDUSTRIAL_SCOPE.md`. Resultado efectivo1000+500+CIF300=1800,10UN/180, residual300 separado, mesmo writer/relatórios/fecho QA e revisão distinta. Bases/roles novas55439 e direitos existentes só QA. Retira apenas a exclusão de desenvolver/provar este lote/CIF seleccionado; preservar demos, seis registos, cache e trabalho alheio. Sem stock valorizado, folha salarial/operações reais, correcção industrial, reabertura, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
|
||||
|
||||
- Autorização expressa posterior de03/11/2026: «Autorizar a correcção sintética proposta» para `COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1`, `docs/GENERAL_LEDGER_DIRECT_COST_CORRECTION_SCOPE.md`: custo original275, taxa material sucessora60/UN e cálculo295 validados pelos workflows, par atómico estorno275/substituição295, revisão distinta, relatórios e fecho QA. Direitos correct_* existentes só em fixtures novas55439; preservar8054, seis registos/cache/demos e trabalho alheio. Retira a exclusão de desenvolver/provar esta correcção em período aberto, sem reabertura, stock/salários, operações/acessos reais, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
|
||||
|
||||
- Autorização expressa posterior de03/11/2026: «Autorizar o ciclo sintético proposto» para `COMMON-LEDGER-DIRECT-COST-SIM-v1`, conforme `docs/GENERAL_LEDGER_DIRECT_COST_SCOPE.md`: workflow efectivo de oficina/custo directo100+175=275MZN, D QA-CUSTO-DIRECTO / C QA-COMPENSACAO-ENSAIO, revisão distinta, mesmo writer, relatórios e conferência do período; bases/roles/direitos só QA novos55439. Retira a exclusão de desenvolver/provar apenas este custo seleccionado no diário sintético. Preservar origens/outbox, seis registos, cache, demos e trabalho alheio. Sem valorização/lançamentos de stock, salários/CIF, correcção de custo já lançado, operações/acessos reais, reabertura, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint presumidos.
|
||||
|
||||
- Autorização expressa posterior: «Autorizar o ciclo sintético proposto» para `COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1`, `docs/GENERAL_LEDGER_SALES_RECEIVABLE_SCOPE.md`: factura de teste862,07+137,93=1000MZN pelo workflow existente, contabilização em três linhas no mesmo writer, recebimentos/afectações400/600, reversão400 e conferência do período. Bases/roles novas no cluster55439, guarda QA específica sem retirar bloqueios reais; direitos só QA. Preservar ciclos de fornecedores, seis registos, cache, demos e alterações alheias. Sem fiscalidade/contas/direitos/operações reais, reabertura, stock/custos/salários contabilizados, migração no destino, activação de Compras, publicação, aceitação ou checkpoint presumidos.
|
||||
|
||||
- Autorização expressa posterior, 02/11/2026: «Autorizar o ciclo sintético proposto» para `COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1`, conforme `docs/GENERAL_LEDGER_COMPLETE_SYNTHETIC_SCOPE.md`: liquidação QA20 sobre principal60, contabilização da afectação e reversão confirmadas, correcção técnica por par imutável, fecho QA e adaptador autónomo SGC sobre núcleo único. Inclui os direitos propostos apenas em fixtures novas no cluster55439. Substitui as exclusões anteriores apenas para este desenvolvimento/provas sintéticos; reabertura, operações/contas/direitos reais, migração no destino, activação de Compras, publicação, aceitação e checkpoint continuam excluídos. Preservar seis registos, cache, demos e trabalho alheio.
|
||||
|
||||
- Autorização expressa posterior, 02/11/2026: implementar `COMMON-LEDGER-PAYABLE-SIM-v1`, conforme `docs/GENERAL_LEDGER_FIRST_SYNTHETIC_SCOPE.md`. Um núcleo único, obrigação reconhecida 60 MZN, duas contas e período artificiais, preparação/revisão distintas, aplicação única/atómica e quatro direitos exclusivamente em fixture QA nova no cluster 55439. Substitui apenas a exclusão de desenvolver este primeiro lançamento sintético; não autoriza lançamentos/contas/direitos reais, migração no destino, fecho/reabertura, novos efeitos de pagamentos, activação de Compras, publicação, aceitação ou checkpoint. Preservar seis registos, cache bloqueada, demos e alterações alheias. Ver `docs/GENERAL_LEDGER_DELIVERY.md` para implementação e provas; revisão visual e implantação separadas.
|
||||
|
||||
- Continuação «prossiga», 29/10/2026, projecto Compras: desenvolvimento sintético de `PAYABLE-IMPORTED-DECISION-v1`, confirmação/rejeição de pagamentos originalmente pendentes após importação. Reutilizar conferência original e núcleo comum, transacção única, fotografia importada imutável, decisão append-only e abatimento único. Ver `C:/laragon/www/compras/docs/conferencia-pendentes-importados.md`. Substitui a exclusão de desenvolver esta conferência apenas neste incremento; preservar configurações/dados/demos existentes. Migração 000023 e activação não aplicadas por consequência do desenvolvimento. Sem novos pagamentos, transferências, contabilidade ou publicação; SGF continua sem adaptador histórico.
|
||||
|
||||
- Aplicação local posterior autorizada por «continue», 29/10/2026: 000022 aplicada em `compras_dev`, lote 23, e importação histórica activada, após backup/restauro de 72 tabelas e preservação verificada. Duas tabelas novas vazias, dois triggers, activação auditada sem contas/concessões novas ou incorporação automática. Ver `C:/laragon/www/compras/docs/passagem-historico-pagamentos.md`. Demos (incluindo 8005) e público preservados; SGF continua sem fornecedor histórico configurado. Este registo substitui apenas a pendência de aplicação local abaixo; não autoriza novos pagamentos/confirmação de pendentes ou publicação.
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# SGF TEMPO
|
||||
|
||||
**Entrada dos seis módulos na mesma instalação:** http://127.0.0.1:8085/sgf · [Guião de apresentação](docs/SGF_GUIAO_APRESENTACAO.md) · [Estado funcional e limitações](docs/SGF_SIX_MODULE_CLOSURE_20261110.md). Comercial, Compras, Stock, Oficinas, Financeiro e Contabilidade integrados no recorte QA aprovado,18 lançamentos equilibrados, sem SGC ou novas demonstrações. Abertura/legibilidade do novo Stock confirmada pelo utilizador; aceitação integral, revisão visual restante e capacidade de implantação continuam separadas.
|
||||
|
||||
**Directiva actual — conclusão do SGF autónomo:** fechar os seis módulos numa única instalação e entregar um produto apresentável a potenciais clientes. [Escopo, diagnóstico e critérios únicos de entrega](docs/SGF_SOLUTION_DELIVERY.md). O conector SGC é opcional e permanece desligado; as provas e demonstrações históricas abaixo não representam a conclusão dos seis módulos.
|
||||
|
||||
**Compras — entrada única actual:** **http://127.0.0.1:8085/compras**. [Guia de utilização](docs/COMPRAS_USO_LOCAL.md) · [percurso concluído e prova](docs/PURCHASING_COMPLETION.md). A instalação existente reúne fornecedores, requisições, propostas/adjudicação, ordens, recepções, facturas e reconhecimento de obrigações. Há um exemplo completo conservado, com principal100MZN e movimento1UN. Funções sintéticas explícitas; nenhuma instalação empresarial ou operação real foi activada. Para reutilizar a aplicação local: `scripts/abrir-compras.ps1`. As referências de demonstrações abaixo são históricas.
|
||||
|
||||
[Comprovativos de fornecedor integrados na consulta contabilística](docs/FINANCIAL_RECEIPT_RECOVERY_DELIVERY.md): domínio explícito de reconhecimento ou liquidação, direitos próprios, sem reexecutar operações.43 verificações dirigidas em base QA nova; nenhum pagamento criado. Revisão visual, carga e implantação pendentes.
|
||||
|
||||
[Recuperação de comprovativos contabilísticos](docs/GENERAL_LEDGER_RECEIPT_RECOVERY_DELIVERY.md): consulta por chave, apenas do autor no contexto actual, sem executar operações. Integrada nas páginas de origens e diário;29 verificações dirigidas em base QA nova. Revisão visual e implantação permanecem pendentes.
|
||||
|
||||
[Correcção industrial1800→1820 implementada em QA](docs/GENERAL_LEDGER_INDUSTRIAL_CORRECTION_DELIVERY.md): nova demo **http://127.0.0.1:8059/qa/cost-demo**, par atómico revisto distintamente, residual300 separado, diário/razão/fecho. Conjunto11 lançamentos/23 linhas, bruto8725 por lado. Demos anteriores intactas; sem reabertura, stock/salários ou implantação presumida.
|
||||
|
||||
[Relatório financeiro: captura histórica validada reutilizada](docs/SGF_PAYABLE_REPORT_CAPTURE_DELIVERY.md). Ensaio em cópia QA:22,0→12,9s para10.000 posições, consultas2313 e heap34MiB iguais; totais/hash preservados. Validação do recibo e histórico original mantida, comandos usam fontes actuais. O gargalo persiste; sem capacidade de implantação validada.
|
||||
|
||||
[Lote industrial1800 → Contabilidade implementado em QA](docs/GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md): demo nova **http://127.0.0.1:8058/qa/cost-demo**, workflows efectivos, residual300 separado e mesmo diário/razão/fecho. Conjunto com vendas, fornecedor e custo directo:9 lançamentos/19 linhas, bruto5105 por lado. Demos anteriores intactas; sem stock valorizado, salários ou uso real. Revisão visual, carga e implantação pendentes.
|
||||
|
||||
[Vendas, fornecedores e custos no mesmo diário QA](docs/GENERAL_LEDGER_COMPOSITION_DELIVERY.md): demonstração nova **http://127.0.0.1:8057/qa/cost-demo**, painel de origens com paginação independente e fecho conjunto. Regressão:8 lançamentos/17 linhas, bruto3305 por lado, cliente400/custo295/fornecedor60. Reutiliza os efeitos autorizados; demos anteriores e decisões iniciais preservadas. Revisão visual, carga sustentada e implantação continuam pendentes.
|
||||
|
||||
[Correcção de custo275→295 implementada em QA](docs/GENERAL_LEDGER_DIRECT_COST_CORRECTION_DELIVERY.md): demonstração própria **http://127.0.0.1:8055/qa/cost-demo**, estorno275/substituição295 numa transacção, revisão distinta, diário/balancete/razão e fecho. Original preservado; tipos de custo/fornecedor separados e SQL contra partes órfãs. A8054 mantém o recorte anterior; revisão visual e implantação separadas.
|
||||
|
||||
[Oficina → custo directo → Contabilidade implementado em QA](docs/GENERAL_LEDGER_DIRECT_COST_DELIVERY.md): demonstração nova **http://127.0.0.1:8054/qa/cost-demo**, custo seleccionado100+175=275, revisão distinta, um lançamento, relatórios e fecho. Mesmos workflows/writer; sem duplicar origens, processar outbox ou valorizar stock. Correcção de custo já lançado, revisão visual e implantação continuam separadas.
|
||||
|
||||
[Balancete e razão por conta integrados](docs/GENERAL_LEDGER_REPORTS_DELIVERY.md): núcleo único em SGF/SGC, abertura/movimentos/fecho por datas, páginas25/100, saldo acumulado entre páginas e evidência da origem. Só consulta com direitos existentes; três consultas de relatório por página do balancete. Provas dirigidas e pacote QA novo, preservando o anterior; revisão visual e implantação separadas.
|
||||
|
||||
[Comercial → Recebíveis → Contabilidade implementado em QA](docs/GENERAL_LEDGER_SALES_RECEIVABLE_DELIVERY.md): nova demonstração **http://127.0.0.1:8053/qa/sales-demo**, factura de teste1.000 → recebimentos/afectações400+600 → reversão400 → extracto e fecho QA. Mesmo writer, três linhas no reconhecimento, revisão distinta e concorrência comprovada. Mantém demos anteriores, revisão visual e capacidade de implantação separadas; não activa operações reais.
|
||||
|
||||
[Distribuição financeira autónoma verificada](docs/COMMON_FINANCE_BUNDLE_DELIVERY.md): artefacto com hashes fixados, recusa de alterações antes do arranque e nova demonstração SGC **http://127.0.0.1:8052/qa/ledger-demo**. Os núcleos financeiros são carregados do pacote local, sem fonte SGF de runtime ou activação do destino.
|
||||
|
||||
[Ciclo contabilístico sintético completo autorizado](docs/GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md): demonstrações novas **SGF8050 / SGC8051**, reconhecimento60 → afectação20/saldo40 → contabilização → reversão/saldo60 → par correctivo → fecho QA. Um núcleo, revisão distinta, recuperação e concorrência comprovadas; sem uso real, reabertura ou activação de Compras.
|
||||
|
||||
[Percurso integrado de conferência financeira até ao lançamento](docs/SGF_FINANCIAL_LEDGER_FLOW_DELIVERY.md): demonstração própria em **http://127.0.0.1:8049/qa/ledger-demo**, começando sem obrigação reconhecida. Preparação, reconhecimento independente, proposta/revisão contabilística, aplicação única, extracto e posição ligados pela mesma origem. Código funcional sintético; compra viva, correcções/fecho contabilísticos, revisão visual e implantação continuam separados.
|
||||
|
||||
[Ciclo de material Oficina–Stock](docs/WORKSHOP_MATERIAL_CYCLE.md): repartição de entregas entre consumos, devolução independente do excedente não consumido e histórico paginado. Ensaio sintético; sem instalação operacional ou compensação de material consumido.
|
||||
|
||||
[Oficina → entrega já abatida](docs/WORKSHOP_DELIVERY_LINK.md): ligação individual de consumo verificado a uma saída existente, com impacto físico zero e confirmação independente. Apenas correspondência integral sintética; repartições e compensações pendentes.
|
||||
@@ -160,6 +192,8 @@ Continuação Oficina → Stock: [retirada e recusa de propostas sem efeito fís
|
||||
|
||||
Continuação Compras → Financeiro: [recibo histórico no adaptador SGF](docs/SGF_PAYABLE_HISTORY_RECEIPT_DELIVERY.md), com 10 cenários/56 asserções e 17 verificações HTTP. Apenas ensaio por fotografia reconciliada; sem fonte viva, cutover operacional, decisão de pendentes ou activação real.
|
||||
|
||||
Consulta financeira SGF: [paginação, leitura conjunta, testes e medições antes/depois](docs/SGF_PAYABLE_PAGED_READ_DELIVERY.md). Em 10.000 documentos sintéticos, 250.028 consultas integrais passaram a 67 por página de 25/100, com posições equivalentes. Sem índices no destino ou validação de capacidade de implantação.
|
||||
|
||||
O menu **Classificação contabilística**, disponível após actualizar a sessão com os acessos especificamente concedidos, permite preparar e submeter versões PGC-NIRF, validar/recusar sem editar e consultar classificação por data. Categoria e excepções são empresariais; operações e respectivos históricos continuam limitados às unidades autorizadas. As tabelas reais começam vazias, sem contas de movimento ou regras aprovadas por omissão. Consultar [utilização, provas e limites da classificação](docs/CATALOGUE_ACCOUNTING_DELIVERY.md).
|
||||
## Ensaio isolado de custo directo
|
||||
|
||||
@@ -172,3 +206,17 @@ Página inicial e menus agrupados: [alterações, verificações e limites visua
|
||||
O ensaio de orçamento versus realizado está descrito em [INDUSTRIAL_BUDGET_DELIVERY.md](docs/INDUSTRIAL_BUDGET_DELIVERY.md), com percurso, estado das provas e propostas separadas. Continua limitado a `sgf_tempo_test`; a instalação normal e os acessos novos não são aplicados por arrasto.
|
||||
|
||||
O percurso `WORKSHOP-DIRECT-COST-SIM-v1` e as instruções de prova estão em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md). Reutiliza apontamentos verificados sem duplicar consumos; não activa custeio real. Migração `090000` e quatro concessões continuam propostas, não aplicadas.
|
||||
|
||||
Continuação financeira nativa: [consulta paginada SGC com origem viva em QA](docs/NATIVE_PAYABLE_PAGED_READ_DELIVERY.md). Leitura conjunta reutiliza o núcleo comum, preserva factura/contexto e mantém comandos actuais. Pacote instalado e activação permanecem separados; revisão visual e capacidade de implantação pendentes.
|
||||
|
||||
Continuação financeira: [origem, obrigação e selecção para pagamentos ligadas](docs/SGF_PAYABLE_EVIDENCE_FOCUS_DELIVERY.md). Conferência directa da origem, evidência exacta do reconhecimento e navegação das obrigações além da primeira página. Testes dirigidos e fingerprint anterior preservado; sem pagamentos, concessões ou activação operacional.
|
||||
|
||||
Histórico financeiro SGF: [pagamentos, afectações e eventos paginados](docs/SGF_SETTLEMENT_PAGED_READ_DELIVERY.md). Quatro janelas independentes, leitura conjunta e montantes por afectar calculados sobre o histórico integral. Em 1.000 pagamentos sintéticos: 3.038 consultas integrais passaram a 35 na leitura do livro com janelas de 25/100; sem índices novos ou capacidade de implantação presumida.
|
||||
|
||||
Consulta financeira SGF: [leitura coerente sem bloqueios de escrita da empresa](docs/SGF_SETTLEMENT_READ_ISOLATION_DELIVERY.md). O leitor paginado terminou durante o bloqueio do escritor; a confirmação continuou a aguardar e a revalidar, sem duplicação. Provas e dados anteriores preservados; sem activação operacional ou validação de implantação.
|
||||
|
||||
[Relatório reconciliado de obrigações SGF](docs/SGF_PAYABLE_REPORT_DELIVERY.md): posição completa do filtro, saldos indeterminados separados e evidência por conteúdo. Em 10.000 posições, a leitura específica reduziu 6.607 para 2.313 consultas; o tempo de 12,6 s continua uma limitação, sem capacidade de implantação declarada. [Primeiro lançamento contabilístico sintético proposto](docs/GENERAL_LEDGER_FIRST_SYNTHETIC_SCOPE.md), apenas especificação para autorização.
|
||||
|
||||
[Primeiro lançamento contabilístico sintético](docs/GENERAL_LEDGER_DELIVERY.md): obrigação reconhecida de 60 MZN → proposta → revisão distinta → lançamento único em duas contas artificiais. Código, interface, extracto, 74 asserções e 23 verificações HTTP; duas aplicações concorrentes recuperam o mesmo lançamento. Configuração real continua desligada, sem publicação ou checkpoint. Revisão visual e implantação separadas.
|
||||
|
||||
Demonstração contabilística isolada para a equipa: http://127.0.0.1:8048/qa/ledger-demo — outra fixture, ainda sem proposta/lançamento, com três funções e cookie próprios. A regressão conserva separadamente o único lançamento comprovado. Ver docs/GENERAL_LEDGER_DELIVERY.md; não equivale a aceitação ou revisão visual.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Sgc\CalendarDimension;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PopulateDimTempo extends Command
|
||||
{
|
||||
protected $signature = 'sgc:populate-dim-tempo {--start-year= : Primeiro ano (por omissão, ano actual)} {--years=10 : Número de anos completos} {--dry-run : Conferir intervalo sem aceder à base}';
|
||||
protected $description = 'Insere o calendário em analytics_dim_tempo sem substituir dias existentes';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$start = (string) ($this->option('start-year') ?? now('Africa/Maputo')->year);
|
||||
$years = (string) $this->option('years');
|
||||
if (!ctype_digit($start) || !ctype_digit($years) || (int)$start < 1 || (int)$years < 1 || (int)$years > 100 || (int)$start + (int)$years > 10000) {
|
||||
$this->error('Indique um ano válido e entre 1 e 100 anos, até ao ano 9999.');
|
||||
return self::INVALID;
|
||||
}
|
||||
$count = 0;
|
||||
if ($this->option('dry-run')) {
|
||||
foreach (CalendarDimension::days((int)$start, (int)$years) as $day) $count++;
|
||||
$this->info("Simulação: {$count} dias; {$start}-01-01 até ".((int)$start+(int)$years-1).'-12-31. Nenhuma escrita.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$connection = DB::connection();
|
||||
if (!$connection->getSchemaBuilder()->hasTable('analytics_dim_tempo')) {
|
||||
$this->error('Falta analytics_dim_tempo; aplique primeiro a migration analítica.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$inserted = $connection->transaction(function () use ($connection, $start, $years, &$count) {
|
||||
$batch = []; $inserted = 0;
|
||||
foreach (CalendarDimension::days((int)$start, (int)$years) as $day) {
|
||||
$batch[] = $day; $count++;
|
||||
if (count($batch) === 250) {
|
||||
$inserted += $connection->table('analytics_dim_tempo')->insertOrIgnore($batch); $batch = [];
|
||||
}
|
||||
}
|
||||
if ($batch) $inserted += $connection->table('analytics_dim_tempo')->insertOrIgnore($batch);
|
||||
return $inserted;
|
||||
});
|
||||
$this->info("Calendário: {$count} dias; {$inserted} inseridos; restantes preservados. Feriados não classificados; dia útil significa apenas segunda a sexta.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Sgc\StoreInvoiceRequest;
|
||||
use App\Http\Requests\Sgc\StorePaymentRequest;
|
||||
use App\Services\Sgc\IntegrationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SgcIntegrationController extends Controller
|
||||
{
|
||||
private IntegrationService $integrationService;
|
||||
|
||||
public function __construct(IntegrationService $integrationService)
|
||||
{
|
||||
$this->integrationService = $integrationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/integration/sgc/invoices
|
||||
* Recebe fatura do SGC
|
||||
*/
|
||||
public function storeInvoice(StoreInvoiceRequest $request): JsonResponse
|
||||
{
|
||||
$idempotencyKey = $request->header('X-Idempotency-Key');
|
||||
|
||||
if (!$idempotencyKey) {
|
||||
return response()->json([
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'MISSING_IDEMPOTENCY_KEY',
|
||||
'message' => 'Header X-Idempotency-Key é obrigatório',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Verificar assinatura HMAC (em produção)
|
||||
// $this->verificarAssinatura($request);
|
||||
|
||||
$resultado = $this->integrationService->receberFatura(
|
||||
$request->validated(),
|
||||
$idempotencyKey
|
||||
);
|
||||
|
||||
$statusCode = match($resultado['status']) {
|
||||
'ACCEPTED' => 201,
|
||||
'DUPLICATE' => 409,
|
||||
'REJECTED' => 400,
|
||||
default => 500,
|
||||
};
|
||||
|
||||
return response()->json($resultado, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/integration/sgc/payments
|
||||
* Recebe confirmação de pagamento do SGC
|
||||
*/
|
||||
public function storePayment(StorePaymentRequest $request): JsonResponse
|
||||
{
|
||||
$idempotencyKey = $request->header('X-Idempotency-Key');
|
||||
|
||||
if (!$idempotencyKey) {
|
||||
return response()->json([
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'MISSING_IDEMPOTENCY_KEY',
|
||||
'message' => 'Header X-Idempotency-Key é obrigatório',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$resultado = $this->integrationService->receberPagamento(
|
||||
$request->validated(),
|
||||
$idempotencyKey
|
||||
);
|
||||
|
||||
$statusCode = match($resultado['status']) {
|
||||
'ACCEPTED' => 201,
|
||||
'DUPLICATE' => 409,
|
||||
'REJECTED' => 400,
|
||||
default => 500,
|
||||
};
|
||||
|
||||
return response()->json($resultado, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/integration/sgc/invoices/{sgfDocNumber}/status
|
||||
* Consulta estado de uma fatura
|
||||
*/
|
||||
public function getInvoiceStatus(string $sgfDocNumber): JsonResponse
|
||||
{
|
||||
$fatura = \App\Models\Sgc\Fatura::where('sgf_doc_number', $sgfDocNumber)
|
||||
->with(['ordemCompra', 'fornecedor'])
|
||||
->first();
|
||||
|
||||
if (!$fatura) {
|
||||
return response()->json([
|
||||
'status' => 'NOT_FOUND',
|
||||
'message' => 'Fatura não encontrada',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Construir lifecycle
|
||||
$lifecycle = [
|
||||
['event' => 'RECEBIDA', 'at' => $fatura->created_at],
|
||||
];
|
||||
|
||||
if ($fatura->status_3_way_match !== 'Pendente') {
|
||||
$lifecycle[] = [
|
||||
'event' => '3WAY_MATCH',
|
||||
'at' => $fatura->updated_at,
|
||||
'result' => $fatura->status_3_way_match,
|
||||
];
|
||||
}
|
||||
|
||||
if ($fatura->status_pagamento === 'Pago') {
|
||||
$lifecycle[] = [
|
||||
'event' => 'PAGA',
|
||||
'at' => $fatura->data_pagamento,
|
||||
'reference' => $fatura->numero_comprovativo,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'sgf_doc_number' => $fatura->sgf_doc_number,
|
||||
'current_status' => $fatura->status_pagamento,
|
||||
'lifecycle' => $lifecycle,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/integration/sgc/stats
|
||||
* Estatísticas de integração
|
||||
*/
|
||||
public function getStats(): JsonResponse
|
||||
{
|
||||
$stats = [
|
||||
'faturas' => [
|
||||
'total' => \App\Models\Sgc\Fatura::count(),
|
||||
'conformes' => \App\Models\Sgc\Fatura::conformes()->count(),
|
||||
'com_excecao' => \App\Models\Sgc\Fatura::comExcecao()->count(),
|
||||
'pagas' => \App\Models\Sgc\Fatura::pagas()->count(),
|
||||
],
|
||||
'ordens_compra' => [
|
||||
'total' => \App\Models\Sgc\OrdemCompra::count(),
|
||||
'cabimentadas' => \App\Models\Sgc\OrdemCompra::cabimentadas()->count(),
|
||||
],
|
||||
'integracao' => [
|
||||
'aceites' => DB::table('sgc_integration_log')
|
||||
->where('status', 'ACCEPTED')->count(),
|
||||
'rejeitadas' => DB::table('sgc_integration_log')
|
||||
->where('status', 'REJECTED')->count(),
|
||||
'duplicadas' => DB::table('sgc_integration_log')
|
||||
->where('status', 'DUPLICATE')->count(),
|
||||
],
|
||||
];
|
||||
|
||||
return response()->json($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica assinatura HMAC (em produção)
|
||||
*/
|
||||
private function verificarAssinatura(Request $request): bool
|
||||
{
|
||||
$signature = $request->header('X-SGC-Signature');
|
||||
$secret = config('services.sgc.webhook_secret');
|
||||
|
||||
$payload = json_encode($request->all(), JSON_UNESCAPED_SLASHES);
|
||||
$expectedSignature = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
return hash_equals($expectedSignature, $signature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Requests\Sgc\StoreInvoiceRequest;
|
||||
use App\Modules\Purchasing\SgcInvoiceIngress;
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
final class SgcInvoiceIngressController
|
||||
{
|
||||
public function context(int $order, \App\Modules\Purchasing\InvoiceController $controller, \App\Modules\Purchasing\ReceiptRuntime $runtime)
|
||||
{
|
||||
$state = $controller->show($order, $runtime)->getData(true);
|
||||
return response()->json(['order'=>$state['order'], 'revision'=>$state['revision'], 'receipt_progress'=>$state['receipt_progress']]);
|
||||
}
|
||||
|
||||
public function __invoke(StoreInvoiceRequest $request, SgcInvoiceIngress $ingress)
|
||||
{
|
||||
try { return $ingress->receive($request); }
|
||||
catch (QueryException $error) {
|
||||
if (in_array($error->getCode(), ['55P03','40P01','40001'], true)) {
|
||||
return response()->json(['error'=>'RETRY_SAME_KEY', 'message'=>'Operação concorrente; recuperar com a mesma chave e conteúdo.'], 503)->header('Retry-After', '1');
|
||||
}
|
||||
if ($error->getCode() === '23505') return response()->json(['error'=>'DOCUMENT_CONFLICT'], 409);
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Context\RequireModuleContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\Store;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
final class AuthenticateSgcInvoice
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
abort_unless(config('sgc_invoice.enabled') === true, 404);
|
||||
// The installed native adapter is QA-only. This transport cannot remove that boundary.
|
||||
abort_unless(app()->environment('budget-testing'), 403);
|
||||
abort_unless($request->isSecure() || in_array($request->ip(), ['127.0.0.1', '::1'], true), 403);
|
||||
abort_if(strlen($request->getContent()) > 131072, 413);
|
||||
if ($request->isMethod('GET')) abort_if($request->query->count() || strlen($request->getContent()), 422);
|
||||
else abort_unless($request->isJson(), 415);
|
||||
$client = (string)$request->header('X-SGC-Client');
|
||||
$principals = config('sgc_invoice.principals', []);
|
||||
$principal = preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $client) ? ($principals[$client] ?? null) : null;
|
||||
$token = $request->bearerToken();
|
||||
abort_unless(is_array($principal) && ($principal['enabled'] ?? false) === true
|
||||
&& is_string($token) && strlen($token) >= 32 && strlen($token) <= 512
|
||||
&& preg_match('/^[a-f0-9]{64}$/D', $principal['token_sha256'] ?? '')
|
||||
&& hash_equals($principal['token_sha256'], hash('sha256', $token)), 401);
|
||||
foreach (['user_id', 'auth_version', 'tenant_id', 'company_id', 'unit_id'] as $key) {
|
||||
abort_unless(is_int($principal[$key] ?? null) && $principal[$key] > 0, 403);
|
||||
}
|
||||
$version = $principal['context_version'] ?? null;
|
||||
abort_unless(is_string($version) && preg_match('/^[a-f0-9]{64}$/D', $version)
|
||||
&& hash_equals($version, (string)$request->header('X-SGC-Context')), 409);
|
||||
$user = User::find($principal['user_id']);
|
||||
abort_unless($user && $user->auth_version === $principal['auth_version'], 401);
|
||||
$context = app(CompanyContext::class);
|
||||
$guard = Auth::getDefaultDriver();
|
||||
$oldUser = Auth::guard($guard)->user();
|
||||
$oldResolver = $request->getUserResolver();
|
||||
$oldSession = $request->hasSession() ? $request->session() : null;
|
||||
try {
|
||||
// Context and person come exclusively from the server-side principal, never JSON/cookies.
|
||||
$context->selectFor($user->id, $principal['tenant_id'], $principal['company_id'], $principal['unit_id']);
|
||||
Auth::guard($guard)->setUser($user);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$session = new Store('sgc_invoice_ephemeral', new ArraySessionHandler(10));
|
||||
$session->start();
|
||||
$session->put('company_context', ['tenant_id'=>$principal['tenant_id'], 'company_id'=>$principal['company_id'],
|
||||
'unit_id'=>$principal['unit_id'], 'module_id'=>'purchasing', 'token'=>$version]);
|
||||
$request->setLaravelSession($session);
|
||||
$request->headers->set('X-SGF-Context', $version);
|
||||
app(RequireModuleContext::class)->check($request, $context);
|
||||
$response = $next($request);
|
||||
$response->headers->set('Cache-Control', 'no-store, private');
|
||||
return $response;
|
||||
} finally {
|
||||
$context->clear();
|
||||
if (isset($session)) $session->flush();
|
||||
Auth::forgetGuards(); Auth::shouldUse($guard);
|
||||
if ($oldUser) Auth::guard($guard)->setUser($oldUser);
|
||||
$request->setUserResolver($oldResolver);
|
||||
if ($oldSession) $request->setLaravelSession($oldSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Sgc;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class StoreInvoiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return $this->user() !== null; }
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$base = ['phase'=>'required|in:preview,confirm', 'order_id'=>'required|integer|min:1'];
|
||||
if ($this->input('phase') === 'confirm') return $base + ['preview'=>'required|string|max:131072'];
|
||||
return $base + ['revision'=>'required|string|regex:/^[a-f0-9]{64}$/D', 'reason'=>'required|string|min:10|max:2000',
|
||||
'data'=>'required|array:number,issued_on,subtotal,tax,total,items', 'data.number'=>'required|string|max:80',
|
||||
'data.issued_on'=>'required|date_format:Y-m-d', 'data.subtotal'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
|
||||
'data.tax'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D', 'data.total'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
|
||||
'data.items'=>'required|array|min:1|max:50', 'data.items.*'=>'required|array:purchase_order_item_id,quantity,unit,unit_price,tax_rate',
|
||||
'data.items.*.purchase_order_item_id'=>'required|integer|min:1|distinct',
|
||||
'data.items.*.quantity'=>'required|string|regex:/^\d{1,12}(\.\d{1,3})?$/D',
|
||||
'data.items.*.unit'=>'required|string|max:20', 'data.items.*.unit_price'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
|
||||
'data.items.*.tax_rate'=>'required|string|regex:/^\d{1,3}(\.\d{1,2})?$/D'];
|
||||
}
|
||||
|
||||
public function after(): array
|
||||
{
|
||||
return [function ($validator) {
|
||||
$allowed = $this->input('phase') === 'confirm' ? ['phase','order_id','preview'] : ['phase','order_id','revision','reason','data'];
|
||||
if ($this->query->count() || array_diff(array_keys($this->json()->all()), $allowed)) $validator->errors()->add('payload', 'Campos ou parâmetros não permitidos.');
|
||||
if (!preg_match('/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[1-8][a-fA-F0-9]{3}-[89aAbB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$/D', (string)$this->header('X-Idempotency-Key'))) $validator->errors()->add('idempotency_key', 'X-Idempotency-Key UUID obrigatório.');
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sgc;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Fatura extends Model
|
||||
{
|
||||
protected $table = 'sgc_faturas';
|
||||
|
||||
protected $fillable = [
|
||||
'fatura_id',
|
||||
'ordem_compra_id',
|
||||
'fornecedor_id',
|
||||
'data_fatura',
|
||||
'data_vencimento',
|
||||
'data_pagamento',
|
||||
'valor_fatura',
|
||||
'valor_pago',
|
||||
'desconto_obtido',
|
||||
'iva_valor',
|
||||
'moeda',
|
||||
'taxa_cambio',
|
||||
'status_3_way_match',
|
||||
'status_pagamento',
|
||||
'numero_comprovativo',
|
||||
'motivo_excecao',
|
||||
'sgf_doc_number',
|
||||
'sgf_sync_timestamp',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'data_fatura' => 'date',
|
||||
'data_vencimento' => 'date',
|
||||
'data_pagamento' => 'date',
|
||||
'valor_fatura' => 'decimal:2',
|
||||
'valor_pago' => 'decimal:2',
|
||||
'desconto_obtido' => 'decimal:2',
|
||||
'iva_valor' => 'decimal:2',
|
||||
'taxa_cambio' => 'decimal:6',
|
||||
'sgf_sync_timestamp' => 'datetime',
|
||||
];
|
||||
|
||||
public function ordemCompra(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OrdemCompra::class);
|
||||
}
|
||||
|
||||
public function fornecedor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Fornecedor::class);
|
||||
}
|
||||
|
||||
public function scopeConformes($query)
|
||||
{
|
||||
return $this->where('status_3_way_match', 'Conforme');
|
||||
}
|
||||
|
||||
public function scopePagas($query)
|
||||
{
|
||||
return $this->where('status_pagamento', 'Pago');
|
||||
}
|
||||
|
||||
public function scopeComExcecao($query)
|
||||
{
|
||||
return $this->where('status_3_way_match', 'Com Excecao');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sgc;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class OrdemCompra extends Model
|
||||
{
|
||||
protected $table = 'sgc_ordens_compra';
|
||||
|
||||
protected $fillable = [
|
||||
'oc_id',
|
||||
'requisicao_id',
|
||||
'data_oc',
|
||||
'fornecedor_id',
|
||||
'centro_custo_id',
|
||||
'categoria',
|
||||
'valor_comprometido',
|
||||
'moeda',
|
||||
'taxa_cambio',
|
||||
'prazo_pagamento_dias',
|
||||
'data_entrega_prevista',
|
||||
'status',
|
||||
'cabimentado',
|
||||
'sgf_compromisso_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'data_oc' => 'date',
|
||||
'data_entrega_prevista' => 'date',
|
||||
'valor_comprometido' => 'decimal:2',
|
||||
'taxa_cambio' => 'decimal:6',
|
||||
'cabimentado' => 'boolean',
|
||||
];
|
||||
|
||||
public function fornecedor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Fornecedor::class);
|
||||
}
|
||||
|
||||
public function centroCusto(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CentroCusto::class);
|
||||
}
|
||||
|
||||
public function faturas(): HasMany
|
||||
{
|
||||
return $this->hasMany(Fatura::class, 'ordem_compra_id');
|
||||
}
|
||||
|
||||
public function recebimentos(): HasMany
|
||||
{
|
||||
return $this->hasMany(Recebimento::class, 'ordem_compra_id');
|
||||
}
|
||||
|
||||
public function scopeEmitidas($query)
|
||||
{
|
||||
return $query->where('status', 'Emitida');
|
||||
}
|
||||
|
||||
public function scopeCabimentadas($query)
|
||||
{
|
||||
return $query->where('cabimentado', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use Brick\Math\{BigInteger,BigRational};
|
||||
/** Pure proposed QA straight-line policy. No fiscal rate, asset, journal or clock source. */
|
||||
final class AssetDepreciation
|
||||
{
|
||||
public static function quota(?string $cost,?string $residual,?int $months,int $month):array
|
||||
{
|
||||
abort_unless($month>=1&&$month<=600,422);foreach([$cost,$residual]as$value)if($value!==null)abort_unless(preg_match('/^[0-9]{1,12}\.[0-9]{2}$/D',$value)===1,422,'Valor documental deve ter duas casas.');if($months!==null)abort_unless($months>=1&&$months<=600&&$month<=$months,422);
|
||||
if($cost===null||$residual===null||$months===null)return['complete'=>false,'quota_cents'=>null,'accumulated_cents'=>null,'carrying_cents'=>null,'exact_quota_cents'=>null,'difference_cents'=>null,'legal_tax_treatment'=>null];
|
||||
$c=BigInteger::of(str_replace('.','',$cost));$r=BigInteger::of(str_replace('.','',$residual));abort_if($r->isGreaterThan($c),422,'Residual excede o custo.');$base=$c->minus($r);$standard=$base->quotient($months);$quota=$month===$months?$base->minus($standard->multipliedBy($months-1)):$standard;$accumulated=$month===$months?$base:$standard->multipliedBy($month);$exact=BigRational::of($base)->dividedBy($months)->simplified();return['complete'=>true,'cost_cents'=>(string)$c,'residual_cents'=>(string)$r,'life_months'=>$months,'month'=>$month,'quota_cents'=>(string)$quota,'accumulated_cents'=>(string)$accumulated,'carrying_cents'=>(string)$c->minus($accumulated),'exact_quota_cents'=>(string)$exact,'difference_cents'=>(string)$exact->minus($quota)->simplified(),'policy'=>'QA-straight-line-floor-months-final-residual-adjustment-v1','legal_tax_treatment'=>null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;use App\Modules\Finance\CashWorkspace;use Illuminate\Support\Facades\DB;use Tempo\Finance\Payables\Payables;
|
||||
final class CashLedgerSource
|
||||
{
|
||||
public function source(User $u,string $kind,int $id):array
|
||||
{
|
||||
$cash=app(CashWorkspace::class);$s=$cash->scope($u->id,'view',true);$b=$cash->binding();$row=DB::table('cash_events')->where($s)->where(['id'=>$id,'session_key'=>$b['session_key'],'action'=>'review','state'=>'approved','kind'=>$kind==='cash.receipt_classified'?'receipt':'expense'])->first()??throw new \DomainException('Origem de Caixa fora do âmbito.');
|
||||
$p=DB::table('cash_events')->where($s)->where(['id'=>$row->proposal_id,'action'=>'prepare','kind'=>$row->kind])->first();$data=json_decode($row->data,true);$deps=json_decode($row->dependencies,true);$receipt=$cash->receipt($s,$b);$hash=hash('sha256',Payables::canonical($receipt));
|
||||
if(!$p||$p->actor_id===$row->actor_id||Payables::canonical(json_decode($p->data,true))!==Payables::canonical($data)||($deps['source']??null)!==$hash||!($deps['open']??false)||$data['amount_minor']!==($row->kind==='receipt'?100000:5000))throw new \DomainException('Caixa sem proposta, origem e revisão distintas.');
|
||||
if($row->kind==='receipt'&&!SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','receivable.allocated')->where('snapshot->source->source_id',$receipt['allocation']['id'])->exists())throw new \DomainException('Contabilize primeiro a afectação original; a Caixa só reclassifica a liquidez já reconhecida.');
|
||||
return ['effect_type'=>$kind,'source_id'=>$id,'obligation_id'=>$id,'origin_key'=>hash('sha256',Payables::canonical([$s,$b['session_key']])),'amount_minor'=>$data['amount_minor'],'principal_minor'=>$data['amount_minor'],'tax_minor'=>0,'currency'=>'MZN','compatible'=>true,'history_imported'=>false,'cash_effect_on'=>$data['date'],'permitted_period_key'=>$b['period_key'],'recognition_revision'=>hash('sha256',Payables::canonical(['event'=>(array)$row,'proposal'=>(array)$p,'source'=>$receipt])),'cash_event'=>(array)$row];
|
||||
}
|
||||
public function period(User $u,array $period):array
|
||||
{
|
||||
if(config('cash_qa.enabled')!==true)return ['facts'=>0,'pending'=>0,'hash'=>hash('sha256','cash-disabled')];$cash=app(CashWorkspace::class);$s=$cash->scope($u->id,'view',true);$b=$cash->binding();$facts=0;$pending=0;$h=hash_init('sha256');$q=DB::table('cash_events')->where($s);
|
||||
foreach((clone$q)->orderBy('id')->cursor() as $row){$facts++;hash_update($h,Payables::canonical((array)$row));if($row->session_key!==$b['session_key']){$pending++;continue;}if($row->action==='prepare'&&!(clone$q)->where('proposal_id',$row->id)->where('action','review')->exists())$pending++;
|
||||
if($row->action==='review'&&$row->state==='approved'&&in_array($row->kind,['receipt','expense'],true)){try{$type=$row->kind==='receipt'?'cash.receipt_classified':'cash.expense_confirmed';$source=$this->source($u,$type,$row->id);if(!SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type',$type)->where('snapshot->source->source_id',$row->id)->where('snapshot->source->recognition_revision',$source['recognition_revision'])->exists())$pending++;}catch(\DomainException|\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){$pending++;}}
|
||||
}
|
||||
if($facts>0&&!(clone$q)->where(['session_key'=>$b['session_key'],'state'=>'closed'])->exists())$pending++;return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Modules\Purchasing\{ReceiptRuntime,RequisitionAccess};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\{DB,Auth,Schema};
|
||||
use Tempo\Finance\GeneralLedger\{LedgerSchema,PayableJournal,StockValuation};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Invoice,PurchaseOrder};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,SupplierPayables};
|
||||
|
||||
/** Host authority and immutable commercial/cost sources; one native financial writer/book. */
|
||||
final class ComposedLedger
|
||||
{
|
||||
private function withHost(object $host,object $user,\Closure $work):mixed
|
||||
{
|
||||
$default=DB::getDefaultConnection();$guard=Auth::getDefaultDriver();$nativeUser=Auth::user();$environment=app()->environment();
|
||||
DB::setDefaultConnection($host->getName());Schema::clearResolvedInstance('db.schema');Auth::shouldUse('web');Auth::guard('web')->setUser($user);app()->instance('env','budget-testing');
|
||||
try{return $work();}finally{DB::setDefaultConnection($default);Schema::clearResolvedInstance('db.schema');Auth::shouldUse($guard);if($nativeUser)Auth::guard($guard)->setUser($nativeUser);app()->instance('env',$environment);}
|
||||
}
|
||||
private function source(object $actor,array $binding,array $financialScope,int $id):array
|
||||
{
|
||||
$o=DB::table('pa_obligations')->where($financialScope)->where('id',$id)->first()??throw new \DomainException('Obrigação fora da origem financeira autorizada.');
|
||||
$s=DB::table('pa_sources')->where($financialScope)->where('id',$o->source_id)->first()??throw new \DomainException('Origem financeira ausente.');
|
||||
$orders=PurchaseOrder::visibleTo($actor)->where(fn($q)=>$q->whereIn('id',$binding['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)));
|
||||
abort_unless(Invoice::where('tenant_id',$actor->tenant_id)->whereIn('purchase_order_id',$orders->select('id'))->where('id',$s->local_source_id)->exists(),403);
|
||||
$service=app(SupplierPayables::class);$service->readContext($actor);$document=$service->document($actor,(int)$s->local_source_id);$position=$service->engine($actor)->positionForSource((int)$s->local_source_id);
|
||||
if(!$position||(int)$position['id']!==$id||$position['source_conflict'])throw new \DomainException('A fonte financeira mudou; exige nova conferência.');
|
||||
$proposal=DB::table('pa_proposals')->where($financialScope)->where('id',$o->proposal_id)->first()??throw new \DomainException('Reconhecimento sem conferência.');
|
||||
return ['effect_type'=>'supplier_payable.recognised','origin_key'=>$s->source_key,'obligation_id'=>$id,'local_source_id'=>(int)$s->local_source_id,'document_revision'=>$o->source_revision,'recognition_revision'=>hash('sha256',Payables::canonical(['obligation'=>(array)$o,'recognition_snapshot'=>$proposal->snapshot])),'principal_minor'=>(int)$o->principal_minor,'currency'=>$o->currency,'due_on'=>$o->due_on,'tax_minor'=>$document['tax_minor'],'compatible'=>true,'history_imported'=>$position['history_imported']];
|
||||
}
|
||||
|
||||
public function run(string $action,\Closure $work):mixed
|
||||
{
|
||||
CompositionRuntime::assert();abort_unless(config('sgf_single_installation.ledger_enabled')===true,404);
|
||||
abort_unless(request()->session()->get('company_context.module_id')==='accounting',409,'Seleccione Contabilidade.');
|
||||
$context=app(CompanyContext::class);$scope=$context->identifiers()+['unit_id'=>$context->unitId()];$hostUser=$context->userId();$host=DB::connection();$hostIdentity=CompositionRuntime::physical($host);
|
||||
$generation=request()->session()->get('company_context.token');$hostActor=\App\Models\User::findOrFail($hostUser);
|
||||
try{return $host->transaction(function()use($action,$work,$context,$scope,$hostUser,$hostActor,$host,$hostIdentity,$generation){
|
||||
$host->statement("SET LOCAL lock_timeout='5s'");$host->table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
return app(ReceiptRuntime::class)->run('general_ledger.'.$action,function($actor,$binding,$revision,$origin)use($action,$work,$context,$scope,$hostUser,$hostActor,$host,$hostIdentity,$generation){
|
||||
$native=DB::connection();LedgerSchema::assertSynthetic($native);$r=$native->table('gl_sgf_composition')->where('tenant_id',$actor->tenant_id)->first();
|
||||
abort_unless($r&&$r->host_database===$hostIdentity['database']&&(int)$r->company_id===$scope['company_id']&&(int)$r->unit_id===$scope['unit_id'],403,'Livro financeiro ligado a outro contexto SGF.');
|
||||
$ledgerScope=['tenant_id'=>(int)$actor->tenant_id,'company_id'=>$scope['company_id'],'unit_id'=>$scope['unit_id']];$financialScope=['tenant_id'=>(int)$actor->tenant_id,'company_id'=>0,'unit_id'=>0];
|
||||
// The legacy payable book has no SGF unit column. Never expose its
|
||||
// listings or settlement facts until every obligation is qualified.
|
||||
$orders=PurchaseOrder::visibleTo($actor)->where(fn($q)=>$q->whereIn('id',$binding['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)));
|
||||
$unqualified=$native->table('pa_obligations as o')->where(['o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0])->whereNotExists(function($q)use($orders,$actor){
|
||||
$q->selectRaw('1')->from('pa_sources as s')->join('invoices as i','i.id','=','s.local_source_id')->whereColumn('s.id','o.source_id')->whereColumn('s.tenant_id','o.tenant_id')->whereColumn('s.company_id','o.company_id')->whereColumn('s.unit_id','o.unit_id')->where('i.tenant_id',$actor->tenant_id)->whereIn('i.purchase_order_id',$orders->select('id'));
|
||||
})->exists();
|
||||
abort_if($unqualified,503,'Há origens financeiras sem unidade conferida; a partição exige decisão explícita.');
|
||||
$identity=app(SupplierPayables::class)->readContext($actor);$person=$identity['person'];
|
||||
$authorize=function(string $right)use($host,$hostUser,$scope,$context,$actor,$person,$ledgerScope,$generation){
|
||||
abort_unless($context->userId()===$hostUser&&$context->identifiers()+['unit_id'=>$context->unitId()]===$scope&&request()->session()->get('company_context.token')===$generation,409,'Contexto SGF alterado.');
|
||||
foreach(['general_ledger.view','general_ledger.'.$right] as $permission)abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$hostUser,'permission'=>$permission])->exists(),403);
|
||||
$fresh=app(CurrentActor::class)->lock($actor,['finance']);abort_if(app(OrganizationAccess::class)->maintenanceOnly($fresh),403);
|
||||
abort_unless(DB::table('pa_access')->where($ledgerScope)->where(['user_id'=>$actor->id,'action'=>'general_ledger.'.$right,'person'=>$person])->exists(),403);
|
||||
};
|
||||
$authorize('view');
|
||||
$isolation=fn()=>app(ComposedMaterialIsolation::class)->check($host,$native,$ledgerScope);
|
||||
$stock=new StockValuation($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),$isolation);
|
||||
$additional=function($kind,$id)use($host,$hostActor,$stock,$isolation,$native,$ledgerScope){
|
||||
if(str_starts_with($kind,'stock.'))return $stock->source($kind,$id);
|
||||
if(str_starts_with($kind,'workshop.')||str_starts_with($kind,'industry.'))$isolation();
|
||||
return $this->withHost($host,$hostActor,function()use($hostActor,$kind,$id,$native,$ledgerScope){
|
||||
if(str_starts_with($kind,'assets.'))return app(FixedAssetLedgerSource::class)->source($hostActor,$kind,$id,$native,$ledgerScope);
|
||||
if(str_starts_with($kind,'cash.'))return app(CashLedgerSource::class)->source($hostActor,$kind,$id);if(str_starts_with($kind,'workshop.'))return $kind==='workshop.direct_cost.validated'?app(CostLedgerSource::class)->source($hostActor,$id):app(CostLedgerSource::class)->correctionSource($hostActor,$kind,$id);
|
||||
if(str_starts_with($kind,'industry.'))return $kind==='industry.cost.validated'?app(IndustrialLedgerSource::class)->source($hostActor,$id):app(IndustrialLedgerSource::class)->correctionSource($hostActor,$kind,$id);
|
||||
return app(SalesLedgerSource::class)->source($hostActor,$kind,$id);
|
||||
});
|
||||
};
|
||||
$period=function($p)use($host,$hostActor,$stock,$native,$ledgerScope){$extra=$this->withHost($host,$hostActor,function()use($p,$hostActor,$native,$ledgerScope){$facts=0;$pending=0;$h=hash_init('sha256');foreach([SalesLedgerSource::class,CostLedgerSource::class,IndustrialLedgerSource::class,CashLedgerSource::class] as $class){$v=app($class)->period($hostActor,$p);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}if(config('fixed_assets.enabled')===true){$v=app(FixedAssetLedgerSource::class)->period($hostActor,$p,$native,$ledgerScope);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($h,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];});$physical=$stock->period($p);return ['facts'=>$extra['facts']+$physical['facts'],'pending'=>$extra['pending']+$physical['pending'],'hash'=>hash('sha256',$extra['hash'].$physical['hash'])];};
|
||||
$engine=new PayableJournal($native,$ledgerScope,$actor->id,$person,$authorize,fn()=>$authorize('view'),fn(int $id)=>$this->source($actor,$binding,$financialScope,$id),null,$additional,$period,$financialScope);
|
||||
return $work($engine,$actor,$binding,$origin+['sgf_host_user'=>$hostUser,'sgf_host_scope'=>$scope,'binding_revision'=>$revision],$ledgerScope,$stock,fn(\Closure $read)=>$this->withHost($host,$hostActor,$read));
|
||||
});
|
||||
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Livro ocupado. Recupere a mesma operação, sem gerar outra chave.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Shared\Context\ModuleRecords;
|
||||
|
||||
/** Bounded candidates per source family; conferences remain individual and current. */
|
||||
final class ComposedLedgerCandidates
|
||||
{
|
||||
public const KINDS=['all','supplier_payable.recognised','allocation.confirmed','allocation.reversed','sales.invoice','receivable.allocated','receivable.reversed','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return'];
|
||||
public function read(array $d,object $actor,array $scope,object $stock,\Closure $hostRead):array
|
||||
{
|
||||
$page=(int)($d['source_page']??1);$size=(int)($d['source_per_page']??25);$kind=$d['source_kind']??'all';$sources=[];$last=1;$totals=[];
|
||||
$accept=fn($k)=>$kind==='all'||$kind===$k;
|
||||
$append=function($query,$type,$columns,\Closure $map)use(&$sources,&$last,&$totals,$page,$size,$accept){if(!$accept($type))return;$p=$query->paginate($size,$columns,'source_page',$page);$last=max($last,$p->lastPage());$totals[$type]=$p->total();foreach($p->items() as $row)$sources[]=['kind'=>$type]+$map($row);};
|
||||
$financial=['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0];
|
||||
$append(DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('invoices as i','i.id','=','s.local_source_id')->where(['o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0])->orderBy('o.id'),'supplier_payable.recognised',['o.id','o.principal_minor','i.number'],fn($o)=>['id'=>$o->id,'label'=>'Compra '.$o->number,'amount'=>$o->principal_minor]);
|
||||
foreach(['ps_allocations'=>'allocation.confirmed','ps_reversals'=>'allocation.reversed'] as $table=>$type)$append(DB::table($table)->where($financial)->orderBy('id'),$type,['id','amount_minor'],fn($o)=>['id'=>$o->id,'label'=>($type==='allocation.confirmed'?'Afectação':'Reversão').' fornecedor #'.$o->id,'amount'=>$o->amount_minor]);
|
||||
$hostRead(function()use($scope,$append){
|
||||
$append(\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderBy('id'),'sales.invoice',['id','series_code','number'],fn($i)=>['id'=>$i->id,'label'=>'Venda '.$i->series_code.'/'.$i->number,'amount'=>null]);
|
||||
foreach(['allocate'=>'receivable.allocated','reverse_allocation'=>'receivable.reversed','reduction'=>'receivable.reduced','reverse_reduction'=>'receivable.reduction_reversed'] as $event=>$type)$append(DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(['r.tenant_id'=>$scope['tenant_id'],'r.company_id'=>$scope['company_id'],'r.unit_id'=>$scope['unit_id']])->whereIn('r.invoice_id',\App\Modules\Services\QualifiedInvoiceSource::query()->select('id'))->where('e.kind',$event)->orderBy('e.id'),$type,['e.id'],fn($e)=>['id'=>$e->id,'label'=>'Afectação/reversão cliente #'.$e->id,'amount'=>null]);
|
||||
if(config('cash_qa.enabled')===true)foreach(['receipt'=>'cash.receipt_classified','expense'=>'cash.expense_confirmed'] as $kind=>$type)$append(DB::table('cash_events')->where($scope)->where(['action'=>'review','state'=>'approved','kind'=>$kind])->orderBy('id'),$type,['id'],fn($r)=>['id'=>$r->id,'label'=>'Caixa QA #'.$r->id,'amount'=>null]);
|
||||
});
|
||||
foreach(['gl_cost_sources'=>['order_id','workshop.direct_cost.validated','Custo de oficina'],'gl_industrial_sources'=>['lot_id','industry.cost.validated','Custo industrial']] as $table=>[$column,$type,$label])$append(DB::table($table)->where($scope)->orderBy('id'),$type,[$column],fn($o)=>['id'=>$o->$column,'label'=>$label.' #'.$o->$column,'amount'=>null]);
|
||||
$stockData=[];if($kind==='all'||str_starts_with($kind,'stock.'))foreach(DB::table('gl_stock_books')->where($scope)->orderBy('id')->cursor() as $book){$value=$stock->page($book->id,$page,$size);$stockData[]=$value;$last=max($last,(int)ceil($value['pagination']['total']/$size));foreach($value['rows'] as $movement){$m=$movement['movement'];$type=\Tempo\Finance\GeneralLedger\StockValuation::TYPES[$m['movement_type']]??null;if($type&&$accept($type))$sources[]=['kind'=>$type,'id'=>$m['id'],'label'=>'Movimento físico #'.$m['id'],'amount'=>null];}}
|
||||
return ['sources'=>$sources,'stock'=>$stockData,'source_pagination'=>['page'=>$page,'last_page'=>$last,'per_page'=>$size,'kind'=>$kind,'totals_by_family'=>$totals,'independent_family_pages'=>true]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Finance\GeneralLedger\ReportsWorkspace;
|
||||
use Tempo\Purchasing\Embedded\Models\AuditLog;
|
||||
use Tempo\Purchasing\Embedded\Services\RequisitionWorkflow;
|
||||
|
||||
final class ComposedLedgerController
|
||||
{
|
||||
public function focused(Request $r,string $family)
|
||||
{
|
||||
[$alias,$target,$kind]=match($family){'sales'=>['invoice_id','sales_invoice_id','sales.invoice'],'cost'=>['order_id','cost_order_id','workshop.direct_cost.validated'],'industrial'=>['lot_id','industrial_lot_id','industry.cost.validated']};
|
||||
$d=$r->validate([$alias=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$query=['source_kind'=>$kind];if(isset($d[$alias]))$query[$target]=(int)$d[$alias];if(isset($d['page']))$query['page']=(int)$d['page'];$r->query->replace($query);return $this->page($r,app(ComposedLedger::class));
|
||||
}
|
||||
public function receipt(Request $r,string $key,?string $domain=null)
|
||||
{
|
||||
abort_if($r->query()!==[],422);abort_unless($domain===null||in_array($domain,['recognition','settlement'],true),422);
|
||||
try{return app(ComposedLedger::class)->run('view',function($engine,$actor)use($key,$domain){if($domain===null)$data=$engine->receipt($key);else{$service=app(\Tempo\Purchasing\Embedded\Services\SupplierPayables::class);$service->readContext($actor);$marker=DB::table('gl_sgf_composition')->where('tenant_id',$actor->tenant_id)->first();$financialScope=['tenant_id'=>(int)$actor->tenant_id,'company_id'=>(int)$marker->financial_company_id,'unit_id'=>(int)$marker->financial_unit_id];$data=\Tempo\Finance\Payables\ReceiptReader::read(DB::connection(),$domain,$financialScope,(int)$actor->id,$key,fn()=>$service->readContext($actor));}abort_if($data===null,404,'Comprovativo não encontrado para esta pessoa e contexto.');return response()->json($data)->header('Cache-Control','no-store');});}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
}
|
||||
public function flow(Request $r)
|
||||
{
|
||||
$d=$r->validate(['source'=>'required|integer|min:1']);abort_if(array_diff(array_keys($r->query()),['source']),422);
|
||||
return app(ComposedLedger::class)->run('view',function($engine,$actor)use($d){$id=DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->where(['o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0,'s.local_source_id'=>(int)$d['source']])->value('o.id');abort_unless($id,404,'Origem sem obrigação reconhecida neste contexto.');return redirect('/general-ledger?obligation_id='.$id);});
|
||||
}
|
||||
private function rights():array
|
||||
{
|
||||
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId(),'user_id'=>$ctx->userId()];$permissions=DB::table('permission_grants')->where($scope)->pluck('permission')->all();$out=[];
|
||||
foreach(['view','prepare','validate','post','correct_prepare','correct_validate','correct_post','period_prepare','period_validate','period_close'] as $right)$out[$right]=in_array('general_ledger.'.$right,$permissions,true);return $out;
|
||||
}
|
||||
|
||||
public function page(Request $r,ComposedLedger $ledger)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','accounts_page'=>'sometimes|integer|min:1|max:100000','accounts_per_page'=>'sometimes|integer|in:25,100','account_q'=>'sometimes|nullable|string|max:80','source_page'=>'sometimes|integer|min:1|max:100000','source_per_page'=>'sometimes|integer|in:25,100','source_kind'=>'sometimes|in:'.implode(',',ComposedLedgerCandidates::KINDS),'period_key'=>'sometimes|string|max:80','account_id'=>'sometimes|nullable|integer|min:1','obligation_id'=>'sometimes|integer|min:1','sales_invoice_id'=>'sometimes|integer|min:1','cost_order_id'=>'sometimes|integer|min:1','industrial_lot_id'=>'sometimes|integer|min:1']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);if(($d['account_id']??null)===null)unset($d['account_id']);$rights=$this->rights();
|
||||
return $ledger->run('view',function($engine,$actor,$binding,$origin,$scope,$stock,$hostRead)use($r,$d,$rights){
|
||||
if(isset($d['sales_invoice_id']))abort_unless($hostRead(fn()=>\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->where('id',$d['sales_invoice_id'])->exists()),403,'Documento fora do contexto e módulo.');
|
||||
$data=$engine->listing(array_diff_key($d,['source_page'=>true,'source_per_page'=>true,'source_kind'=>true,'accounts_page'=>true,'accounts_per_page'=>true,'account_q'=>true]));$data['cycle']=$engine->cycleListing((int)($d['page']??1));$data['permissions']=$rights;$data['current_actor']=$actor->id;$data['sgf_scope']=$origin['sgf_host_scope'];
|
||||
$data=array_merge($data,app(ComposedLedgerCandidates::class)->read($d,$actor,$scope,$stock,$hostRead));
|
||||
$accounts=DB::table('gl_accounts')->where($scope);if(($d['account_q']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['account_q']).'%';$accounts->where(fn($q)=>$q->whereRaw("code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("label ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
$chart=$accounts->orderBy('code')->paginate((int)($d['accounts_per_page']??25),['id','code','label','currency','is_movement'],'accounts_page',(int)($d['accounts_page']??1));$data['chart_accounts']=$chart->items();$data['chart_pagination']=['page'=>$chart->currentPage(),'last_page'=>$chart->lastPage(),'per_page'=>$chart->perPage(),'total'=>$chart->total()];
|
||||
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
|
||||
return response()->view('accounting.composed-ledger',['data'=>$data,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
|
||||
public function command(Request $r,string $action,ComposedLedger $ledger)
|
||||
{
|
||||
$allowed=['prepare','validate','post','effect-prepare','correct-prepare','correct-validate','correct-post','period-prepare','period-validate','period-close'];abort_unless(in_array($action,$allowed,true),404);
|
||||
$right=$action==='effect-prepare'?'prepare':str_replace('-','_',$action);$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$data=$r->all();abort_if(strlen(json_encode($data))>65536,422);
|
||||
try{return $ledger->run($right,function($engine,$actor,$binding,$origin)use($action,$data,$key){$hash=hash('sha256',Payables::canonical([$action,$data]));$old=AuditLog::where('tenant_id',$actor->tenant_id)->where('action','sgf.ledger.command')->where('after->operation_key',$key)->first();if($old)abort_unless(($old->after['sgf_context_key']??null)===$origin['sgf_context_key']&&($old->after['request_hash']??null)===$hash,409,'Chave utilizada noutro contexto/conteúdo.');$result=$engine->command($action,$data,$key);if(!$old)app(RequisitionWorkflow::class)->audit($actor,'sgf.ledger.command',null,null,$origin+['operation_key'=>$key,'request_hash'=>$hash,'command'=>$action,'reason'=>$data['reason']??'','result'=>$result]);return response()->json($result)->header('Cache-Control','no-store');});}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(409,$e->getMessage());}
|
||||
}
|
||||
|
||||
public function report(Request $r,string $kind,ComposedLedger $ledger)
|
||||
{
|
||||
abort_unless(in_array($kind,['trial-balance','account-ledger'],true),404);$d=$r->validate(['from_on'=>'required|date_format:Y-m-d','to_on'=>'required|date_format:Y-m-d|after_or_equal:from_on','account_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
try{return $ledger->run('view',function($engine)use($r,$kind,$d){$data=$kind==='trial-balance'?$engine->trialBalance($d):$engine->accountLedger($d);return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response(ReportsWorkspace::render($data,['host'=>'SGF · livro integrado','return_url'=>'/general-ledger']))->header('Cache-Control','no-store');});}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
}
|
||||
public function stockCommand(Request $r,string $action,ComposedLedger $ledger)
|
||||
{
|
||||
abort_unless(in_array($action,['rate-prepare','rate-review'],true),404);$right=$action==='rate-prepare'?'prepare':'validate';$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);
|
||||
$data=$r->all();abort_if(strlen(json_encode($data))>65536,422);
|
||||
try{return $ledger->run($right,function($e,$actor,$b,$origin,$s,$stock)use($action,$data,$key){
|
||||
$hash=hash('sha256',Payables::canonical([$action,$data]));
|
||||
$old=AuditLog::where('tenant_id',$actor->tenant_id)->where('action','sgf.ledger.stock-command')->where('after->operation_key',$key)->first();
|
||||
if($old)abort_unless(($old->after['sgf_context_key']??null)===$origin['sgf_context_key']&&($old->after['request_hash']??null)===$hash,409,'Chave utilizada noutro contexto/conteúdo.');
|
||||
// The public review label maps to the original engine's validation command.
|
||||
$result=$stock->command($action==='rate-review'?'rate-validate':$action,$data,$key);
|
||||
if(!$old)app(RequisitionWorkflow::class)->audit($actor,'sgf.ledger.stock-command',null,null,$origin+['operation_key'=>$key,'request_hash'=>$hash,'command'=>$action,'reason'=>$data['reason']??'','result'=>$result]);
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
});}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(409,$e->getMessage());}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Explicit, fixed synthetic material perimeter. Unknown or shared origins fail closed. */
|
||||
final class ComposedMaterialIsolation
|
||||
{
|
||||
public function check($host,$native,array $scope):void
|
||||
{
|
||||
$physical=CompositionRuntime::physical($host);$path='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$physical['composition'].'/single-material-perimeter.json';
|
||||
$pin=config('sgf_single_installation.material_perimeter_sha256');
|
||||
if(!is_string($pin)||!preg_match('/^[a-f0-9]{64}$/D',$pin)||!is_file($path)||is_link($path)||!hash_equals($pin,hash_file('sha256',$path)))throw new \DomainException('Perímetro de materiais não conferido; não combinar custos e valorização.');
|
||||
$p=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);
|
||||
if(($p['contract']??null)!=='SGF-QA-DISJOINT-MATERIALS-v1'||($p['scope']??null)!==$scope||($p['host_database']??null)!==$host->getDatabaseName()||($p['composition']??null)!==$physical['composition'])throw new \DomainException('Perímetro pertence a outro contexto.');
|
||||
$stocks=$native->table('gl_stock_books')->where($scope)->orderBy('id')->get(['stock_item_id','warehouse_id']);
|
||||
$actual=$stocks->map(fn($r)=>(array)$r)->all();if(Payables::canonical($actual)!==Payables::canonical($p['valued_stock']))throw new \DomainException('Artigo/armazém de valorização mudou; nova conferência obrigatória.');
|
||||
$codes=$native->table('stock_items')->where('tenant_id',$scope['tenant_id'])->whereIn('id',$stocks->pluck('stock_item_id'))->pluck('code')->all();
|
||||
foreach($native->table('gl_cost_sources')->where($scope)->orderBy('id')->cursor() as $map){
|
||||
$claim=$p['direct_costs'][(string)$map->order_id]??null;
|
||||
if(!$claim||$claim['material_origin']!==$map->material_origin)throw new \DomainException('Custo sem declaração individual de exclusão do Stock valorizado.');
|
||||
if(!$host->table('wo_orders')->where($scope)->where('id',$map->order_id)->exists())throw new \DomainException('Ordem de custo fora da empresa/unidade.');
|
||||
$entry=$host->table('wo_entries')->where('order_id',$map->order_id)->where('origin',$map->material_origin)->orderByDesc('version')->first();$data=$entry?json_decode($entry->data,true):null;
|
||||
if(!$entry||$entry->kind!=='material'||($data['resource']??null)!==$claim['resource']||in_array($claim['resource'],$codes,true)||!str_starts_with($claim['resource'],'QA-MATERIAL-DIRECTO-')||isset($data['stock_item_id'])||isset($data['delivery_movement_id']))throw new \DomainException('Material directo partilhado, alterado ou sem fronteira física explícita.');
|
||||
}
|
||||
foreach($native->table('gl_industrial_sources')->where($scope)->orderBy('id')->cursor() as $map){
|
||||
$claim=$p['industrial_costs'][(string)$map->lot_id]??null;$origins=json_decode($map->origins,true);
|
||||
if(!$claim||$claim['material_origin']!==($origins['material']??null)||in_array($claim['material_origin'],$codes,true))throw new \DomainException('Material industrial sem exclusão individual do Stock valorizado.');
|
||||
$entry=$host->table('ic_inputs')->where($scope)->where('lot_id',$map->lot_id)->where('kind','material')->where('source_ref',$claim['material_origin'])->orderByDesc('version')->first();
|
||||
if(!$entry||!str_starts_with($entry->source_ref,'IND-TEST-MATERIAL-'))throw new \DomainException('Origem industrial fora do perímetro sintético seleccionado.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class CostLedgerController extends Controller
|
||||
{
|
||||
public function index(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->focused($r,'cost');
|
||||
$d=$r->validate(['order_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{$data=$ledger->costs($r->user(),isset($d['order_id'])?(int)$d['order_id']:null,(int)($d['page']??1));}catch(\DomainException $e){abort(403,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.cost-ledger',['data'=>$data])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Workshop\DirectCosts;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Brick\Math\BigRational;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Explicit composition of an actual cost conference; no technical, financial or outbox writes. */
|
||||
final class CostLedgerSource
|
||||
{
|
||||
private function scope(User $u):array
|
||||
{
|
||||
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);DirectCosts::guard();$ctx=app(CompanyContext::class);
|
||||
abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$s=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];
|
||||
foreach(['workshop.view','direct_costs.view'] as $p)app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);
|
||||
foreach(['gl_cost_sources','wo_orders','wo_entries','wo_decisions','dc_rates','dc_calculations','dc_decisions'] as $t)abort_unless(str_starts_with($t,'gl_')?SourceBook::has($t):DB::getSchemaBuilder()->hasTable($t),503,'Origem de custo não composta neste ensaio.');return $s;
|
||||
}
|
||||
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
|
||||
private function facts(User $u,array $s,object $map):array
|
||||
{
|
||||
$c=app(DirectCosts::class)->conference($map->order_id,$u->id);$lines=$c['snapshot']['lines']??[];$entries=array_column($lines,'entry_id');$rates=array_values(array_filter(array_column($lines,'rate_id'),fn($id)=>$id!==null));
|
||||
return ['mapping'=>(array)$map,'conference'=>$c,'entries'=>DB::table('wo_entries')->where('order_id',$map->order_id)->whereIn('id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'technical_decisions'=>DB::table('wo_decisions')->whereIn('entry_id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rates'=>DB::table('dc_rates')->where($s)->where('order_id',$map->order_id)->whereIn('id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rate_decisions'=>DB::table('dc_decisions')->whereIn('rate_id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
|
||||
}
|
||||
private function eligible(array $facts):array
|
||||
{
|
||||
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$decision=$c['decision'];
|
||||
if(!$c['calculation']||!$c['current']||!$decision||$decision['decision']!=='approved'||$decision['actor_id']===$c['calculation']['actor_id']||$snap['currency']!=='MZN'||$snap['incomplete']||$snap['selected_direct_total_exact']===null)throw new \DomainException('Cálculo actual completo e validado distintamente obrigatório; custo conhecido parcial não é total.');
|
||||
$origins=[];foreach($snap['lines'] as $line)$origins[$line['kind']]=$line['origin'];
|
||||
if($origins!==['time'=>$m['time_origin'],'material'=>$m['material_origin']]&&$origins!==['material'=>$m['material_origin'],'time'=>$m['time_origin']])throw new \DomainException('Perímetro seleccionado mudou; não atribuir outra origem automaticamente.');
|
||||
if(config('industrial_ledger.synthetic_enabled')===true){abort_unless(SourceBook::has('gl_industrial_sources'),503);if(SourceBook::table('gl_industrial_sources')->where('tenant_id',$m['tenant_id'])->where('company_id',$m['company_id'])->whereRaw("EXISTS(SELECT FROM jsonb_each_text(origins::jsonb) AS o WHERE o.value IN (?,?))",array_values($origins))->exists())throw new \DomainException('Origem já composta em lote industrial; não reconhecer novamente.');}
|
||||
try{$amount=BigRational::of($snap['selected_direct_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Custo exacto não representável em cêntimos; não arredondar.',0,$e);}
|
||||
if($amount<=0)throw new \DomainException('Este ensaio exige custo seleccionado positivo.');
|
||||
return ['effect_type'=>'workshop.direct_cost.validated','source_id'=>$m['order_id'],'cost_order_id'=>$m['order_id'],'obligation_id'=>$m['order_id'],'origin_key'=>$this->hash(['scope'=>array_intersect_key($m,array_flip(['tenant_id','company_id','unit_id'])),'order'=>$m['order_id'],'material_origin'=>$m['material_origin'],'time_origin'=>$m['time_origin']]),'recognition_revision'=>$this->hash($facts),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'calculation_id'=>$c['calculation']['id'],'calculation_version'=>$c['calculation']['version'],'document_revision'=>$c['calculation']['fingerprint'],'cost_evidence'=>$facts,'label'=>'Custo directo conhecido do perímetro seleccionado; não custo completo, margem ou valorização de inventário'];
|
||||
}
|
||||
public function source(User $u,int $order):array
|
||||
{
|
||||
$s=$this->scope($u);$m=SourceBook::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??throw new \DomainException('Ordem/perímetro de custo não composto neste âmbito.');return $this->eligible($this->facts($u,$s,$m));
|
||||
}
|
||||
public function period(User $u,array $period):array
|
||||
{
|
||||
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;
|
||||
foreach(SourceBook::table('gl_cost_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$s,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($v);$j=SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$m->order_id)->first();if(!$j||!$this->resolved($s,$j,$source))$pending++;}catch(\DomainException){$pending++;}}
|
||||
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
public function correctionSource(User $u,string $kind,int $journal):array
|
||||
{
|
||||
abort_unless(config('cost_ledger.correction_enabled')===true,403,'Correcção de custo não composta neste ensaio.');$s=$this->scope($u);abort_unless(SourceBook::has('gl_cost_correction_capability'),503,'Schema de correcção de custo não instalado.');
|
||||
if(!in_array($kind,['workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true))throw new \DomainException('Tipo de correcção fora do recorte.');$j=SourceBook::table('gl_journals')->where($s)->where('id',$journal)->first()??throw new \DomainException('Original fora do âmbito.');$original=json_decode($j->snapshot,true,128,JSON_THROW_ON_ERROR);if($original['source']['effect_type']!=='workshop.direct_cost.validated')throw new \DomainException('Só o custo original admite esta correcção.');
|
||||
$base=$this->source($u,(int)$original['source']['cost_order_id']);if($base['origin_key']!==$original['source']['origin_key']||$base['permitted_period_key']!==$original['period']['period_key'])throw new \DomainException('Perímetro/período original incompatível.');if($base['recognition_revision']===$original['source']['recognition_revision'])throw new \DomainException('Não há nova versão validada para substituir o original.');
|
||||
return array_replace($base,['effect_type'=>$kind,'source_id'=>$journal,'amount_minor'=>$kind==='workshop.direct_cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$journal,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j),'original_cost_source'=>$original['source']]);
|
||||
}
|
||||
private function resolved(array $s,object $original,array $source):bool
|
||||
{
|
||||
if(json_decode($original->snapshot,true)['source']['recognition_revision']===$source['recognition_revision'])return true;
|
||||
if(config('cost_ledger.correction_enabled')!==true||!SourceBook::has('gl_cost_correction_capability'))return false;
|
||||
$r=SourceBook::table('gl_correction_results')->where($s)->where('original_journal_id',$original->id)->first();if(!$r)return false;$j=SourceBook::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$j)return false;$current=json_decode($j->snapshot,true)['source'];return $current['effect_type']==='workshop.direct_cost.replacement'&&($current['cost_order_id']??null)===$source['cost_order_id']&&($current['replacement_source_revision']??null)===$source['recognition_revision'];
|
||||
}
|
||||
public function workspace(User $u,?int $order,int $page):array
|
||||
{
|
||||
$s=$this->scope($u);$q=SourceBook::table('gl_cost_sources')->where($s);$p=$q->orderBy('id')->paginate(25,['*'],'page',$page);$chosen=null;$source=null;$pending=null;
|
||||
if($order!==null){$m=SourceBook::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??abort(403,'Ordem fora do âmbito composto.');$chosen=$this->facts($u,$s,$m);try{$source=$this->eligible($chosen);}catch(\DomainException $e){$pending=$e->getMessage();}}
|
||||
$original=$order!==null?SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$order)->first():null;$state=$original?($source&&$this->resolved($s,$original,$source)?'current':'requires_correction'):'unposted';
|
||||
return ['scope'=>$s,'orders'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'order_id'=>$order,'facts'=>$chosen,'source'=>$source,'pending'=>$pending,'original_journal'=>$original,'accounting_state'=>$state];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
/** Preview via rollback, confirm through the original writer, receipt through its original store. */
|
||||
final class FixedAssetJournalController
|
||||
{
|
||||
private function hash(array $a):string{return hash('sha256',Payables::canonical($a));}
|
||||
private function validate(array $v):array{abort_if(array_diff(array_keys($v),['action','data']),422);return Validator::make($v,['action'=>'required|in:effect-prepare,validate,post,correct-prepare,correct-validate,correct-post','data'=>'required|array'])->validate();}
|
||||
private function run(Request $r,string $right,\Closure $fn){$actor=$r->user();app(FixedAssets::class)->scope($actor->id);try{return app(ComposedLedger::class)->run($right,fn($e,$a,$b,$o,$s,$stock,$hostRead)=>$fn($e,DB::connection(),$s,$hostRead));}catch(\DomainException $e){abort(409,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}}
|
||||
private function right(string $action):string{return $action==='effect-prepare'?'prepare':str_replace('-','_',$action);}
|
||||
private function qualified(object $db,array $s,array $d):void{
|
||||
$v=$d['data'];if($d['action']==='effect-prepare'){abort_unless(in_array($v['effect_type']??null,['assets.admitted','assets.depreciation','assets.writeoff'],true),422);return;}
|
||||
if(in_array($d['action'],['validate','post'],true)){$p=$db->table('gl_proposals')->where($s)->where('id',$v['proposal_id']??0)->first();abort_unless($p&&str_starts_with(json_decode($p->snapshot,true)['source']['effect_type'],'assets.'),403);return;}
|
||||
if($d['action']==='correct-prepare')$id=$v['journal_id']??0;else{$g=$db->table('gl_corrections')->where($s)->where('id',$v['correction_id']??0)->first();abort_unless($g,403);$id=$g->original_journal_id;}$j=$db->table('gl_journals')->where($s)->where('id',$id)->first();abort_unless($j&&json_decode($j->snapshot,true)['source']['effect_type']==='assets.depreciation',403);
|
||||
}
|
||||
private function impact(object $db,array $s,array $result):array{
|
||||
$ids=[];if(isset($result['proposal_id']))$ids[]=$result['proposal_id'];if(isset($result['correction_id'])){$g=$db->table('gl_corrections')->where($s)->where('id',$result['correction_id'])->first();$ids=[$g->reverse_proposal_id,$g->replacement_proposal_id];}
|
||||
$snap=[];foreach($ids as$id)$snap[]=json_decode($db->table('gl_proposals')->where($s)->where('id',$id)->value('snapshot'),true);return ['state'=>$result['state'],'snapshots'=>$snap,'journal_count'=>isset($result['journal_id'])?1:(isset($result['reverse_journal_id'])?2:0),'payment_created'=>false];
|
||||
}
|
||||
private function simulate($e,object $db,array $s,array $d,string $key):array{
|
||||
$this->qualified($db,$s,$d);$db->beginTransaction();try{$result=$e->command($d['action'],$d['data'],$key);return$this->impact($db,$s,$result);}finally{$db->rollBack();}
|
||||
}
|
||||
public function preview(Request $r){abort_if(strlen(json_encode($r->all()))>16384,422);$d=$this->validate($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);return$this->run($r,$this->right($d['action']),function($e,$db,$s)use($r,$d,$key){$impact=$this->simulate($e,$db,$s,$d,$key);$c=['actor'=>$r->user()->id,'scope'=>$s,'generation'=>$r->session()->get('company_context.token'),'observed'=>time(),'input'=>$d,'key'=>$key,'impact'=>$impact];return response()->json(['preview'=>Crypt::encryptString(Payables::canonical($c)),'impact'=>$impact])->header('Cache-Control','no-store');});}
|
||||
public function confirm(Request $r){abort_if(array_diff(array_keys($r->all()),['preview']),422);$r->validate(['preview'=>'required|string|max:131072']);try{$c=json_decode(Crypt::decryptString($r->input('preview')),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->validate($c['input']??[]);return$this->run($r,$this->right($d['action']),function($e,$db,$s)use($r,$c,$d){abort_unless($c['actor']===$r->user()->id&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===$r->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$this->qualified($db,$s,$d);$old=$e->receipt($c['key']);if($old){abort_unless(hash_equals($old['request_hash'],$this->hash([$d['action'],$d['data']])),409);return response()->json($old['result']+['replayed'=>true])->header('Cache-Control','no-store');}$impact=$this->simulate($e,$db,$s,$d,$c['key']);abort_unless($this->hash($impact)===$this->hash($c['impact']),409,'Impacto ou origem mudou; confira novamente.');$out=$e->command($d['action'],$d['data'],$c['key']);return response()->json($out)->header('Cache-Control','no-store');});}
|
||||
public function index(Request $r){$v=$r->validate(['asset_key'=>'required|uuid','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($v)),422);$hostActor=$r->user();return$this->run($r,'view',function($e,$db,$s,$hostRead)use($r,$v,$hostActor){$key=$v['asset_key'];$hostRead(fn()=>app(FixedAssets::class)->approved($s,$key,'asset'));$p=$db->table('gl_proposals')->where($s)->where('snapshot->source->asset_key',$key)->orderByDesc('id')->paginate($v['per_page']??25,['*'],'page',$v['page']??1);$ids=$p->pluck('id');$reviews=$db->table('gl_reviews')->where($s)->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$journals=$db->table('gl_journals')->where($s)->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$items=[];foreach($p as$row)$items[]=['id'=>$row->id,'revision'=>$row->revision,'prepared_by'=>$row->prepared_by,'snapshot'=>json_decode($row->snapshot,true),'review'=>$reviews->get($row->id),'journal'=>$journals->get($row->id)];$p->setCollection(collect($items));$groups=$db->table('gl_corrections')->where($s)->where(fn($q)=>$q->whereIn('reverse_proposal_id',$ids)->orWhereIn('replacement_proposal_id',$ids))->orderByDesc('id')->get()->map(function($g)use($db,$s){$g->applied=$db->table('gl_correction_results')->where($s)->where('correction_id',$g->id)->exists();$g->approved=$db->table('gl_reviews')->where($s)->whereIn('proposal_id',[$g->reverse_proposal_id,$g->replacement_proposal_id])->where('decision','approved')->count()===2;return$g;});$balance=$hostRead(fn()=>app(FixedAssets::class)->balance($s,$key,$db));return response()->json(['records'=>$p->toArray(),'balance'=>$balance,'corrections'=>$groups])->header('Cache-Control','no-store');});}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
/** Read-only qualified source adapter. Never creates a journal or debt. */
|
||||
final class FixedAssetLedgerSource
|
||||
{
|
||||
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
|
||||
public function source(User $actor,string $kind,int $id,object $native,array $scope,bool $historical=false):array{
|
||||
$service=app(FixedAssets::class);abort_unless($service->scope($actor->id)===$scope,403);
|
||||
if(in_array($kind,['assets.depreciation.reversal','assets.depreciation.replacement'],true)){
|
||||
$j=$native->table('gl_journals')->where($scope)->where('id',$id)->first()??abort(403);$original=json_decode($j->snapshot,true);abort_unless($original['source']['effect_type']==='assets.depreciation',409,'Só a quota original admite esta correcção.');
|
||||
$base=$this->source($actor,'assets.depreciation',(int)$original['source']['source_id'],$native,$scope);abort_unless($base['recognition_revision']!==$original['source']['recognition_revision'],409,'Não existe nova quota revista.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='assets.depreciation.reversal'?(int)$j->debit_minor:$base['amount_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j)]);
|
||||
}
|
||||
abort_unless(in_array($kind,['assets.admitted','assets.depreciation','assets.writeoff'],true),422);$initial=$service->byId($scope,$id);abort_unless($initial&&$initial['state']==='approved',403);$expected=match($kind){'assets.admitted'=>'asset','assets.depreciation'=>'charge','assets.writeoff'=>'disposal'};abort_unless($initial['kind']===$expected,409);$current=$service->approved($scope,$initial['record_key'],$expected);$asset=$expected==='asset'?$current:$service->approved($scope,$current['data']['input']['asset_key'],'asset');$a=$asset['data']['input'];abort_unless($a['cost']!==null&&$a['residual']!==null&&$a['available_on']!==null,409,'Dados financeiros do activo indeterminados.');
|
||||
$balance=$service->balance($scope,$asset['record_key'],$native);$amount=(int)str_replace('.','',$a['cost']);$date=$a['available_on'];$facts=['asset'=>FixedAssets::financial($asset)];$extra=[];
|
||||
if($expected==='charge'){
|
||||
abort_unless($balance['cost_minor']===$amount&&($historical||!$balance['disposed']),409,'Admissão actual contabilizada obrigatória.');$p=$service->approved($scope,$current['data']['input']['plan_key'],'plan');abort_unless(($historical||$p['data']['status']==='active')&&$p['data']['input']['asset_key']===$asset['record_key'],409,'Plano suspenso ou incompatível.');abort_unless($current['data']['dependencies']['plan']['data']['input']['life_months']===$p['data']['input']['life_months']&&FixedAssets::financial($current['data']['dependencies']['asset'])===FixedAssets::financial($asset),409,'Plano/dados alterados; confira a quota.');
|
||||
$c=$current['data']['calculation'];abort_unless($c['complete'],409);$amount=(int)$c['quota_cents'];$month=$current['data']['input']['month'];$date=(new \DateTimeImmutable($month.'-01'))->modify('last day of this month')->format('Y-m-d');for($n=1;$n<$c['month'];$n++){$previous=(new \DateTimeImmutable(substr($a['available_on'],0,7).'-01'))->modify('+'.($n-1).' months')->format('Y-m');abort_unless(isset($balance['charges'][$previous]),409,'Quota anterior não contabilizada.');}
|
||||
if(!$historical&&!isset($balance['charges'][$month]))abort_unless($balance['accumulated_minor']===(int)$c['accumulated_cents']-$amount&&$balance['accumulated_minor']+$amount<=(int)$c['cost_cents']-(int)$c['residual_cents'],409,'Acumulado não corresponde às quotas anteriores revistas; exige conferência/correcção.');
|
||||
$facts+=['charge_version'=>$current['id'],'plan'=>$current['data']['dependencies']['plan']['id'],'calculation'=>$c];$extra=['charge_key'=>$current['record_key'],'charge_month'=>$month];
|
||||
}
|
||||
if($expected==='disposal'){
|
||||
$pending=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($scope)),array_values($scope)))->whereIn('v.kind',['plan','charge'])->whereRaw("v.data->'input'->>'asset_key'=?",[$asset['record_key']])->where('v.state','prepared')->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->whereColumn('n.record_key','v.record_key')->whereColumn('n.version','>','v.version');})->exists();abort_if($pending,409,'Plano ou quota aguarda decisão; confira antes do abate.');
|
||||
foreach($native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->where('snapshot->source->effect_type','assets.depreciation')->orderBy('id')->get()as$q){$snap=json_decode($q->snapshot,true)['source'];$currentSource=$this->source($actor,'assets.depreciation',(int)$snap['source_id'],$native,$scope);if($snap['recognition_revision']!==$currentSource['recognition_revision']){$pair=$native->table('gl_correction_results')->where($scope)->where('original_journal_id',$q->id)->first();$replacement=$pair?$native->table('gl_journals')->where($scope)->where('id',$pair->replacement_journal_id)->first():null;abort_unless($replacement&&(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)===$currentSource['recognition_revision'],409,'Quota exige correcção aplicada antes do abate.');}}
|
||||
|
||||
abort_unless($balance['cost_minor']===$amount&&!$balance['disposed'],409,'Activo sem custo aplicado ou já abatido.');$frozen=$current['data']['calculation'];abort_unless($this->hash($balance)===$this->hash($frozen),409,'Saldo alterado; confira novo abate.');$date=$current['data']['input']['effect_on'];$last=$native->table('gl_journals')->where($scope)->where('snapshot->source->asset_key',$asset['record_key'])->max('journal_on');abort_unless($last===null||$last<=$date,409,'Abate anterior a movimentos.');$facts+=['disposal_version'=>$current['id'],'balance'=>$balance];$extra=['cost_minor'=>$balance['cost_minor'],'accumulated_minor'=>$balance['accumulated_minor'],'carrying_minor'=>$balance['carrying_minor']];
|
||||
}
|
||||
abort_unless($amount>0,409,'Efeito nulo não cria lançamento.');$observedKeys=array_values(array_unique(array_filter([$asset['record_key'],$current['record_key'],$current['data']['input']['plan_key']??null])));$observed=DB::table('fa_versions')->where($scope)->whereIn('record_key',$observedKeys)->select('record_key')->selectRaw('max(version) as version')->groupBy('record_key')->orderBy('record_key')->get()->map(fn($r)=>(array)$r)->all();$extra['administrative_revision']=$this->hash($observed);$first=DB::table('fa_versions')->where($scope)->where(['record_key'=>$current['record_key'],'state'=>'approved'])->orderBy('version')->value('id');return $extra+['effect_type'=>$kind,'source_id'=>(int)$first,'obligation_id'=>(int)$first,'asset_key'=>$asset['record_key'],'origin_key'=>$this->hash(['scope'=>$scope,'asset'=>$asset['record_key'],'kind'=>$kind,'month'=>$extra['charge_month']??null]),'host_database'=>DB::selectOne('SELECT current_database() d')->d,'permitted_period_key'=>'QA-2026-INTEGRADO','effect_on'=>$date,'recognition_revision'=>$this->hash($facts),'document_revision'=>$this->hash($facts),'amount_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false];
|
||||
}
|
||||
public function period(User $actor,array $period,object $native,array $s):array{
|
||||
app(FixedAssets::class)->scope($actor->id);$h=hash_init('sha256');$facts=0;$pending=0;$rows=DB::table('fa_versions as v')->where(array_combine(array_map(fn($k)=>'v.'.$k,array_keys($s)),array_values($s)))->where('v.state','approved')->whereIn('v.kind',['asset','charge','disposal'])->whereNotExists(function($q){$q->selectRaw('1')->from('fa_versions as n')->whereColumn('n.record_key','v.record_key')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->where('n.state','approved')->whereColumn('n.version','>','v.version');});
|
||||
foreach($rows->orderBy('v.id')->cursor()as$r){$d=json_decode($r->data,true);if($d['status']==='cancelled')continue;$on=match($r->kind){'asset'=>$d['input']['available_on'],'charge'=>$d['input']['month'].'-01','disposal'=>$d['input']['effect_on']};if($on!==null&&($on<$period['starts_on']||$on>$period['ends_on']))continue;$facts++;hash_update($h,$r->data);$kind=match($r->kind){'asset'=>'assets.admitted','charge'=>'assets.depreciation','disposal'=>'assets.writeoff'};
|
||||
$j=$native->table('gl_journals')->where($s)->where('snapshot->source->effect_type',$kind)->where('snapshot->source->asset_key',$r->kind==='asset'?$r->record_key:$d['input']['asset_key']);if($r->kind==='charge')$j->where('snapshot->source->charge_key',$r->record_key);$j=$j->first();
|
||||
if($r->kind==='disposal'&&$j)continue;
|
||||
try{$source=$this->source($actor,$kind,(int)$r->id,$native,$s,true);if(!$j){$pending++;continue;}$snap=json_decode($j->snapshot,true)['source'];if($snap['recognition_revision']!==$source['recognition_revision']){$pair=$native->table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();$replacement=$pair?$native->table('gl_journals')->where($s)->where('id',$pair->replacement_journal_id)->first():null;if(!$replacement||(json_decode($replacement->snapshot,true)['source']['replacement_source_revision']??null)!==$source['recognition_revision'])$pending++;}}
|
||||
catch(\DomainException|\Symfony\Component\HttpKernel\Exception\HttpException $e){$pending++;}
|
||||
}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Modules\Identity\Services\{Audit,Permissions};
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
/** Reviewed administrative assets. Accounting effects exclusively through the common journal. */
|
||||
final class FixedAssets
|
||||
{
|
||||
public const POLICY='SGF-FIXED-ASSETS-INTEGRATED-QA-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}
|
||||
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
|
||||
public function scope(int $actor,string $action='view'):array{
|
||||
abort_unless(config('fixed_assets.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='accounting',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.'.$r,$s['unit_id']);
|
||||
|
||||
$m=DB::table('fa_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('fixed_assets.schema_sha256')),503,'Schema de activos não conferido.');
|
||||
$boot=config('database.connections.receipt_native');if($boot===null){$ready=app(ComposedLedger::class)->run('view',fn()=>true);abort_unless($ready===true,503,'Livro ocupado; tente novamente com a mesma operação.');}$native=DB::connection('receipt_native');\Tempo\Finance\GeneralLedger\LedgerSchema::assertSynthetic($native);$m=$native->table('gl_asset_extension_versions')->where('version',1)->first();abort_unless($m&&hash_equals($m->source_hash,(string)config('fixed_assets.native_schema_sha256'))&&$native->table('gl_asset_books')->where($s)->where('host_database',DB::connection()->getDatabaseName())->exists(),503,'Origem contabilística de activos não conferida.');return$s;
|
||||
}
|
||||
private function identity(int $actor,array $s):string{$u=DB::table('users')->where('id',$actor)->first(['id','name','email']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);}
|
||||
private function lock(int $actor,array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();}
|
||||
private function rows(array $s){return DB::table('fa_versions')->where($s);}
|
||||
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$k)$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return$a;}
|
||||
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
|
||||
public function effective(array $s,string $key):?array{return $this->latest($s,$key,true);}
|
||||
public function byId(array $s,int $id):?array{return $this->row($this->rows($s)->where('id',$id)->first());}
|
||||
public function approved(array $s,string $key,string $kind):array{$a=$this->effective($s,$key);abort_unless($a&&$a['kind']===$kind&&$a['data']['status']!=='cancelled',409,'Origem revista activa obrigatória.');return $a;}
|
||||
private function input(array $v):array{
|
||||
if(is_string($v['reason']??null))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','kind'=>'required|in:asset,plan,charge,disposal','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];$d['record_key']=strtolower($d['record_key']);
|
||||
if($d['action']==='review'){$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;}
|
||||
$money='present|nullable|string|regex:/^[0-9]{1,12}\\.[0-9]{2}$/D';
|
||||
$rules=match($d['kind']){
|
||||
'asset'=>['code'=>'required|string|regex:/^QA-[A-Z0-9-]{2,60}$/D','description'=>'required|string|min:5|max:300','origin_reference'=>'required|string|regex:/^QA-[A-Z0-9-]{2,80}$/D','cost'=>$money,'residual'=>$money,'available_on'=>'present|nullable|date_format:Y-m-d','location'=>'required|string|max:100','custodian'=>'required|string|max:100'],
|
||||
'plan'=>['asset_key'=>'required|uuid','life_months'=>'present|nullable|integer|min:1|max:600','status'=>'required|in:active,suspended'],
|
||||
'charge'=>['asset_key'=>'required|uuid','plan_key'=>'required|uuid','month'=>'required|date_format:Y-m'],
|
||||
'disposal'=>['asset_key'=>'required|uuid','effect_on'=>'required|date_format:Y-m-d']};
|
||||
$d['data']=$this->exact($d['data'],['operation'=>'required|in:establish,amend,cancel,correct_plan','evidence'=>'required|string|min:10|max:1000']+$rules);foreach(['asset_key','plan_key']as$k)if(isset($d['data'][$k]))$d['data'][$k]=strtolower($d['data'][$k]);if(isset($d['data']['life_months']))$d['data']['life_months']=(int)$d['data']['life_months'];return$d;
|
||||
}
|
||||
private function basis(int $actor,array $s,array $d):array{
|
||||
$last=$this->latest($s,$d['record_key']);$data=$d['action']==='prepare'?$d['data']:($last['data']['input']??[]);$deps=[];
|
||||
if($d['kind']!=='asset'){$deps['asset']=$this->approved($s,$data['asset_key']??'','asset');$deps['ledger']=$this->balance($s,$data['asset_key']);}
|
||||
else $deps['ledger']=$this->balance($s,$d['record_key']);
|
||||
if($d['kind']==='charge')$deps['plan']=$this->approved($s,$data['plan_key']??'','plan');
|
||||
return['last'=>$last,'effective'=>$this->effective($s,$d['record_key']),'dependencies'=>$deps,'actor_identity'=>$this->identity($actor,$s)];
|
||||
}
|
||||
public function balance(array $s,string $key,?object $db=null):array{
|
||||
$db??=DB::connection('receipt_native');$q=$db->table('gl_journals')->where($s)->where('snapshot->source->asset_key',$key);$rows=(clone$q)->orderBy('id')->get(['id','snapshot','debit_minor']);$cost=0;$acc=0;$disposed=false;$charges=[];
|
||||
foreach($rows as$j){$v=json_decode($j->snapshot,true);$kind=$v['source']['effect_type'];if($kind==='assets.admitted')$cost+=(int)$j->debit_minor;if(in_array($kind,['assets.depreciation','assets.depreciation.replacement'],true))$acc+=(int)$j->debit_minor;if($kind==='assets.depreciation.reversal')$acc-=(int)$j->debit_minor;if($kind==='assets.writeoff')$disposed=true;if($kind==='assets.depreciation')$charges[$v['source']['charge_month']]=$j->id;}
|
||||
return['cost_minor'=>$cost,'accumulated_minor'=>$acc,'carrying_minor'=>$disposed?0:$cost-$acc,'disposed'=>$disposed,'charges'=>$charges,'journal_ids'=>$rows->pluck('id')->all()];
|
||||
}
|
||||
public static function financial(array $a):array{return array_intersect_key($a['data']['input'],array_flip(['code','origin_reference','cost','residual','available_on']));}
|
||||
private function plan(int $actor,array $s,array $d,array $b):array{
|
||||
$last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$effective['data']??null;
|
||||
if($d['action']==='prepare'){$input=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'fixed_assets.prepare',$s['unit_id']);$input=$last['data']['input'];abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Dependências mudaram. Prepare novamente.');}
|
||||
$op=$input['operation'];if($op==='establish')abort_if($effective,409,'Registo já aprovado. Use correcção.');else abort_unless($old&&$old['status']!=='cancelled',409,'Exige registo activo já aprovado.');
|
||||
$kind=$d['kind'];$dep=$b['dependencies'];$balance=$dep['ledger'];$status=$op==='cancel'?'cancelled':'active';$calculation=null;
|
||||
if($op==='correct_plan')abort_unless($kind==='plan'&&$old,422);
|
||||
if($kind==='asset'){
|
||||
if($old)foreach(['code','origin_reference']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Código/origem imutáveis.');
|
||||
abort_if($this->rows($s)->where('kind','asset')->where('record_key','<>',$d['record_key'])->where(fn($q)=>$q->whereRaw("data->'input'->>'code'=?",[$input['code']])->orWhereRaw("data->'input'->>'origin_reference'=?",[$input['origin_reference']]))->exists(),409,'Código ou origem já usados.');
|
||||
if($balance['journal_ids']){abort_if($op==='cancel',409,'Activo contabilizado não se cancela; use abate.');foreach(['cost','residual','available_on']as$k)abort_unless($old['input'][$k]===$input[$k],409,'Custo/residual/data contabilizados imutáveis.');}
|
||||
if($input['cost']!==null&&$input['residual']!==null)AssetDepreciation::quota($input['cost'],$input['residual'],1,1);
|
||||
if($op==='cancel')abort_if($this->rows($s)->where('kind','<>','asset')->whereRaw("data->'input'->>'asset_key'=?",[$d['record_key']])->exists(),409,'Activo com dependências.');
|
||||
}else{
|
||||
abort_if($balance['disposed'],409,'Activo já abatido.');if($old)abort_unless($input['asset_key']===$old['input']['asset_key'],409,'Activo imutável.');
|
||||
if(in_array($kind,['plan','disposal'],true))abort_if($this->rows($s)->where('kind',$kind)->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'asset_key'=?",[$input['asset_key']])->where('state','approved')->exists(),409,'Reutilize o plano ou abate existente.');
|
||||
if($kind==='plan'){$status=$input['status'];abort_if($op==='cancel',422,'Suspenda explicitamente o plano.');if($old&&$balance['charges']&&$input['life_months']!==$old['input']['life_months']){abort_unless($op==='correct_plan',409,'Quotas aplicadas: exige plano de correcção explícito.');abort_unless(count($balance['charges'])===1,409,'Várias quotas aplicadas exigem tratamento coordenado fora deste recorte.');}}
|
||||
if($kind==='charge'){
|
||||
$a=$dep['asset']['data']['input'];$p=$dep['plan']['data'];abort_unless($p['input']['asset_key']===$input['asset_key']&&$p['status']==='active',409,'Plano suspenso ou de outro activo.');abort_unless($a['available_on']!==null,409,'Entrada em uso desconhecida.');$begin=$input['month'].'-01';$available=substr($a['available_on'],0,7).'-01';$delta=(int)substr($begin,0,4)*12+(int)substr($begin,5,2)-((int)substr($available,0,4)*12+(int)substr($available,5,2))+1;abort_unless($delta>=1,409,'Competência anterior à entrada em uso.');$calculation=AssetDepreciation::quota($a['cost'],$a['residual'],$p['input']['life_months'],$delta);if($old)foreach(['month','plan_key']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Competência/plano imutáveis.');
|
||||
abort_if($this->rows($s)->where('kind','charge')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'asset_key'=? AND data->'input'->>'month'=?",[$input['asset_key'],$input['month']])->exists(),409,'Competência já registada.');abort_if($op==='cancel'&&isset($balance['charges'][$input['month']]),409,'Quota aplicada exige par de correcção.');
|
||||
if($d['action']==='review'&&$d['data']['decision']==='approved'&&$op!=='cancel')abort_unless($calculation['complete'],409,'Custo/residual/vida desconhecidos.');
|
||||
}
|
||||
if($kind==='disposal'){abort_unless($balance['cost_minor']>0,409,'Admissão contabilizada obrigatória.');abort_unless($input['effect_on']>=$dep['asset']['data']['input']['available_on'],409);$calculation=$balance;}
|
||||
}
|
||||
$data=['input'=>$input,'status'=>$status,'dependencies'=>$dep,'calculation'=>$calculation,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;
|
||||
$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'payments'=>0,'journals'=>0,'accounting_requires_separate_decisions'=>true,'legal_tax_treatment'=>null];return['kind'=>$kind,'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array{$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['action']);$b=$this->basis($actor,$s,$d);$out=$this->plan($actor,$s,$d,$b);$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($c)),'impact'=>$out['impact']];});}
|
||||
public function confirm(int $actor,string $token):array{
|
||||
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('fa_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('fa_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('fa_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('fixed_assets.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
|
||||
}
|
||||
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$old=DB::table('fa_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];}
|
||||
public function listing(int $actor,array $input):array{
|
||||
$s=$this->scope($actor);$d=$this->exact($input,['kind'=>'required|in:asset,plan,charge,disposal','record_key'=>'sometimes|uuid','month'=>'sometimes|date_format:Y-m','state'=>'sometimes|in:prepared,approved,refused','search'=>'sometimes|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
|
||||
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$this->rows($s)->where('kind',$d['kind']);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403);}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('fa_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'fa_versions.'.$k);$n->whereColumn('next.version','>','fa_versions.version');});
|
||||
if(isset($d['month']))$q->whereRaw("data->'input'->>'month'=?",[$d['month']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("concat_ws(' ',data->'input'->>'code',data->'input'->>'description',data->'input'->>'asset_key',data->'input'->>'month') ILIKE ? ESCAPE '!'",[$needle]);}
|
||||
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$a=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('fa_versions as e');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('e.'.$k,'fa_versions.'.$k);$n->where('e.state','approved')->whereColumn('e.version','>','fa_versions.version');})->get()->keyBy('record_key');
|
||||
$items=[];foreach($page->items()as$r){$row=$this->row($r);$row['effective']=$this->row($a->get($r->record_key));$items[]=$row;}$page->setCollection(collect($items));return['policy'=>self::POLICY,'kind'=>$d['kind'],'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fixed_assets.review',$s['unit_id'])],'synthetic_only'=>true,'legal_tax_treatment'=>null];});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use Illuminate\Http\Request;
|
||||
final class FixedAssetsController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Processamento de activos ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
|
||||
public function index(Request $r,FixedAssets $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,FixedAssets $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,FixedAssets $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,FixedAssets $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\GeneralLedger\{LedgerSchema,PayableJournal};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
final class GeneralLedger
|
||||
{
|
||||
public static function guard():void
|
||||
{
|
||||
abort_unless((app()->environment('payable-testing')||(app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true||config('industrial_ledger.synthetic_enabled')===true)))&&config('general_ledger.synthetic_enabled')===true,403,'Lançamentos apenas na composição sintética explicitamente preparada.');
|
||||
LedgerSchema::assertSynthetic(DB::connection());
|
||||
foreach(LedgerSchema::TABLES as $table)abort_unless(DB::getSchemaBuilder()->hasTable($table),503,'Núcleo contabilístico não instalado neste ensaio.');
|
||||
}
|
||||
private function scope(User $user):array
|
||||
{
|
||||
self::guard();$context=app(CompanyContext::class);
|
||||
abort_unless(auth()->id()===$user->id&&$context->userId()===$user->id&&$context->unitId(),403);
|
||||
if(request()->hasSession())abort_unless(request()->session()->get('company_context.module_id')==='accounting',409,'Seleccione Contabilidade.');
|
||||
else abort_unless(app()->runningInConsole(),403);
|
||||
$scope=$context->identifiers()+['unit_id'=>$context->unitId()];
|
||||
app(Permissions::class)->require($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']);return $scope;
|
||||
}
|
||||
public function can(User $user,string $action):bool
|
||||
{
|
||||
$scope=$this->scope($user);return app(Permissions::class)->has($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.'.$action,$scope['unit_id']);
|
||||
}
|
||||
public function engine(User $user,?\Closure $checkpoint=null):PayableJournal
|
||||
{
|
||||
$scope=$this->scope($user);$person=DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person');abort_unless(is_string($person)&&$person!=='',403);
|
||||
$authorize=function(string $action,bool $lock=true)use($user,$scope,$person):void{
|
||||
self::guard();
|
||||
if($lock){DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$user->id)->lockForUpdate()->firstOrFail();}
|
||||
abort_unless($this->scope($user)===$scope,409,'Contexto alterado.');
|
||||
app(CompanyContext::class)->selectFor($user->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person')===$person,409,'Pessoa alterada; reabra a conferência.');
|
||||
abort_unless($this->can($user,$action),403);
|
||||
};
|
||||
$sales=config('sales_ledger.synthetic_enabled')===true?app(SalesLedgerSource::class):null;
|
||||
$cost=config('cost_ledger.synthetic_enabled')===true?app(CostLedgerSource::class):null;
|
||||
$industrial=config('industrial_ledger.synthetic_enabled')===true?app(IndustrialLedgerSource::class):null;
|
||||
$providers=array_values(array_filter([$sales,$cost,$industrial]));
|
||||
$extra=$providers?function($kind,$id)use($sales,$cost,$industrial,$user){if(in_array($kind,['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'],true)){if(!$industrial)throw new \DomainException('Origem industrial não composta.');return $kind==='industry.cost.validated'?$industrial->source($user,$id):$industrial->correctionSource($user,$kind,$id);}if(in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true)){if(!$cost)throw new \DomainException('Origem de custo não composta neste host.');return $kind==='workshop.direct_cost.validated'?$cost->source($user,$id):$cost->correctionSource($user,$kind,$id);}if(!$sales)throw new \DomainException('Origem comercial não composta neste host.');return $sales->source($user,$kind,$id);}:null;
|
||||
$period=$providers?function($period)use($providers,$user){if(count($providers)===1)return $providers[0]->period($user,$period);$facts=0;$pending=0;$hash=hash_init('sha256');foreach($providers as $provider){$v=$provider->period($user,$period);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($hash,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($hash)];}:null;
|
||||
return new PayableJournal(DB::connection(),$scope,$user->id,$person,fn(string $a)=>$authorize($a),fn()=>$authorize('view',false),fn(int $id)=>$this->source($user,$scope,$id),$checkpoint,$extra,$period);
|
||||
}
|
||||
public function industrial(User $user,?int $lot=null,int $page=1):array{
|
||||
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$lot,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$origin=app(IndustrialLedgerSource::class)->workspace($user,$lot,$page);$data=$this->listing($user,['page'=>$page]+($lot!==null?['industrial_lot_id'=>$lot]:[]),false);if($lot===null){foreach(['journals','lines','proposals','reviews','events'] as $key)$data[$key]=[];$data['totals']=['debit_minor'=>0,'credit_minor'=>0,'net_minor'=>0];$data['pagination']=['page'=>$page,'last_page'=>1,'total'=>0];foreach($data['evidence_pagination'] as &$p)$p=['page'=>$page,'last_page'=>1,'total'=>0];unset($p);}$closed=DB::table('gl_periods')->where($origin['scope'])->where('period_key','QA-2026-10')->orderByDesc('version')->value('status')==='closed';$cycle=$lot!==null&&config('industrial_ledger.correction_enabled')===true?$this->cycle($user,$page,['industrial_lot_id'=>$lot]):['corrections'=>[],'correction_results'=>[],'pagination'=>[],'permissions'=>[]];foreach($cycle['corrections'] as $group){$v=json_decode($group->replacement_snapshot,true)['source'];$group->current=$group->current&&$origin['source']!==null&&($v['replacement_source_revision']??null)===$origin['source']['recognition_revision'];}return ['industrial'=>$origin,'book'=>$data,'closed'=>$closed,'corrections'=>$cycle['corrections'],'correction_results'=>$cycle['correction_results'],'pagination'=>$cycle['pagination'],'permissions'=>$cycle['permissions']];});
|
||||
}
|
||||
public function costs(User $user,?int $order=null,int $page=1):array
|
||||
{
|
||||
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$order,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$commercial=app(CostLedgerSource::class)->workspace($user,$order,$page);$data=$this->cycle($user,$page,$order!==null?['cost_order_id'=>$order]:[]);foreach($data['corrections'] as $group){$source=json_decode($group->replacement_snapshot,true)['source'];if(isset($source['cost_order_id']))$group->current=$group->current&&$commercial['source']!==null&&($source['replacement_source_revision']??null)===$commercial['source']['recognition_revision'];}return ['cost'=>$commercial]+$data;});
|
||||
}
|
||||
private function source(User $user,array $scope,int $id):array
|
||||
{
|
||||
$obligation=DB::table('pa_obligations')->where($scope)->where('id',$id)->lockForUpdate()->first()??throw new \DomainException('Obrigação indisponível neste âmbito.');
|
||||
$origin=DB::table('pa_sources')->where($scope)->where('id',$obligation->source_id)->lockForUpdate()->first()??throw new \DomainException('Referência factual ausente.');
|
||||
$finance=app(SupplierPayables::class);$document=$finance->document($user,(int)$origin->local_source_id);
|
||||
$position=$finance->engine($user)->positionForSource((int)$origin->local_source_id);
|
||||
if(!$position||(int)$position['id']!==$id||$position['source_conflict'])throw new \DomainException('Fonte financeira mudou ou não está conferida.');
|
||||
$proposal=DB::table('pa_proposals')->where($scope)->where('id',$obligation->proposal_id)->first()??throw new \DomainException('Conferência original ausente.');
|
||||
return ['effect_type'=>'supplier_payable.recognised','origin_key'=>$origin->source_key,'obligation_id'=>$id,'local_source_id'=>(int)$origin->local_source_id,
|
||||
'document_revision'=>$obligation->source_revision,'recognition_revision'=>hash('sha256',Payables::canonical(['obligation'=>(array)$obligation,'recognition_snapshot'=>$proposal->snapshot])),
|
||||
'principal_minor'=>(int)$obligation->principal_minor,'currency'=>$obligation->currency,'due_on'=>$obligation->due_on,'tax_minor'=>$document['tax_minor'],
|
||||
'compatible'=>true,'history_imported'=>$position['history_imported']];
|
||||
}
|
||||
public function report(User $user,string $kind,array $options):array
|
||||
{
|
||||
if(!in_array($kind,['trial-balance','account-ledger'],true))throw new \InvalidArgumentException('Relatório desconhecido.');$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$kind,$options,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$engine=$this->engine($user);
|
||||
return $kind==='trial-balance'?$engine->trialBalance($options):$engine->accountLedger($options);
|
||||
});
|
||||
}
|
||||
public function financialReceipt(User $user,string $domain,string $key):?array
|
||||
{
|
||||
if(!in_array($domain,['recognition','settlement'],true))throw new \InvalidArgumentException('Domínio de comprovativo inválido.');$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$domain,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$engine=app(SupplierPayables::class)->engine($user);return $domain==='recognition'?$engine->receipt($key):$engine->settlements()->receipt($key);});
|
||||
}
|
||||
public function receipt(User $user,string $key):?array
|
||||
{
|
||||
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $this->engine($user)->receipt($key);});
|
||||
}
|
||||
public function listing(User $user,array $options=[],bool $withAccounts=true):array
|
||||
{
|
||||
$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$options,$own,$withAccounts){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$data=$this->engine($user)->listing($options,$withAccounts);
|
||||
$data['permissions']=array_combine(['prepare','validate','post'],array_map(fn($a)=>$this->can($user,$a),['prepare','validate','post']));return $data;
|
||||
});
|
||||
}
|
||||
public function cycle(User $user,int $page=1,array $bookOptions=[]):array
|
||||
{
|
||||
abort_unless(config('general_ledger.cycle_enabled')===true,403);
|
||||
$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$page,$own,$bookOptions){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$data=$this->engine($user)->cycleListing($page,$bookOptions['cost_order_id']??null,$bookOptions['industrial_lot_id']??null);
|
||||
$data['book']=$this->listing($user,['page'=>$page]+$bookOptions);
|
||||
$actions=['prepare','validate','post','correct_prepare','correct_validate','correct_post','period_prepare','period_validate','period_close'];
|
||||
$data['permissions']=array_combine($actions,array_map(fn($a)=>$this->can($user,$a),$actions));return $data;
|
||||
});
|
||||
}
|
||||
public function sales(User $user,?int $invoice=null,int $page=1):array
|
||||
{
|
||||
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$invoice,$page,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$commercial=app(SalesLedgerSource::class)->workspace($user,$invoice,$page);$data=$this->cycle($user,$page,$invoice!==null?['sales_invoice_id'=>$invoice]:[]);$data['commercial']=$commercial;
|
||||
if($invoice!==null){$account=DB::table('gl_accounts')->where($data['commercial']['scope'])->where('code','QA-CLIENTES')->value('id');$data['client_account']=$account?$this->listing($user,['page'=>$page,'sales_invoice_id'=>$invoice,'account_id'=>$account])['totals']:null;}return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Read one factual chain. No command, inferred association or module switch. */
|
||||
public function flow(User $user,int $sourceId):array
|
||||
{
|
||||
$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$sourceId,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$scope=$this->scope($user);
|
||||
$finance=app(SupplierPayables::class);
|
||||
abort_unless($finance->can($user,'view'),403);
|
||||
$page=$finance->listing($user,['source'=>$sourceId,'per_page'=>25]);
|
||||
$document=$page['documents'][0]??abort(403);
|
||||
$position=$page['obligations'][0]??null;
|
||||
$proposal=null;$review=null;$journal=null;
|
||||
if($position){
|
||||
$proposal=DB::table('gl_proposals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->orderByDesc('capture_version')->first();
|
||||
if($proposal)$review=DB::table('gl_reviews')->where($scope)->where('proposal_id',$proposal->id)->first();
|
||||
$journal=DB::table('gl_journals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->first();
|
||||
}
|
||||
return compact('scope','document','position','proposal','review','journal')+['source_id'=>$sourceId,
|
||||
'financial_proposals'=>$page['proposals'],'financial_events'=>$page['events'],
|
||||
'source_conflict'=>$position['source_conflict']??false];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class GeneralLedgerController extends Controller
|
||||
{
|
||||
public function financialReceipt(Request $r,string $domain,string $key,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->receipt($r,$key,$domain);
|
||||
abort_if($r->query()!==[],422);try{$data=$ledger->financialReceipt($r->user(),$domain,$key);}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(403,$e->getMessage());}abort_if($data===null,404,'Comprovativo não encontrado neste domínio, contexto e captura.');return response()->json($data)->header('Cache-Control','no-store');
|
||||
}
|
||||
public function receipt(Request $r,string $key,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->receipt($r,$key);
|
||||
abort_if($r->query()!==[],422);try{$data=$ledger->receipt($r->user(),$key);}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(403,$e->getMessage());}abort_if($data===null,404,'Comprovativo não encontrado neste contexto e nesta captura.');return response()->json($data)->header('Cache-Control','no-store');
|
||||
}
|
||||
public function report(Request $r,string $kind,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->report($r,$kind,app(ComposedLedger::class));
|
||||
$d=$r->validate(['from_on'=>'required|date_format:Y-m-d','to_on'=>'required|date_format:Y-m-d|after_or_equal:from_on','account_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{$data=$ledger->report($r->user(),$kind,$d);}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
|
||||
return response(\Tempo\Finance\GeneralLedger\ReportsWorkspace::render($data,['host'=>'ERP SGF','return_url'=>'/general-ledger','industrial_links'=>config('industrial_ledger.synthetic_enabled')===true,'cost_links'=>config('cost_ledger.synthetic_enabled')===true,'sales_links'=>config('sales_ledger.synthetic_enabled')===true]))->header('Cache-Control','no-store');
|
||||
}
|
||||
private function options(Request $r):array
|
||||
{
|
||||
$d=$r->validate(['industrial_lot_id'=>'sometimes|required|integer|min:1','cost_order_id'=>'sometimes|required|integer|min:1','sales_invoice_id'=>'sometimes|required|integer|min:1','obligation_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','period_key'=>'sometimes|required|string|max:80','account_id'=>'sometimes|nullable|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);if(array_key_exists('account_id',$d)&&$d['account_id']===null)unset($d['account_id']);return $d;
|
||||
}
|
||||
public function index(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->page($r,app(ComposedLedger::class));
|
||||
try{return response()->json($ledger->listing($r->user(),$this->options($r)))->header('Cache-Control','no-store');}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
}
|
||||
public function page(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->page($r,app(ComposedLedger::class));
|
||||
try{return response()->view('accounting.general-ledger',['data'=>$ledger->listing($r->user(),$this->options($r))])->header('Cache-Control','no-store');}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
}
|
||||
public function command(Request $r,string $action,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->command($r,$action,app(ComposedLedger::class));
|
||||
if(!in_array($action,['prepare','validate','post'],true))abort_unless(config('general_ledger.cycle_enabled')===true,403);
|
||||
try{return response()->json($ledger->engine($r->user())->command($action,$r->all(),(string)$r->header('Idempotency-Key')))->header('Cache-Control','no-store');}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
catch(\DomainException $e){abort(409,$e->getMessage());}
|
||||
}
|
||||
public function cycle(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->page($r,app(ComposedLedger::class));
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page'])!==[],422);
|
||||
$page=(int)($d['page']??1);$data=$ledger->cycle($r->user(),$page);
|
||||
return response(\Tempo\Finance\GeneralLedger\CycleWorkspace::render($data,['host'=>'ERP SGF','csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token'),'scope'=>$r->session()->get('company_context'),'financial_url'=>'/supplier-payments','return_url'=>'/general-ledger','page_url'=>'/general-ledger/cycle']))->header('Cache-Control','no-store');
|
||||
}
|
||||
public function cycleData(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->page($r,app(ComposedLedger::class));
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page'])!==[],422);
|
||||
return response()->json($ledger->cycle($r->user(),(int)($d['page']??1)))->header('Cache-Control','no-store');
|
||||
}
|
||||
public function flow(Request $r,GeneralLedger $ledger)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->flow($r);
|
||||
$d=$r->validate(['source'=>'required|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($r->query()),['source'])!==[],422);
|
||||
return response()->view('accounting.payable-flow',['flow'=>$ledger->flow($r->user(),(int)$d['source'])])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class IndustrialLedgerController extends Controller{
|
||||
public function index(Request $r,GeneralLedger $ledger){if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->focused($r,'industrial');$d=$r->validate(['lot_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);try{$data=$ledger->industrial($r->user(),isset($d['lot_id'])?(int)$d['lot_id']:null,(int)($d['page']??1));}catch(\DomainException $e){abort(403,$e->getMessage());}return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.industrial-ledger',['data'=>$data])->header('Cache-Control','no-store');}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Industry\IndustrialCosts;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Brick\Math\BigRational;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
final class IndustrialLedgerSource
|
||||
{
|
||||
private function scope(User $u):array{
|
||||
abort_unless(config('industrial_ledger.synthetic_enabled')===true,403);IndustrialCosts::guard();$c=app(CompanyContext::class);abort_unless(auth()->id()===$u->id&&$c->userId()===$u->id&&$c->unitId(),403);$s=$c->identifiers()+['unit_id'=>$c->unitId()];app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],'industrial_costs.view',$s['unit_id']);foreach(['gl_industrial_sources','ic_lots','ic_results','ic_inputs','ic_decisions'] as $t)abort_unless(str_starts_with($t,'gl_')?SourceBook::has($t):DB::getSchemaBuilder()->hasTable($t),503,'Origem industrial indisponível.');return $s;
|
||||
}
|
||||
private function facts(User $u,object $map):array{return ['mapping'=>(array)$map,'conference'=>app(IndustrialCosts::class)->conference($map->lot_id,$u->id)];}
|
||||
private function eligible(array $s,array $facts):array{
|
||||
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$r=$c['result'];$d=$c['decision'];
|
||||
if(!$r||!$c['product']||($c['product']['id']??null)!==$c['lot']['product_id']||!$c['current']||!$d||$d['decision']!=='approved'||$d['actor_id']===$r['actor_id']||!$snap||$snap['incomplete']||$snap['selected_total_exact']===null||$snap['currency']!=='MZN'||!$snap['reconciliation']['balanced'])throw new \DomainException('Resultado industrial actual completo e validado distintamente obrigatório.');
|
||||
$origins=[];foreach(['material','labour','pool','machine','production'] as $kind)$origins[$kind]=$snap['origins'][$kind]['source_ref'];if(Payables::canonical($origins)!==Payables::canonical(json_decode($m['origins'],true)))throw new \DomainException('Perímetro industrial alterado; nova conferência necessária.');
|
||||
if(count(array_unique($origins))!==5)throw new \DomainException('Origem industrial ambígua.');
|
||||
if(DB::table('ic_inputs')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('lot_id','<>',$m['lot_id'])->whereIn('source_ref',array_values($origins))->exists())throw new \DomainException('Origem reutilizada entre lotes.');
|
||||
if(SourceBook::has('gl_cost_sources')&&SourceBook::table('gl_cost_sources')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where(fn($q)=>$q->whereIn('material_origin',array_values($origins))->orWhereIn('time_origin',array_values($origins)))->exists())throw new \DomainException('Origem já composta como custo directo; não reconhecer novamente.');
|
||||
try{$amount=BigRational::of($snap['selected_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Montante industrial não representável em cêntimos; sem arredondamento.',0,$e);}if($amount<=0)throw new \DomainException('Montante seleccionado deve ser positivo.');
|
||||
return ['effect_type'=>'industry.cost.validated','source_id'=>$m['lot_id'],'industrial_lot_id'=>$m['lot_id'],'obligation_id'=>$m['lot_id'],'origin_key'=>hash('sha256',Payables::canonical(['scope'=>$s,'lot'=>$m['lot_id'],'origins'=>$origins])),'recognition_revision'=>hash('sha256',Payables::canonical($facts)),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'period_start'=>$c['lot']['period_start'],'period_end'=>$c['lot']['period_end'],'result_id'=>$r['id'],'result_version'=>$r['version'],'document_revision'=>$r['fingerprint'],'good_units'=>$snap['good_units'],'unit_cost_exact'=>$snap['unit_cost_exact'],'residual_exact'=>$snap['residual_exact'],'industrial_evidence'=>$facts,'label'=>'Resultado industrial seleccionado de ensaio; residual separado, sem stock ou salários contabilizados'];
|
||||
}
|
||||
public function source(User $u,int $lot):array{$s=$this->scope($u);$m=SourceBook::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??throw new \DomainException('Lote fora do âmbito composto.');return $this->eligible($s,$this->facts($u,$m));}
|
||||
public function correctionSource(User $u,string $kind,int $id):array{
|
||||
abort_unless(config('industrial_ledger.correction_enabled')===true,403);$s=$this->scope($u);abort_unless(SourceBook::has('gl_industrial_correction_capability'),503);if(!in_array($kind,['industry.cost.reversal','industry.cost.replacement'],true))throw new \DomainException('Tipo de correcção industrial inválido.');$j=SourceBook::table('gl_journals')->where($s)->where('id',$id)->first()??throw new \DomainException('Original fora do âmbito.');$old=json_decode($j->snapshot,true);if($old['source']['effect_type']!=='industry.cost.validated')throw new \DomainException('Apenas o original industrial admite esta correcção.');$base=$this->source($u,(int)$old['source']['industrial_lot_id']);if($base['origin_key']!==$old['source']['origin_key']||$base['permitted_period_key']!==$old['period']['period_key']||$base['recognition_revision']===$old['source']['recognition_revision'])throw new \DomainException('Origem/perímetro/período sem sucessora compatível.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='industry.cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>hash('sha256',Payables::canonical(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind])),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>hash('sha256',Payables::canonical((array)$j)),'original_industrial_source'=>$old['source']]);
|
||||
}
|
||||
private function resolved(array $s,object $j,array $source):bool{
|
||||
if($j->source_revision===$source['recognition_revision'])return true;if(config('industrial_ledger.correction_enabled')!==true||!SourceBook::has('gl_industrial_correction_capability'))return false;$r=SourceBook::table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();if(!$r)return false;$replacement=SourceBook::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$replacement)return false;$v=json_decode($replacement->snapshot,true)['source'];return $v['effect_type']==='industry.cost.replacement'&&($v['industrial_lot_id']??null)===$source['industrial_lot_id']&&($v['replacement_source_revision']??null)===$source['recognition_revision'];
|
||||
}
|
||||
public function period(User $u,array $period):array{
|
||||
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;foreach(SourceBook::table('gl_industrial_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($s,$v);$j=SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$m->lot_id)->first();if(!$j||!$this->resolved($s,$j,$source)||$source['period_start']<$period['starts_on']||$source['period_end']>$period['ends_on'])$pending++;}catch(\DomainException){$pending++;}}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
public function workspace(User $u,?int $lot,int $page):array{
|
||||
$s=$this->scope($u);$p=SourceBook::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'page',$page);$facts=null;$source=null;$pending=null;$journal=null;if($lot!==null){$m=SourceBook::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??abort(403,'Lote fora do âmbito.');$facts=$this->facts($u,$m);try{$source=$this->eligible($s,$facts);}catch(\DomainException $e){$pending=$e->getMessage();}$journal=SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$lot)->first();}return ['scope'=>$s,'lots'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'lot_id'=>$lot,'facts'=>$facts,'source'=>$source,'pending'=>$pending,'journal'=>$journal,'accounting_state'=>$journal?($source&&$this->resolved($s,$journal,$source)?'current':'requires_future_correction'):'unposted'];
|
||||
}
|
||||
public function cards(User $u,int $page):array{
|
||||
$s=$this->scope($u);$p=SourceBook::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'industrial_page',$page);$ids=array_column($p->items(),'lot_id');$results=DB::table('ic_results as r')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->whereIn('r.lot_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('ic_results as n')->whereColumn('n.previous_id','r.id'))->get(['r.id','r.lot_id','r.version'])->keyBy('lot_id');$decisions=DB::table('ic_decisions')->whereIn('result_id',$results->pluck('id')->all())->get(['result_id','decision'])->keyBy('result_id');$posted=SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->whereIn('snapshot->source->industrial_lot_id',$ids)->select('id')->selectRaw("snapshot->'source'->>'industrial_lot_id' AS lot_id")->get()->keyBy('lot_id');$rows=[];foreach($p->items() as $m){$r=$results->get($m->lot_id);$rows[]=['lot_id'=>$m->lot_id,'period_key'=>$m->period_key,'result_id'=>$r?->id,'result_version'=>$r?->version,'recorded_decision'=>$r?$decisions->get($r->id)?->decision:null,'original_journal_id'=>$posted->get($m->lot_id)?->id,'current_dependency_conference_required'=>true];}return ['allowed'=>true,'rows'=>$rows,'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total(),'per_page'=>25]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use Brick\Math\BigInteger;
|
||||
|
||||
/** Adds original report values only; never reconstructs journal balances. */
|
||||
final class InternalStatementTotals
|
||||
{
|
||||
public const CATEGORIES=['asset','liability','income','expense','unclassified'];
|
||||
private array $groups=[];
|
||||
public function add(array $row,string $category):void
|
||||
{
|
||||
if(!in_array($category,self::CATEGORIES,true))throw new \InvalidArgumentException('Rubrica inválida.');$currency=$row['currency'];foreach(self::CATEGORIES as$c)$this->groups[$currency][$c]??=['accounts'=>0,'opening_net_minor'=>'0','debit_minor'=>'0','credit_minor'=>'0','closing_net_minor'=>'0','inverted_accounts'=>0];$g=&$this->groups[$currency][$category];$g['accounts']++;
|
||||
foreach(['opening_net_minor','debit_minor','credit_minor','closing_net_minor']as$key)$g[$key]=(string)BigInteger::of($g[$key])->plus($row[$key]);
|
||||
if(($category==='asset'&&BigInteger::of($row['closing_net_minor'])->isNegative())||($category==='liability'&&BigInteger::of($row['closing_net_minor'])->isPositive()))$g['inverted_accounts']++;
|
||||
}
|
||||
public function result():array
|
||||
{
|
||||
$out=[];foreach($this->groups as$currency=>$groups){foreach($groups as$category=>&$g){$net=BigInteger::of($g['debit_minor'])->minus($g['credit_minor']);$g['period_net_minor']=(string)$net;$g['presentation_minor']=$g['accounts']===0?null:(string)(match($category){'liability'=>BigInteger::of($g['closing_net_minor'])->negated(),'income'=>$net->negated(),'expense'=>$net,default=>BigInteger::of($g['closing_net_minor'])});}unset($g);$known=BigInteger::of($groups['income']['presentation_minor']??'0')->minus($groups['expense']['presentation_minor']??'0');$out[$currency]=['groups'=>$groups,'known_result_subtotal_minor'=>(string)$known,'complete_result_minor'=>$groups['unclassified']['accounts']===0?(string)$known:null,'equity_minor'=>null,'complete_balance_sheet'=>false,'unclassified_accounts'=>$groups['unclassified']['accounts']];}return$out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Services\RequisitionWorkflow;
|
||||
|
||||
final class InternalStatementsController
|
||||
{
|
||||
public const POLICY='SGF-INTERNAL-STATEMENTS-QA-v1';
|
||||
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
|
||||
private function exact(array $v,array $rules):array{abort_if(array_diff(array_keys($v),array_keys($rules)),422);return Validator::make($v,$rules)->validate();}
|
||||
private function run(Request $r,string $action,\Closure $fn):mixed
|
||||
{
|
||||
abort_unless(config('internal_statements.enabled')===true,404);$host=DB::connection();$hostId=(int)$r->user()->id;$identity=$host->table('users')->where('id',$hostId)->first(['id','email','name']);$identityHash=$this->hash((array)$identity);$generation=$this->hash($r->session()->get('company_context',[]));
|
||||
try{return app(ComposedLedger::class)->run('view',function($engine,$actor,$binding,$origin,$scope)use($action,$fn,$host,$hostId,$identityHash,$generation){$meta=DB::table('fs_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&DB::connection()->getDatabaseName()==='compras_receipts_test_'.$meta->composition&&hash_equals($meta->source_hash,(string)config('internal_statements.schema_sha256')),503,'Schema de demonstrações não conferido.');if($action!=='view')abort_unless($hostId===($action==='prepare'?15:16)&&$host->table('permission_grants')->where($origin['sgf_host_scope']+['user_id'=>$hostId,'permission'=>'general_ledger.statements.'.$action])->lockForUpdate()->first(),403);$person=DB::table('pa_access')->where($scope+['user_id'=>$actor->id,'action'=>'general_ledger.view'])->lockForUpdate()->value('person');abort_unless(is_string($person)&&$person!=='',403);$freshIdentity=$host->table('users')->where('id',$hostId)->lockForUpdate()->first(['id','email','name']);abort_unless($freshIdentity,403);$identityHash=$this->hash((array)$freshIdentity);return$fn($engine,$actor,$scope,$hostId,$person,$identityHash,$generation,$this->hash([$origin['binding_revision'],$origin['sgf_host_scope']]),$host,$origin);});}catch(\InvalidArgumentException$e){abort(422,$e->getMessage());}catch(\DomainException$e){abort(403,$e->getMessage());}
|
||||
}
|
||||
private function rows(array $s){return DB::table('fs_versions')->where($s);}
|
||||
private function row(?object $r):?array{if(!$r)return null;$v=(array)$r;$v['data']=json_decode($v['data'],true,64,JSON_THROW_ON_ERROR);return$v;}
|
||||
private function latest(array $s,string $kind,bool $approved=false):?array{$q=$this->rows($s)->where('kind',$kind);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
|
||||
private function plan(array $s):array{$v=DB::table('gl_accounts')->where($s)->selectRaw('count(*) accounts,coalesce(max(id),0) last_id')->first();return(array)$v;}
|
||||
private function mapping(array $s,array $entries):array
|
||||
{
|
||||
$ids=array_column($entries,'account_id');$accounts=DB::table('gl_accounts')->where($s)->whereIn('id',$ids)->get(['id','code','label','currency'])->keyBy('id');abort_unless(count($ids)===$accounts->count(),403,'Conta fora do livro autorizado.');$out=[];foreach($entries as$e){$a=(array)$accounts[$e['account_id']];$out[]=['account_id'=>$e['account_id'],'category'=>$e['category'],'account'=>$a,'account_hash'=>$this->hash($a)];}usort($out,fn($a,$b)=>$a['account_id']<=>$b['account_id']);return$out;
|
||||
}
|
||||
private function validateMapping(array $s,array $mapping):void
|
||||
{
|
||||
$fresh=$this->mapping($s,array_map(fn($e)=>array_intersect_key($e,array_flip(['account_id','category'])),$mapping));abort_unless($this->hash($fresh)===$this->hash($mapping),409,'Conta ou correspondência alterada; exige revisão.');
|
||||
}
|
||||
private function input(array $v):array
|
||||
{
|
||||
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['kind'=>'required|in:mapping,result','action'=>'required|in:prepare,review','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];
|
||||
if($d['action']==='review')$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);elseif($d['kind']==='mapping'){$d['data']=$this->exact($d['data'],['entries'=>'required|array|min:1|max:100','evidence'=>'required|string|min:10|max:2000']);$seen=[];foreach($d['data']['entries']as&$e){$e=$this->exact($e,['account_id'=>'required|integer|min:1','category'=>'required|in:asset,liability,income,expense,unclassified']);$e['account_id']=(int)$e['account_id'];abort_if(isset($seen[$e['account_id']]),422,'Conta repetida.');$seen[$e['account_id']]=true;}unset($e);}else$d['data']=$this->exact($d['data'],['mapping_id'=>'required|integer|min:1','from_on'=>'required|date_format:Y-m-d','to_on'=>'required|date_format:Y-m-d|after_or_equal:from_on','per_page'=>'required|integer|in:25,100','evidence'=>'required|string|min:10|max:2000']);return$d;
|
||||
}
|
||||
private function basis(array $s,string $kind,string $identity):array{return ['last'=>$this->latest($s,$kind),'mapping'=>$this->latest($s,'mapping',true),'mapping_latest'=>$this->latest($s,'mapping'),'plan'=>$this->plan($s),'identity'=>$identity];}
|
||||
private function capture($engine,array $s,array $mapping,array $d,?string $persistKey=null):array
|
||||
{
|
||||
$this->validateMapping($s,$mapping['data']['entries']);$map=collect($mapping['data']['entries'])->keyBy('account_id');$totals=new InternalStatementTotals();$digest=hash_init('sha256');$first=null;$query=array_intersect_key($d,array_flip(['from_on','to_on','per_page']));$plan=$this->plan($s);$count=0;
|
||||
for($page=1;;$page++){$report=$engine->trialBalance($query+['page'=>$page]);if(!$first)$first=$report;else abort_unless($this->hash($first['basis'])===$this->hash($report['basis'])&&$this->hash($first['totals'])===$this->hash($report['totals']),409,'Livro mudou entre páginas.');foreach($report['rows']as$row){$entry=$map->get($row['account_id']);$category=$entry['category']??'unclassified';$totals->add($row,$category);$line=$row+['category'=>$category];foreach(['opening_net_minor','debit_minor','credit_minor','closing_net_minor']as$key)$line[$key]=(string)$line[$key];hash_update($digest,Payables::canonical($line)."\n");if($persistKey)DB::table('fs_result_lines')->insert($s+['snapshot_key'=>$persistKey]+$line);$count++;}if($page>=$report['pagination']['last_page'])break;}
|
||||
$current=DB::table('gl_journals')->where($s)->where('journal_on','<=',$d['to_on'])->selectRaw('count(*) journals,coalesce(max(id),0) last_journal_id')->first();abort_unless((int)$current->journals===$first['basis']['journals']&&(int)$current->last_journal_id===$first['basis']['last_journal_id']&&$this->hash($plan)===$this->hash($this->plan($s))&&$count===$first['totals']['accounts'],409,'Fonte mudou durante a preparação.');
|
||||
$summary=['query'=>['from_on'=>$d['from_on'],'to_on'=>$d['to_on']],'mapping_id'=>$mapping['id'],'mapping_version'=>$mapping['version'],'basis'=>$first['basis'],'original_totals'=>array_map('strval',$first['totals']),'currencies'=>$totals->result(),'accounts'=>$count,'plan'=>$plan,'rows_sha256'=>hash_final($digest),'synthetic_only'=>true,'legal_model'=>false];return['summary'=>$summary,'source_basis'=>$this->hash($summary)];
|
||||
}
|
||||
private function csv(array $s,string $key,array $data):array
|
||||
{
|
||||
$stream=fopen('php://temp/maxmemory:1048576','w+');$h=hash_init('sha256');$write=function(array $row)use($stream,$h){$f=fopen('php://memory','w+');fputcsv($f,$row,';','"','',"\r\n");rewind($f);$bytes=stream_get_contents($f);fclose($f);fwrite($stream,$bytes);hash_update($h,$bytes);};
|
||||
$write(['DEMONSTRACOES INTERNAS PARCIAIS QA — SEM MODELO LEGAL']);$write(['Snapshot',$key,'Versao de resultado',$data['result_version'],'Observacao',$data['frozen_at']]);$write(['Datas',$data['summary']['query']['from_on'],$data['summary']['query']['to_on'],'Correspondencias',$data['summary']['mapping_version']]);$write(['Capital proprio','INDETERMINADO','Nao presumir zero ou completar equacao patrimonial']);$write(['Rubricas/pendencias',Payables::canonical($data['summary']['currencies'])]);$write(['Fundamento',"'".$data['evidence']]);$write(['Conta','Descricao','Moeda','Rubrica','Abertura liquida minor','Debito minor','Credito minor','Fecho liquido minor']);
|
||||
DB::table('fs_result_lines')->where($s+['snapshot_key'=>$key])->orderBy('code')->orderBy('account_id')->chunk(100,function($rows)use($write){foreach($rows as$r)$write(["'".$r->code,"'".$r->label,$r->currency,$r->category,$r->opening_net_minor,$r->debit_minor,$r->credit_minor,$r->closing_net_minor]);});rewind($stream);return['stream'=>$stream,'sha256'=>hash_final($h)];
|
||||
}
|
||||
private function execute($engine,$actor,array $s,int $hostId,string $person,string $identity,array $d,array $b,string $key,string $stamp,$host,array $origin):array
|
||||
{
|
||||
$last=$b['last'];abort_unless(($last['version']??0)===$d['version'],409,'Versão alterada; confira novamente.');$version=$d['version']+1;$prepared=$hostId;$native=$actor->id;$preparedPerson=$person;$preparedIdentity=$identity;
|
||||
if($d['action']==='prepare'){
|
||||
if($d['kind']==='mapping'){$entries=$this->mapping($s,$d['data']['entries']);$data=['entries'=>$entries,'evidence'=>$d['data']['evidence']];$source=$this->hash(['entries'=>$entries,'plan'=>$b['plan']]);}
|
||||
else{$m=$b['mapping'];abort_unless($m&&$m['id']===(int)$d['data']['mapping_id'],409,'Escolha explicitamente as correspondências revistas actuais.');$captured=$this->capture($engine,$s,$m,$d['data'],$key);$data=['snapshot_key'=>$key,'result_version'=>$version,'frozen_at'=>$stamp,'evidence'=>$d['data']['evidence'],'summary'=>$captured['summary']];$csv=$this->csv($s,$key,$data);$data['csv_sha256']=$csv['sha256'];fclose($csv['stream']);$source=$captured['source_basis'];DB::table('fs_results')->insert($s+['snapshot_key'=>$key,'data'=>Payables::canonical($data),'csv_sha256'=>$data['csv_sha256'],'source_basis'=>$source,'created_at'=>$stamp]);}
|
||||
$state='prepared';
|
||||
}else{
|
||||
abort_unless($last&&$last['state']==='prepared'&&$last['prepared_host_id']!==$hostId&&$last['prepared_native_id']!==$actor->id&&$last['prepared_person']!==$person,403,'Preparação actual e pessoa distinta obrigatórias.');$prepared=$last['prepared_host_id'];$native=$last['prepared_native_id'];$preparedPerson=$last['prepared_person'];$preparedIdentity=$last['preparer_identity'];$u=$host->table('users')->where('id',$prepared)->lockForUpdate()->first(['id','email','name']);abort_unless($u&&hash_equals($preparedIdentity,$this->hash((array)$u))&&$host->table('company_memberships')->where(['tenant_id'=>$origin['sgf_host_scope']['tenant_id'],'company_id'=>$origin['sgf_host_scope']['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),409,'Identidade/vínculo do preparador mudou.');abort_unless($host->table('permission_grants')->where($origin['sgf_host_scope']+['user_id'=>$prepared,'permission'=>'general_ledger.statements.prepare'])->lockForUpdate()->first(),403);$freshPerson=DB::table('pa_access')->where($s+['user_id'=>$native,'action'=>'general_ledger.view'])->lockForUpdate()->value('person');abort_unless($freshPerson===$preparedPerson,409,'Pessoa do preparador mudou.');$data=$last['data'];
|
||||
if($d['kind']==='mapping'){$this->validateMapping($s,$data['entries']);$source=$this->hash(['entries'=>$data['entries'],'plan'=>$b['plan']]);}else{$m=$b['mapping'];abort_unless($m&&$m['id']===$data['summary']['mapping_id'],409,'Correspondências mudaram desde a preparação.');$source=$this->capture($engine,$s,$m,$data['summary']['query']+['per_page'=>100])['source_basis'];$csv=$this->csv($s,$data['snapshot_key'],$data);abort_unless(hash_equals($data['csv_sha256'],$csv['sha256']),409,'Snapshot congelado incompatível.');fclose($csv['stream']);}abort_unless(hash_equals($last['source_basis'],$source),409,'Contas, livro ou dependências mudaram desde a preparação.');$state=$d['data']['decision'];
|
||||
}
|
||||
$impact=['before'=>['version'=>$d['version'],'state'=>$last['state']??null],'after'=>['version'=>$version,'state'=>$state],'kind'=>$d['kind'],'data'=>$data,'new_journals'=>0,'accounts_changed'=>false,'legal_model'=>false];$id=DB::table('fs_versions')->insertGetId($s+['kind'=>$d['kind'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_host_id'=>$prepared,'prepared_native_id'=>$native,'prepared_person'=>$preparedPerson,'preparer_identity'=>$preparedIdentity,'host_actor_id'=>$hostId,'actor_id'=>$actor->id,'data'=>Payables::canonical($data),'source_basis'=>$source,'reason'=>$d['reason'],'created_at'=>now()->utc()]);return['id'=>$id,'kind'=>$d['kind'],'version'=>$version,'state'=>$state,'impact'=>$impact];
|
||||
}
|
||||
public function preview(Request $r)
|
||||
{
|
||||
$d=$this->input($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$d['action'],function($e,$a,$s,$h,$p,$identity,$g,$binding,$host,$origin)use($d,$key){$b=$this->basis($s,$d['kind'],$identity);$stamp=now()->utc()->toIso8601String();$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($e,$a,$s,$h,$p,$identity,$d,$b,$key,$stamp,$host,$origin);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['input'=>$d,'actor'=>$h,'person'=>$p,'generation'=>$g,'binding'=>$binding,'key'=>$key,'stamp'=>$stamp,'observed'=>time(),'basis'=>$this->hash($b),'impact'=>$out['impact']];return response()->json(['preview'=>Crypt::encryptString(Payables::canonical($cut)),'impact'=>$out['impact']])->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function confirm(Request $r)
|
||||
{
|
||||
$v=$this->exact($r->all(),['preview'=>'required|string|max:131072']);try{$c=json_decode(Crypt::decryptString($v['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);return$this->run($r,$d['action'],function($e,$a,$s,$h,$p,$identity,$g,$binding,$host,$origin)use($c,$d){abort_unless($c['actor']===$h&&$c['person']===$p&&$c['generation']===$g&&$c['binding']===$binding&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$command=$s+['host_actor_id'=>$h,'command_key'=>$c['key']];$old=DB::table('fs_commands')->where($command)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d))&&hash_equals($old->binding_hash,$binding),409,'Chave usada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true])->header('Cache-Control','no-store');}$b=$this->basis($s,$d['kind'],$identity);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Versão, identidade ou correspondências mudaram.');$out=$this->execute($e,$a,$s,$h,$p,$identity,$d,$b,$c['key'],$c['stamp'],$host,$origin);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');app(RequisitionWorkflow::class)->audit($a,'sgf.statements.command',null,['version'=>$d['version']],['host_actor_id'=>$h,'operation_key'=>$c['key'],'reason'=>$d['reason'],'after'=>$out]);DB::table('fs_commands')->insert($command+['actor_id'=>$a->id,'request_hash'=>$this->hash($d),'binding_hash'=>$binding,'input'=>Payables::canonical($d),'result'=>Payables::canonical($out),'created_at'=>now()->utc()]);return response()->json($out+['replayed'=>false],201)->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function receipt(Request $r,string $key)
|
||||
{
|
||||
abort_if($r->query()!==[],422);$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,'view',function($e,$a,$s,$h,$p,$identity,$g,$binding)use($key){$old=DB::table('fs_commands')->where($s+['host_actor_id'=>$h,'command_key'=>$key])->first();abort_unless($old,404);abort_unless(hash_equals($old->binding_hash,$binding),409);return response()->json(['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'operation_executed'=>false,'historical_receipt_only'=>true])->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function index(Request $r)
|
||||
{
|
||||
$d=$this->exact($r->query(),['view'=>'sometimes|in:history,accounts,result','kind'=>'sometimes|in:mapping,result','version_id'=>'required_if:view,result|prohibited_unless:view,result|integer|min:1','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);return$this->run($r,'view',function($e,$a,$s,$h,$p,$identity,$g,$binding,$host,$origin)use($d){$view=$d['view']??'history';$size=(int)($d['per_page']??25);$page=(int)($d['page']??1);$lastMapping=$this->latest($s,'mapping');$mapping=$this->latest($s,'mapping',true);$lastResult=$this->latest($s,'result');$approvedResult=$this->latest($s,'result',true);$snapshot=null;$matches=null;
|
||||
if($view==='accounts'){$q=DB::table('gl_accounts')->where($s);if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($q)=>$q->whereRaw("code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("label ILIKE ? ESCAPE '!'",[$needle]));}$rows=$q->orderBy('code')->orderBy('id')->paginate($size,['id','code','label','currency'],'page',$page);}
|
||||
elseif($view==='result'){$v=$this->row($this->rows($s)->where(['id'=>$d['version_id'],'kind'=>'result','state'=>'approved'])->first());abort_unless($v,403,'Resultado revisto fora do contexto.');$snapshot=$v['data'];$q=DB::table('fs_result_lines')->where($s+['snapshot_key'=>$snapshot['snapshot_key']]);$rows=$q->orderBy('code')->orderBy('account_id')->paginate($size,['*'],'page',$page);$current=$mapping&&$mapping['id']===$snapshot['summary']['mapping_id'];if($current){try{$now=$this->capture($e,$s,$mapping,$snapshot['summary']['query']+['per_page'=>100]);$current=hash_equals($v['source_basis'],$now['source_basis']);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$ex){if($ex->getStatusCode()!==409)throw$ex;$current=false;}}$matches=$current;}
|
||||
else{$q=$this->rows($s);if(isset($d['kind']))$q->where('kind',$d['kind']);$rows=$q->orderByDesc('id')->paginate($size,['*'],'page',$page);$rows->setCollection($rows->getCollection()->map(fn($v)=>$this->row($v)));}
|
||||
$can=fn($action,$expected)=>$h===$expected&&$host->table('permission_grants')->where($origin['sgf_host_scope']+['user_id'=>$h,'permission'=>'general_ledger.statements.'.$action])->exists();return response()->json(['policy'=>self::POLICY,'view'=>$view,'records'=>$rows,'mapping_latest'=>$lastMapping,'mapping_approved'=>$mapping,'result_latest'=>$lastResult,'result_approved'=>$approvedResult,'snapshot'=>$snapshot,'current_source_matches'=>$matches,'capabilities'=>['prepare'=>$can('prepare',15),'review'=>$can('review',16)],'notice'=>'Demonstrações internas parciais QA; classificação pendente visível, capital não presumido. Não é modelo legal.'])->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function export(Request $r,int $id)
|
||||
{
|
||||
abort_if($r->query()!==[],422);return$this->run($r,'view',function($e,$a,$s)use($id){$v=$this->row($this->rows($s)->where(['id'=>$id,'kind'=>'result','state'=>'approved'])->first());abort_unless($v,403,'Resultado não revisto ou fora do âmbito.');$data=$v['data'];$saved=DB::table('fs_results')->where($s+['snapshot_key'=>$data['snapshot_key']])->first();abort_unless($saved&&hash_equals($saved->csv_sha256,$data['csv_sha256']),409);$csv=$this->csv($s,$data['snapshot_key'],$data);if(!hash_equals($data['csv_sha256'],$csv['sha256'])){fclose($csv['stream']);abort(409,'Artefacto alterado.');}$stream=$csv['stream'];return response()->stream(function()use($stream){try{fpassthru($stream);}finally{fclose($stream);}},200,['Content-Type'=>'text/csv; charset=UTF-8','Content-Disposition'=>'attachment; filename="sgf-demonstracoes-internas-QA.csv"','X-Content-SHA256'=>$csv['sha256'],'Cache-Control'=>'no-store, private']);});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Bounded cross-source navigation. Recorded evidence never substitutes a current owner conference. */
|
||||
final class LedgerComposition
|
||||
{
|
||||
private function requireTables(array $tables):void{foreach($tables as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Fonte composta indisponível; não interpretar como zero.');}
|
||||
private function postings(array $scope,string $kind,string $key,array $ids):array
|
||||
{
|
||||
if(!$ids)return [];$rows=DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',$kind)->whereIn('snapshot->source->'.$key,$ids)->select('id')->selectRaw("snapshot->'source'->>'$key' AS source_id")->get();$out=[];foreach($rows as $row)$out[(int)$row->source_id]=$row->id;return $out;
|
||||
}
|
||||
private function page($pager,array $rows):array{return ['allowed'=>true,'rows'=>$rows,'pagination'=>['page'=>$pager->currentPage(),'total'=>$pager->total(),'last_page'=>$pager->lastPage(),'per_page'=>25]];}
|
||||
public function workspace(User $user,array $options=[]):array
|
||||
{
|
||||
abort_unless(config('general_ledger.composition_enabled')===true&&config('sales_ledger.synthetic_enabled')===true&&config('cost_ledger.synthetic_enabled')===true,403,'Composição disponível apenas no ensaio explicitamente preparado.');$own=DB::transactionLevel()===0;
|
||||
return DB::transaction(function()use($user,$options,$own){
|
||||
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$ledger=app(GeneralLedger::class);$book=$ledger->listing($user,['page'=>(int)($options['journals_page']??1),'period_key'=>$options['period_key']??'QA-2026-10'],false);$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$permissions=app(Permissions::class);$can=fn($right)=>$permissions->has($user->id,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);$panels=['sales'=>['allowed'=>false],'costs'=>['allowed'=>false],'suppliers'=>['allowed'=>false]];
|
||||
if($can('service_invoices.view')&&$can('service_receivables.view')){
|
||||
$this->requireTables(['si_invoices','sr_records','sr_effects','gl_sales_capability']);$p=\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderBy('id')->paginate(25,['id','series_code','number','issued_at'],'invoices_page',(int)($options['invoices_page']??1));$ids=array_column($p->items(),'id');$posted=$this->postings($scope,'sales.invoice','invoice_id',$ids);$records=DB::table('sr_records')->where($scope)->whereIn('invoice_id',$ids)->where('kind','obligation')->get(['id','invoice_id'])->keyBy('invoice_id');$opened=DB::table('sr_effects')->whereIn('record_id',$records->pluck('id')->all())->where('kind','open')->pluck('record_id')->all();$rows=[];foreach($p->items() as $i){$ob=$records->get($i->id);$rows[]=['invoice_id'=>$i->id,'number'=>$i->series_code.'/'.$i->number,'issued_at'=>$i->issued_at,'confirmed_opening'=>$ob&&in_array($ob->id,$opened,true),'original_journal_id'=>$posted[$i->id]??null];}$panels['sales']=$this->page($p,$rows);
|
||||
}
|
||||
if($can('workshop.view')&&$can('direct_costs.view')){
|
||||
$this->requireTables(['gl_cost_sources','wo_orders','dc_calculations','dc_decisions']);$p=DB::table('gl_cost_sources')->where($scope)->orderBy('order_id')->paginate(25,['order_id','period_key'],'costs_page',(int)($options['costs_page']??1));$ids=array_column($p->items(),'order_id');$calculations=DB::table('dc_calculations as c')->where(array_combine(array_map(fn($k)=>'c.'.$k,array_keys($scope)),array_values($scope)))->whereIn('c.order_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('dc_calculations as n')->whereColumn('n.order_id','c.order_id')->whereColumn('n.version','>','c.version'))->get(['c.id','c.order_id','c.version'])->keyBy('order_id');$decisions=DB::table('dc_decisions')->whereIn('calculation_id',$calculations->pluck('id')->all())->get(['calculation_id','decision'])->keyBy('calculation_id');$posted=$this->postings($scope,'workshop.direct_cost.validated','cost_order_id',$ids);$results=DB::table('gl_correction_results')->where($scope)->whereIn('original_journal_id',array_values($posted))->get(['id','original_journal_id'])->keyBy('original_journal_id');$rows=[];foreach($p->items() as $m){$calc=$calculations->get($m->order_id);$j=$posted[$m->order_id]??null;$rows[]=['order_id'=>$m->order_id,'period_key'=>$m->period_key,'calculation_id'=>$calc?->id,'calculation_version'=>$calc?->version,'recorded_decision'=>$calc?$decisions->get($calc->id)?->decision:null,'original_journal_id'=>$j,'recorded_correction_result_id'=>$j?$results->get($j)?->id:null,'current_dependency_conference_required'=>true];}$panels['costs']=$this->page($p,$rows);
|
||||
}
|
||||
if($can('supplier_payables.view')){
|
||||
$this->requireTables(['pa_obligations','pa_sources','pa_imported_sources']);$q=DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('pa_imported_sources as r','r.id','=','s.local_source_id');foreach($scope as $k=>$v)$q->where('o.'.$k,$v)->where('s.'.$k,$v)->where('r.'.$k,$v);$p=$q->orderBy('o.id')->paginate(25,['o.id','o.principal_minor','o.currency','s.local_source_id','r.payload'],'suppliers_page',(int)($options['suppliers_page']??1));$posted=$this->postings($scope,'supplier_payable.recognised','obligation_id',array_column($p->items(),'id'));$rows=[];foreach($p->items() as $o)$rows[]=['obligation_id'=>$o->id,'source_id'=>$o->local_source_id,'document_number'=>json_decode($o->payload,true)['number']??'—','principal_minor'=>(int)$o->principal_minor,'currency'=>$o->currency,'original_journal_id'=>$posted[$o->id]??null];$panels['suppliers']=$this->page($p,$rows);
|
||||
}
|
||||
if(config('industrial_ledger.synthetic_enabled')===true)$panels['industrial']=$can('industrial_costs.view')?app(IndustrialLedgerSource::class)->cards($user,(int)($options['industrial_page']??1)):['allowed'=>false];
|
||||
$key=$options['period_key']??'QA-2026-10';$period=DB::table('gl_periods')->where($scope)->where('period_key',$key)->orderByDesc('version')->first();$p=DB::table('gl_period_checks')->where($scope)->where('period_key',$key)->orderByDesc('id')->paginate(25,['*'],'checks_page',(int)($options['checks_page']??1));$ids=array_column($p->items(),'id');$reviews=DB::table('gl_period_reviews')->where($scope)->whereIn('check_id',$ids)->get()->keyBy('check_id');$closures=DB::table('gl_period_closures')->where($scope)->whereIn('check_id',$ids)->get()->keyBy('check_id');$checks=[];foreach($p->items() as $c)$checks[]=['capture'=>(array)$c,'review'=>$reviews->get($c->id),'closure'=>$closures->get($c->id)];$periodActions=[];foreach(['period_prepare','period_validate','period_close'] as $a)$periodActions[$a]=$ledger->can($user,$a);return ['synthetic_only'=>true,'scope'=>$scope,'panels'=>$panels,'book'=>$book,'period'=>$period,'checks'=>$this->page($p,$checks),'period_permissions'=>$periodActions,'all_current_source_views'=>!in_array(false,array_column($panels,'allowed'),true),'query'=>$options,'recorded_state_is_not_current_conference'=>true];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class LedgerCompositionController extends Controller
|
||||
{
|
||||
public function index(Request $r,LedgerComposition $workspace)
|
||||
{
|
||||
if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->page($r,app(ComposedLedger::class));
|
||||
$rules=['period_key'=>'sometimes|required|string|max:80'];foreach(['industrial_page','invoices_page','costs_page','suppliers_page','journals_page','checks_page'] as $k)$rules[$k]='sometimes|integer|min:1|max:100000';$d=$r->validate($rules);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{$data=$workspace->workspace($r->user(),$d);}catch(\DomainException $e){abort(403,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.ledger-composition',['data'=>$data])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
final class SalesLedgerController extends Controller
|
||||
{
|
||||
private function data(Request $r,GeneralLedger $ledger):array
|
||||
{
|
||||
$d=$r->validate(['invoice_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
try{return $ledger->sales($r->user(),isset($d['invoice_id'])?(int)$d['invoice_id']:null,(int)($d['page']??1));}
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
}
|
||||
public function page(Request $r,GeneralLedger $ledger){if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->focused($r,'sales');return response()->view('accounting.sales-ledger',['data'=>$this->data($r,$ledger)])->header('Cache-Control','no-store');}
|
||||
public function index(Request $r,GeneralLedger $ledger){if(config('sgf_single_installation.ledger_enabled')===true)return app(ComposedLedgerController::class)->focused($r,'sales');return response()->json($this->data($r,$ledger))->header('Cache-Control','no-store');}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Services\{ServiceInvoicing,ServiceReceivables};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Reads immutable commercial/receivable facts. Never issues, opens, receives or allocates. */
|
||||
final class SalesLedgerSource
|
||||
{
|
||||
private function scope(User $u):array
|
||||
{
|
||||
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);ServiceReceivables::guard();foreach(['si_invoices','sr_records','sr_effects','gl_sales_capability'] as $table)abort_unless(str_starts_with($table,'gl_')?SourceBook::has($table):DB::getSchemaBuilder()->hasTable($table),503,'Composição comercial contabilística não instalada.');$ctx=app(CompanyContext::class);
|
||||
abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];
|
||||
foreach(['service_invoices.view','service_receivables.view'] as $p)app(Permissions::class)->require($u->id,$scope['tenant_id'],$scope['company_id'],$p,$scope['unit_id']);return $scope;
|
||||
}
|
||||
private function hash(array $d):string{return hash('sha256',Payables::canonical($d));}
|
||||
private function minor(string $value):int{return BigDecimal::of(ServiceReceivables::money($value,false))->multipliedBy(100)->toInt();}
|
||||
private function effectQuery(array $s){return DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->select('e.*');}
|
||||
private function invoice(User $u,array $scope,int $id):array
|
||||
{
|
||||
$row=DB::table('si_invoices')->where($scope)->where('id',$id)->first()??abort(403,'Factura fora do âmbito.');
|
||||
$shown=app(ServiceInvoicing::class)->show($id,$u->id);$snapshot=$shown['snapshot'];
|
||||
if(($snapshot['simulation_only']??false)!==true||($snapshot['notice']??'')!==ServiceInvoicing::NOTICE||($snapshot['document']['data']['currency']??'')!=='MZN')throw new \DomainException('Só factura efectivamente emitida pelo circuito de teste.');
|
||||
$ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$id,'kind'=>'obligation'])->first();
|
||||
$open=$ob?$this->effectQuery($scope)->where('e.record_id',$ob->id)->where('e.kind','open')->first():null;
|
||||
if(!$ob||!$open)throw new \DomainException('Conferência financeira de abertura pendente; não presumir dívida aprovada.');
|
||||
$origin=json_decode($ob->data,true)['origin']??[];
|
||||
if(($origin['invoice_id']??null)!==$id||!hash_equals($origin['snapshot_sha256']??'',hash('sha256',$row->snapshot))||(int)$open->actor_id===(int)$ob->actor_id)throw new \DomainException('Abertura ou fotografia documental divergente.');
|
||||
$calc=$snapshot['calculation'];$amount=$this->minor($calc['total_document_proposed']);$documentBase=$calc['base_exact'];if(($snapshot['calculation_policy']??null)===\App\Modules\Services\MultiLineInvoiceCalculation::POLICY){$values=\App\Modules\Services\MultiLineInvoiceCalculation::financialAmounts($snapshot);$documentBase=$values['base'];}
|
||||
if($this->minor($ob->amount)!==$amount||$this->minor($open->amount)!==$amount)throw new \DomainException('Principal não coincide com o documento.');
|
||||
return ['effect_type'=>'sales.invoice','origin_key'=>$this->hash(['scope'=>$scope,'invoice'=>$id,'series'=>$row->series_code,'number'=>$row->number]),'source_id'=>$id,'invoice_id'=>$id,'obligation_id'=>(int)$ob->id,'recognition_revision'=>$this->hash(['invoice_snapshot'=>$snapshot,'pdf_sha256'=>$row->pdf_sha256,'opening'=>(array)$open,'obligation'=>(array)$ob]),'principal_minor'=>$amount,'amount_minor'=>$amount,'base_minor'=>$this->minor($documentBase),'tax_minor'=>$this->minor($calc['vat_document_proposed']),'currency'=>'MZN','compatible'=>true,'history_imported'=>false,'due_on'=>json_decode($ob->data,true)['due_date']??null,'document_revision'=>hash('sha256',$row->snapshot),'document_snapshot'=>$snapshot,'opening_effect_id'=>$open->id];
|
||||
}
|
||||
public function source(User $u,string $kind,int $id):array
|
||||
{
|
||||
$scope=$this->scope($u);if($kind==='sales.invoice')return $this->invoice($u,$scope,$id);
|
||||
$expected=match($kind){'receivable.allocated'=>'allocate','receivable.reversed'=>'reverse_allocation','receivable.reduced'=>'reduction','receivable.reduction_reversed'=>'reverse_reduction',default=>throw new \DomainException('Efeito comercial não suportado.')};
|
||||
$e=$this->effectQuery($scope)->where('e.id',$id)->where('e.kind',$expected)->first()??throw new \DomainException('Afectação/reversão confirmada fora do âmbito.');
|
||||
$ob=DB::table('sr_records')->where($scope)->where('id',$e->obligation_id)->where('kind','obligation')->first()??throw new \DomainException('Obrigação a receber indisponível.');
|
||||
$base=$this->invoice($u,$scope,(int)$ob->invoice_id);
|
||||
if($kind==='receivable.reduction_reversed'){
|
||||
$target=$this->effectQuery($scope)->where(['e.id'=>$e->target_effect_id,'e.kind'=>'reduction','e.obligation_id'=>$ob->id])->first();$r=DB::table('sr_records')->where($scope)->where(['id'=>$e->record_id,'kind'=>'correction'])->first();$cancel=DB::table('sd_cancellations')->where($scope)->where(['effect_id'=>$e->id,'state'=>'cancelled'])->first();
|
||||
if(!$target||!$r||!$cancel||$r->actor_id===$e->actor_id||$target->amount!==$e->amount||!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','receivable.reduced')->where('snapshot->source->source_id',(int)$target->id)->exists())throw new \DomainException('Reversão sem crédito original contabilizado e anulação distinta.');
|
||||
return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'target'=>(array)$target,'effect'=>(array)$e,'record'=>(array)$r,'cancellation'=>(array)$cancel]),'financial_effect'=>(array)$e]);
|
||||
}
|
||||
if($kind==='receivable.reduced'){
|
||||
$r=DB::table('sr_records')->where($scope)->where(['id'=>$e->record_id,'kind'=>'correction','invoice_id'=>$ob->invoice_id,'obligation_id'=>$ob->id])->first();
|
||||
$doc=DB::getSchemaBuilder()->hasTable('sd_outputs')?DB::table('sd_outputs')->where($scope)->where(['effect_id'=>$e->id,'invoice_id'=>$ob->invoice_id,'kind'=>'credit_note'])->first():null;
|
||||
if(!$r||!$doc||$r->actor_id===$e->actor_id||$r->amount!==$e->amount||!hash_equals($doc->pdf_sha256,hash('sha256',base64_decode($doc->pdf_base64,true)))||!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$ob->invoice_id)->exists())throw new \DomainException('Crédito sem documento, decisão distinta ou factura original contabilizada.');
|
||||
return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'invoice_tax_minor'=>$base['tax_minor'],'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'effect'=>(array)$e,'record'=>(array)$r,'credit_document'=>(array)$doc]),'financial_effect'=>(array)$e,'credit_document_id'=>$doc->id,'operational_nominal_reduction'=>true]);
|
||||
}
|
||||
$receipt=DB::table('sr_records')->where($scope)->where('id',$e->receipt_id)->where(['kind'=>'receipt','obligation_id'=>$ob->id,'invoice_id'=>$ob->invoice_id])->first();
|
||||
$confirmation=$receipt?$this->effectQuery($scope)->where('e.record_id',$receipt->id)->where('e.kind','receipt_confirm')->first():null;
|
||||
if(!$receipt||!$confirmation||$receipt->actor_id===$confirmation->actor_id)throw new \DomainException('Recebimento não confirmado independentemente.');
|
||||
if(!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$ob->invoice_id)->exists())throw new \DomainException('Contabilize primeiro a factura original.');
|
||||
if($kind==='receivable.reversed'){
|
||||
$target=$this->effectQuery($scope)->where('e.id',$e->target_effect_id)->where('e.kind','allocate')->where('e.obligation_id',$ob->id)->first();
|
||||
if(!$target||$target->amount!==$e->amount||!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','receivable.allocated')->where('snapshot->source->source_id',(int)$target->id)->exists())throw new \DomainException('Reversão exige a afectação original contabilizada integralmente.');
|
||||
}
|
||||
return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'invoice_tax_minor'=>$base['tax_minor'],'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'effect'=>(array)$e,'receipt'=>(array)$receipt,'confirmation'=>(array)$confirmation]),'financial_effect'=>(array)$e,'receipt_confirmation_id'=>$confirmation->id]);
|
||||
}
|
||||
public function period(User $u,array $period):array
|
||||
{
|
||||
$scope=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;
|
||||
foreach(DB::table('si_invoices')->where($scope)->orderBy('id')->cursor() as $i){$facts++;hash_update($h,$i->snapshot);hash_update($h,$i->pdf_sha256);if(!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$i->id)->exists())$pending++;}
|
||||
foreach(DB::table('sr_records')->where($scope)->orderBy('id')->cursor() as $r){hash_update($h,Payables::canonical((array)$r));if(!$this->effectQuery($scope)->where('e.record_id',$r->id)->where('e.kind','<>','allocate')->exists())$pending++;}
|
||||
foreach($this->effectQuery($scope)->orderBy('e.id')->cursor() as $e){$facts++;hash_update($h,Payables::canonical((array)$e));
|
||||
if(in_array($e->kind,['allocate','reverse_allocation','reduction','reverse_reduction'],true)){if(!SourceBook::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',match($e->kind){'allocate'=>'receivable.allocated','reduction'=>'receivable.reduced','reverse_reduction'=>'receivable.reduction_reversed',default=>'receivable.reversed'})->where('snapshot->source->source_id',(int)$e->id)->exists())$pending++;}
|
||||
elseif(!in_array($e->kind,['open','receipt_confirm','refused'],true))$pending++; // Unsupported financial changes are visible blockers, never ignored.
|
||||
}
|
||||
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
|
||||
}
|
||||
public function workspace(User $u,?int $invoice,int $page=1):array
|
||||
{
|
||||
$scope=$this->scope($u);$p=\App\Modules\Services\QualifiedInvoiceSource::query()->where($scope)->orderByDesc('id')->paginate(25,['id','series_code','number','issued_at'],'page',$page);
|
||||
$data=['scope'=>$scope,'invoices'=>$p->items(),'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()],'invoice'=>null];if($invoice===null)return $data;
|
||||
$data['invoice']=app(ServiceInvoicing::class)->show($invoice,$u->id);
|
||||
$row=DB::table('si_invoices')->where($scope)->where('id',$invoice)->first()??abort(403);
|
||||
$records=DB::table('sr_records')->where($scope)->where('invoice_id',$invoice)->orderByDesc('id')->paginate(25,['*'],'records_page',$page);$data['records']=$records->items();$data['records_pagination']=['page'=>$page,'last_page'=>$records->lastPage(),'total'=>$records->total()];
|
||||
$decisions=$this->effectQuery($scope)->whereIn('e.record_id',array_column($records->items(),'id'))->where('e.kind','<>','allocate')->get()->keyBy('record_id');foreach($data['records'] as $r){$r->decision_kind=$decisions->get($r->id)?->kind;$r->decision_id=$decisions->get($r->id)?->id;}
|
||||
$ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$invoice,'kind'=>'obligation'])->first();$data['obligation_id']=$ob?->id;
|
||||
$data['position']=$ob?app(ServiceReceivables::class)->position($ob->id,$u->id):null;
|
||||
if($data['position']){unset($data['position']['effects']);$data['position']['receipts']=array_intersect_key($data['position']['receipts'],array_fill_keys(array_column($records->items(),'id'),true));}
|
||||
$effects=$this->effectQuery($scope)->where('r.invoice_id',$invoice)->orderByDesc('e.id')->paginate(25,['e.*'],'effects_page',$page);$data['effects']=$effects->items();$data['effects_pagination']=['page'=>$page,'last_page'=>$effects->lastPage(),'total'=>$effects->total()];
|
||||
$data['finance_permissions']=[];foreach(['prepare','confirm','allocate','correction_request','correction_approve'] as $a)$data['finance_permissions'][$a]=app(Permissions::class)->has($u->id,$scope['tenant_id'],$scope['company_id'],'service_receivables.'.$a,$scope['unit_id']);
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\GeneralLedger\LedgerSchema;
|
||||
|
||||
/** Read routing only. Domain facts stay in the host; ledger metadata stays in its original book. */
|
||||
final class SourceBook
|
||||
{
|
||||
private static function connection()
|
||||
{
|
||||
if(!(app()->environment('budget-testing')&&config('sgf_single_installation.enabled')===true&&config('sgf_single_installation.ledger_enabled')===true))return DB::connection();
|
||||
CompositionRuntime::assert();$host=CompositionRuntime::physical();$ctx=app(CompanyContext::class);$scope=$ctx->identifiers();$native=DB::connection('receipt_native');LedgerSchema::assertSynthetic($native);
|
||||
$binding=$native->table('gl_sgf_composition')->where('tenant_id',$scope['tenant_id'])->first();
|
||||
abort_unless($binding&&$binding->host_database===$host['database']&&(int)$binding->company_id===$scope['company_id']&&(int)$binding->unit_id===$ctx->unitId(),403);
|
||||
return $native;
|
||||
}
|
||||
public static function table(string $table){if(!preg_match('/^gl_[a-z_]+$/D',$table))throw new \LogicException('Ledger metadata only');return self::connection()->table($table);}
|
||||
public static function has(string $table):bool{if(!preg_match('/^gl_[a-z_]+$/D',$table))throw new \LogicException('Ledger metadata only');return self::connection()->getSchemaBuilder()->hasTable($table);}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
namespace App\Modules\Accounting;
|
||||
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** SGF observes the canonical physical/valuation book with a SELECT-only QA role. */
|
||||
final class StockValuationRead
|
||||
{
|
||||
public function index(Request $r,CompanyContext $context,Permissions $permissions)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
$s=$context->identifiers();$unit=$context->unitId();abort_unless($unit,403);
|
||||
foreach(['stock.view','general_ledger.view']as$p)$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$p,$unit);
|
||||
$binding=config('stock_ledger.read_sources.'.$s['tenant_id'].':'.$s['company_id'].':'.$unit);
|
||||
if(!$binding)return response()->json(['status'=>'unavailable','message'=>'Livro de valorização autorizado ainda não ligado a este contexto.'],503)->header('Cache-Control','no-store');
|
||||
abort_unless(app()->environment('budget-testing'),403);
|
||||
$host=DB::connection();$h=$host->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&($h['driver']??null)==='pgsql'&&($h['host']??null)==='127.0.0.1'&&(string)($h['port']??'')==='55439'&&preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$h['database']??'',$hm)&&($h['username']??null)==='stock_ledger_sgf_'.$hm[1]&&empty($h['url'])&&empty($h['read'])&&empty($h['write']),403);
|
||||
$hi=$host->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster");abort_unless($hi->db===$h['database']&&$hi->role===$h['username']&&$hi->cluster==='sgf_modular_regression_'.$run,403);
|
||||
abort_unless(($binding['connection']??null)==='stock_valuation_source'&&is_int($binding['book_id']??null)&&($binding['book_id']??0)>0,503);
|
||||
$c=DB::connection('stock_valuation_source');$cfg=$c->getConfig();
|
||||
abort_unless(($cfg['driver']??null)==='pgsql'&&($cfg['host']??null)==='127.0.0.1'&&(string)($cfg['port']??'')==='55439'&&preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$cfg['database']??'',$m)&&($cfg['username']??null)==='stock_valuation_read_'.$m[1]&&empty($cfg['url'])&&empty($cfg['read'])&&empty($cfg['write'])&&($cfg['search_path']??'public')==='public',403);
|
||||
$native=$binding['native_scope']??[];abort_unless(array_keys($native)===['tenant_id','company_id','unit_id']&&is_int($native['tenant_id'])&&$native['tenant_id']>0&&$native['company_id']===0&&$native['unit_id']===0,503);
|
||||
try{$data=$c->transaction(function()use($c,$cfg,$run,$native,$binding,$d){
|
||||
abort_unless($c->transactionLevel()===1,503);$c->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$i=$c->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,current_setting('default_transaction_read_only') ro,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless($i->db===$cfg['database']&&$i->role===$cfg['username']&&$i->cluster==='sgf_modular_regression_'.$run&&$i->ro==='on'&&!$i->elevated,403);
|
||||
foreach(['stock_movements','gl_journals','gl_lines','gl_stock_rates','gl_stock_books']as$table)abort_if($c->selectOne("SELECT has_table_privilege(current_user,?,'INSERT,UPDATE,DELETE') writes",[$table])->writes,403);
|
||||
$book=$c->table('gl_stock_books')->where($native)->where('id',$binding['book_id'])->first();abort_unless($book,503,'Livro composto incompatível.');
|
||||
abort_unless($c->table('stock_items')->where(['tenant_id'=>$native['tenant_id'],'id'=>$book->stock_item_id,'unit'=>$book->unit_code])->exists()
|
||||
&&$c->table('warehouses')->where(['tenant_id'=>$native['tenant_id'],'id'=>$book->warehouse_id])->exists(),503,'Artigo/armazém/unidade da origem incompatíveis.');
|
||||
$page=(int)($d['page']??1);$size=(int)($d['per_page']??25);
|
||||
$physical=$c->table('stock_movements')->where(['tenant_id'=>$native['tenant_id'],'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id]);
|
||||
$journals=$c->table('gl_journals')->where($native)->where('snapshot->source->stock_book_id',$book->id);$count=(clone$journals)->count();
|
||||
$rows=(clone$journals)->orderBy('id')->offset(($page-1)*$size)->limit($size)->get();$ids=$rows->pluck('id')->all();$lines=$c->table('gl_lines')->where($native)->whereIn('journal_id',$ids)->orderBy('journal_id')->orderBy('line_no')->get();
|
||||
$postedIds=(clone$journals)->selectRaw("(snapshot->'source'->>'stock_movement_id')::bigint");
|
||||
$unposted=(clone$physical)->whereNotIn('id',$postedIds)->count();
|
||||
$rate=$c->table('gl_stock_rates')->where($native)->where('book_id',$book->id)->orderByDesc('version')->first();$review=$rate?$c->table('gl_stock_rate_reviews')->where($native)->where('rate_id',$rate->id)->first():null;
|
||||
$stale=$rate?(clone$journals)->where('snapshot->source->stock_rate_id','<>',$rate->id)->count():$count;
|
||||
$net=$c->table('gl_lines as l')->join('gl_accounts as a','a.id','=','l.account_id')->where(['l.tenant_id'=>$native['tenant_id'],'l.company_id'=>0,'l.unit_id'=>0,'a.code'=>'QA-STOCK'])->whereIn('l.journal_id',(clone$journals)->select('id'))->selectRaw('coalesce(sum(l.debit_minor-l.credit_minor),0) net')->first();
|
||||
return ['synthetic_only'=>true,'single_canonical_book'=>true,'status'=>$rate&&$review&&$review->decision==='approved'&&$unposted===0&&$stale===0?'reconciled':'pending','reconciliation_scope'=>'physical_movement_coverage_and_rate_ids_only','current_source_dependencies_validated'=>false,'book'=>['id'=>$book->id,'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id,'unit'=>$book->unit_code,'period_key'=>$book->period_key],'physical_quantity_milli'=>(int)(clone$physical)->sum('quantity_milli'),'physical_revision'=>(int)(clone$physical)->max('id'),'posted_stock_value_minor'=>(int)$net->net,'currency'=>'MZN','unposted_movements'=>$unposted,'stale_rate_journals'=>$stale,'journals'=>$rows->all(),'lines'=>$lines->all(),'pagination'=>['page'=>$page,'per_page'=>$size,'total'=>$count],'source_identity'=>['database'=>$i->db,'role'=>$i->role],'operation_executed'=>false];
|
||||
});}catch(\PDOException $e){$state=$e->errorInfo[0]??$e->getCode();if(str_starts_with((string)$state,'08')||in_array($state,['28P01','3D000','42P01','42703','57014'],true))return response()->json(['status'=>'unavailable','message'=>'Livro de valorização indisponível ou incompatível.'],503)->header('Cache-Control','no-store');throw$e;}
|
||||
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
|
||||
return response()->view('accounting.stock-valuation-read',['data'=>$data])->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance;
|
||||
use App\Shared\Installation\CompositionRuntime;use App\Modules\Accounting\SourceBook;use Illuminate\Support\Facades\DB;
|
||||
/** Strict read adapter over two existing native QA lines. No fixture fallback or bank classification. */
|
||||
class BankLedgerSource
|
||||
{
|
||||
public function snapshot(array $scope,array $binding):array
|
||||
{
|
||||
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/binding.json';abort_unless(realpath((string)config('receipt_qa.profile'))===realpath($file),403);$profile=json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);$c=$profile['connection'];abort_unless($c['database']==='compras_receipts_test_'.$p['composition']&&$c['username']==='payable_history_native_'.$p['composition']&&$c['host']==='127.0.0.1'&&(int)$c['port']===55439,403);config(['database.connections.receipt_native'=>$c]);
|
||||
return $this->read($scope,$binding);
|
||||
}
|
||||
private function read(array $s,array $b):array
|
||||
{
|
||||
$rows=SourceBook::table('gl_lines')->where($s)->whereIn('id',$b['ledger_line_ids'])->orderBy('id')->get();abort_unless($rows->count()===2,409,'As duas fontes contabilísticas do exercício não estão disponíveis.');$out=[];
|
||||
foreach($rows as$r){$j=SourceBook::table('gl_journals')->where($s)->where('id',$r->journal_id)->first();$a=SourceBook::table('gl_accounts')->where($s)->where('id',$r->account_id)->first();abort_unless($j&&$a&&$a->code==='QA-LIQUIDACAO'&&$j->currency==='MZN'&&$j->journal_on==='2026-11-10'&&max($r->debit_minor,$r->credit_minor)===2000,409,'Linha contabilística incompatível com o exercício autorizado.');$out[]=['id'=>$r->id,'journal_id'=>$r->journal_id,'account_code'=>$a->code,'date'=>$j->journal_on,'currency'=>'MZN','debit_minor'=>$r->debit_minor,'credit_minor'=>$r->credit_minor,'journal_sha256'=>hash('sha256',$j->snapshot)];}
|
||||
return ['kind'=>'native_qa_ledger','lines'=>$out,'hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($out))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance;
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use App\Modules\Identity\Services\{Permissions,Audit};use Illuminate\Support\Facades\{DB,Crypt,Validator};use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** A bounded internal statement exercise. Never posts, pays or changes account classification. */
|
||||
final class BankReconciliation
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions,private BankLedgerSource $source){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
public function scope(int $actor,string $right='view'):array
|
||||
{
|
||||
abort_unless(config('bank_qa.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&ModuleRecords::active()==='finance',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(array_unique(['view',$right])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'bank_reconciliation.'.$r,$s['unit_id']);abort_unless($this->binding()['scope']===$s,403);return $s;
|
||||
}
|
||||
public function binding():array
|
||||
{
|
||||
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/bank-binding.json';$pin=config('bank_qa.binding_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503,'Vínculo do exercício bancário não conferido.');$b=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($b['composition']===$p['composition']&&$b['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&$b['ledger_line_ids']===[4,5],403);return $b;
|
||||
}
|
||||
private function statements(array $s,array $b){return DB::table('br_statements')->where($s)->where('statement_key',$b['statement_key']);}
|
||||
private function events(array $s,int $id){return DB::table('br_events')->where($s)->where('statement_id',$id);}
|
||||
private function row(object $r):array{$a=(array)$r;foreach(['entries','data']as$k)if(isset($a[$k]))$a[$k]=json_decode($a[$k],true,32,JSON_THROW_ON_ERROR);return $a;}
|
||||
private function basis(array $s,array $b):array
|
||||
{
|
||||
$source=$this->source->snapshot($s,$b);$header=$this->statements($s,$b)->first();$last=$header?$this->events($s,$header->id)->orderByDesc('version')->first():null;
|
||||
abort_if($header&&$header->source_hash!==$source['hash'],409,'A origem contabilística mudou. Confira o exercício; não há ajuste automático.');return ['binding'=>$this->hash($b),'source'=>$source,'header'=>$header?$this->row($header):null,'last'=>$last?$this->row($last):null];
|
||||
}
|
||||
private function exact(array $d,array $rules):array{abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo fora do exercício autorizado.');return Validator::make($d,$rules)->validate();}
|
||||
private function right(string $a):string{return match($a){'import'=>'import','prepare_match'=>'prepare',default=>'review'};}
|
||||
private function lock(int $actor,array $s):void
|
||||
{
|
||||
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first(),403);DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
abort_if(strlen(json_encode($input))>32768,422);$d=$this->exact($input,['action'=>'required|in:import,review_statement,prepare_match,review_match,finalize','version'=>'required|integer|min:0','proposal_id'=>'nullable|required_if:action,review_match|integer|min:1','data'=>'present|array','reason'=>'required|string|min:10|max:4000']);$d['version']=(int)$d['version'];abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$this->right($d['action']));$b=$this->binding();return DB::transaction(function()use($actor,$s,$b,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));$basis=$this->basis($s,$b);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$b,$d,$basis);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'observed'=>time(),'key'=>$key,'input'=>$d,'basis'=>$this->hash($basis),'impact'=>$out['impact']];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
|
||||
}
|
||||
public function confirm(int $actor,string $preview):array
|
||||
{
|
||||
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$c['input'];$s=$this->scope($actor,$this->right($d['action']));$b=$this->binding();return DB::transaction(function()use($actor,$s,$b,$d,$c){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));abort_unless($c['actor']===$actor&&$c['scope']==$s&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto ou prévia expirados.');$old=DB::table('br_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409);return json_decode($old->result,true)+['replayed'=>true,'historical_receipt_only'=>true];}$basis=$this->basis($s,$b);abort_unless($this->hash($basis)===$c['basis'],409,'Extracto, origem ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$b,$d,$basis);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('br_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('bank_qa.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'before_revision'=>$d['version'],'reason'=>$d['reason'],'review_hash'=>$basis['source']['hash']]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function parse(array $d,array $b):array
|
||||
{
|
||||
$d=$this->exact($d,['account_ref'=>'required|in:QA-BANCO-LOCAL','currency'=>'required|in:MZN','period'=>'required|in:2026-11','opening'=>'required|in:0.00','closing'=>'required|in:0.00','csv'=>'required|string|max:8192']);$fp=fopen('php://temp','w+');fwrite($fp,$d['csv']);rewind($fp);abort_unless(fgetcsv($fp,0,',','"','')===['date','reference','debit','credit','balance'],422,'Cabeçalho CSV inválido.');$rows=[];$balance=0;while(($row=fgetcsv($fp,0,',','"',''))!==false){if($row===[null])continue;abort_unless(count($row)===5&&count($rows)<2,422,'Este exercício exige exactamente duas linhas.');[$date,$ref,$debit,$credit,$closing]=$row;$expected=count($rows)===0?['2026-11-10','QA-OUT-20','20.00','0.00','-20.00']:['2026-11-10','QA-IN-20','0.00','20.00','0.00'];abort_unless($row===$expected,422,'Data, referência, sinal, valor ou saldo fora do exercício artificial autorizado.');$out=$debit==='20.00'?2000:0;$in=$credit==='20.00'?2000:0;$balance+=$in-$out;abort_unless(($closing==='-20.00'?-2000:0)===$balance,422);$rows[]=['date'=>$date,'reference'=>$ref,'debit_minor'=>$out,'credit_minor'=>$in,'balance_minor'=>$balance];}fclose($fp);abort_unless(count($rows)===2&&$balance===0,422,'Fecho/linhas incompletos.');return $d+['entries'=>$rows,'csv_sha256'=>hash('sha256',$d['csv'])];
|
||||
}
|
||||
private function reviewed(array $s,int $id,string $state):bool{return $this->events($s,$id)->where('state',$state)->exists();}
|
||||
private function checkPreparer(int $actor,int $prepared,array $s,string $permission):void
|
||||
{
|
||||
abort_unless($actor!==$prepared,403,'Revisão por pessoa distinta obrigatória.');DB::table('users')->where('id',$prepared)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),403);abort_unless(DB::table('permission_grants')->where($s+['user_id'=>$prepared,'permission'=>$permission])->lockForUpdate()->first(),403,'Preparador sem autoridade actual.');
|
||||
}
|
||||
private function execute(int $actor,array $s,array $b,array $d,array $basis):array
|
||||
{
|
||||
$h=$basis['header'];$last=$basis['last'];abort_unless(($last['version']??0)===$d['version'],409,'Versão alterada.');$prepared=$actor;$proposal=null;$data=[];$state='';$id=$h['id']??null;
|
||||
if($d['action']==='import'){
|
||||
abort_if(isset($d['proposal_id']),422);$parsed=$this->parse($d['data'],$b);if($h){abort_unless($parsed['csv_sha256']===$h['csv_sha256'],409,'Conserve o extracto importado; não pode substituir a fotografia.');return ['id'=>$last['id'],'statement_id'=>$id,'version'=>$last['version'],'state'=>$last['state'],'already_imported'=>true,'impact'=>['action'=>'import','before_version'=>$d['version'],'after_version'=>$d['version'],'state'=>$last['state'],'existing_statement'=>true,'new_journals'=>0,'bank_operations'=>false]];}
|
||||
$id=DB::table('br_statements')->insertGetId($s+['statement_key'=>$b['statement_key'],'account_ref'=>$parsed['account_ref'],'currency'=>$parsed['currency'],'period'=>$parsed['period'],'csv_sha256'=>$parsed['csv_sha256'],'csv_bytes'=>$parsed['csv'],'opening_minor'=>0,'closing_minor'=>0,'entries'=>$this->json($parsed['entries']),'source_hash'=>$basis['source']['hash'],'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()]);$state='imported';$data=['csv_sha256'=>$parsed['csv_sha256'],'opening_minor'=>0,'closing_minor'=>0,'rows'=>2];
|
||||
}else{
|
||||
abort_unless($h,409,'Importe e confira o extracto primeiro.');abort_if($this->reviewed($s,$id,'finalized'),409,'Conferência finalizada, sem reabertura.');
|
||||
if($d['action']==='review_statement'){$decision=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);abort_if($this->events($s,$id)->where('action','review_statement')->exists(),409,'O extracto já recebeu decisão.');$prepared=$h['actor_id'];$this->checkPreparer($actor,$prepared,$s,'bank_reconciliation.import');$state=$decision['decision']==='reviewed'?'statement_reviewed':'statement_refused';$data=['csv_sha256'=>$h['csv_sha256'],'opening_minor'=>$h['opening_minor'],'closing_minor'=>$h['closing_minor']];}
|
||||
else{
|
||||
abort_unless($this->reviewed($s,$id,'statement_reviewed'),409,'Extracto sem revisão favorável.');
|
||||
if($d['action']==='prepare_match'){
|
||||
abort_if(isset($d['proposal_id']),422);$data=$this->exact($d['data'],['row_reference'=>'required|in:QA-OUT-20,QA-IN-20','ledger_line_id'=>'required|integer|min:1']);$row=null;$line=null;foreach($h['entries']as$r)if($r['reference']===$data['row_reference'])$row=$r;foreach($basis['source']['lines']as$l)if($l['id']===(int)$data['ledger_line_id'])$line=$l;abort_unless($row&&$line,403,'Escolha individual da linha de extracto e origem autorizadas.');abort_unless($row['debit_minor']===$line['credit_minor']&&$row['credit_minor']===$line['debit_minor']&&$row['date']===$line['date'],422,'Sinal/data/valor não correspondem.');
|
||||
$used=$this->events($s,$id)->where('state','match_reviewed')->where(fn($q)=>$q->where('data->row_reference',$data['row_reference'])->orWhere('data->ledger_line_id',$data['ledger_line_id']))->exists();abort_if($used,409,'Linha já conferida; não pode ser usada novamente.');$pending=$this->events($s,$id)->where('state','match_prepared')->where('data->row_reference',$data['row_reference'])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('br_events as r')->whereColumn('r.proposal_id','br_events.id'))->exists();abort_if($pending,409,'Já existe uma proposta por rever nesta linha.');$state='match_prepared';$data=['row_reference'=>$row['reference'],'ledger_line_id'=>$line['id'],'journal_id'=>$line['journal_id'],'debit_minor'=>$row['debit_minor'],'credit_minor'=>$row['credit_minor'],'source_hash'=>$basis['source']['hash']];
|
||||
}elseif($d['action']==='review_match'){
|
||||
$decision=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);$p=$this->events($s,$id)->where(['id'=>$d['proposal_id'],'state'=>'match_prepared'])->first();abort_unless($p,403);abort_if($this->events($s,$id)->where('proposal_id',$p->id)->exists(),409,'Proposta já revista.');$prepared=$p->prepared_by;$this->checkPreparer($actor,$prepared,$s,'bank_reconciliation.prepare');$proposal=$p->id;$data=json_decode($p->data,true);abort_unless($data['source_hash']===$basis['source']['hash'],409);$state=$decision['decision']==='reviewed'?'match_reviewed':'match_refused';
|
||||
}else{
|
||||
$this->exact($d['data'],[]);abort_if(isset($d['proposal_id']),422);$prepared=$h['actor_id'];$this->checkPreparer($actor,$prepared,$s,'bank_reconciliation.import');abort_unless($this->events($s,$id)->where('state','match_reviewed')->count()===2,409,'Há linhas por conferir.');abort_if($this->events($s,$id)->where('state','match_prepared')->whereNotExists(fn($q)=>$q->selectRaw('1')->from('br_events as r')->whereColumn('r.proposal_id','br_events.id'))->exists(),409,'Há propostas por rever.');$state='finalized';$data=['opening_minor'=>0,'closing_minor'=>0,'difference_minor'=>0,'matched_rows'=>2,'source_hash'=>$basis['source']['hash']];
|
||||
}
|
||||
}
|
||||
}
|
||||
$version=$d['version']+1;$event=DB::table('br_events')->insertGetId($s+['statement_id'=>$id,'version'=>$version,'previous_id'=>$last['id']??null,'proposal_id'=>$proposal,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'data'=>$this->json($data),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$event,'statement_id'=>$id,'version'=>$version,'state'=>$state,'impact'=>['action'=>$d['action'],'before_version'=>$d['version'],'after_version'=>$version,'before_state'=>$last['state']??null,'state'=>$state,'data'=>$data,'new_journals'=>0,'bank_operations'=>false]];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=$this->exact($input,['view'=>'sometimes|in:rows,history','status'=>'sometimes|in:unmatched,pending,matched','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$b=$this->binding();return DB::transaction(function()use($actor,$s,$d,$b){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$basis=null;$blocked=null;try{$basis=$this->basis($s,$b);}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==409)throw $e;$blocked=$e->getMessage();}$h=$basis['header']??($this->statements($s,$b)->first()?->id? $this->row($this->statements($s,$b)->first()):null);$rows=[];$pending=[];$matches=[];$history=null;$statementReviewed=false;$final=false;
|
||||
if($h){$matches=$this->events($s,$h['id'])->where('state','match_reviewed')->get()->map(fn($r)=>$this->row($r))->all();$pending=$this->events($s,$h['id'])->where('state','match_prepared')->whereNotExists(fn($q)=>$q->selectRaw('1')->from('br_events as r')->whereColumn('r.proposal_id','br_events.id'))->get()->map(fn($r)=>$this->row($r))->all();foreach($h['entries']as$r){$r['status']='unmatched';foreach($matches as$m)if($m['data']['row_reference']===$r['reference']){$r['status']='matched';$r['match']=$m;}foreach($pending as$p)if($p['data']['row_reference']===$r['reference']){$r['status']='pending';$r['proposal']=$p;}if(!isset($d['status'])||$d['status']===$r['status'])$rows[]=$r;}$q=$this->events($s,$h['id']);$history=$q->orderByDesc('version')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$history->setCollection($history->getCollection()->map(fn($r)=>$this->row($r)));$statementReviewed=$this->reviewed($s,$h['id'],'statement_reviewed');$final=$this->reviewed($s,$h['id'],'finalized');unset($h['csv_bytes']);}
|
||||
return ['synthetic_only'=>true,'account_is_real_bank'=>false,'blocked'=>$blocked,'source_kind'=>$basis['source']['kind']??null,'source_lines'=>$basis['source']['lines']??[],'statement'=>$h,'version'=>$basis['last']['version']??0,'statement_reviewed'=>$statementReviewed,'finalized'=>$final,'rows'=>$rows,'pending_proposals'=>$pending,'history'=>$history?->toArray(),'report_available'=>$final&&!$blocked,'capabilities'=>array_combine(['import','prepare','review'],array_map(fn($r)=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'bank_reconciliation.'.$r,$s['unit_id']),['import','prepare','review'])),'template_csv'=>file_get_contents(base_path('resources/data/bank-statement-qa.csv')),'new_journals'=>0];});
|
||||
}
|
||||
public function receipt(int $actor,string $key):array
|
||||
{
|
||||
$s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$r=DB::table('br_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'operation_executed'=>false,'historical_receipt_only'=>true,'created_at'=>$r->created_at];
|
||||
}
|
||||
public function report(int $actor):array
|
||||
{
|
||||
$s=$this->scope($actor);$b=$this->binding();return DB::transaction(function()use($actor,$s,$b){$this->lock($actor,$s);$basis=$this->basis($s,$b);$h=$basis['header'];abort_unless($h&&$this->reviewed($s,$h['id'],'finalized'),409,'Conclua a conferência antes do relatório.');$matches=$this->events($s,$h['id'])->where('state','match_reviewed')->orderBy('id')->get();$fp=fopen('php://temp','w+');$rows=[['CONFERENCIA INTERNA QA — NAO E EXTRACTO BANCARIO REAL'],['Conta de exercicio',$h['account_ref']],['Abertura','0.00'],['Fecho','0.00'],['Diferenca','0.00'],['Linha extracto','Linha contabilistica','Diario','Debito extracto MZN','Credito extracto MZN']];foreach($matches as$m){$d=json_decode($m->data,true);$rows[]=[$d['row_reference'],(string)$d['ledger_line_id'],(string)$d['journal_id'],(string)\Brick\Math\BigDecimal::of($d['debit_minor'])->dividedBy('100',2),(string)\Brick\Math\BigDecimal::of($d['credit_minor'])->dividedBy('100',2)];}foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);Audit::record('bank_qa.report_downloaded',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$h['id'],'version'=>$basis['last']['version'],'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance;
|
||||
use App\Modules\Identity\Services\{Permissions,Audit};use App\Modules\Services\{ServiceReceivables,DocumentPeriod};use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use App\Modules\Accounting\SourceBook;use Illuminate\Support\Facades\{DB,Crypt,Validator};use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** One explicit QA custody book; never issues invoices/receipts or moves real money. */
|
||||
final class CashWorkspace
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $v):string{return Payables::canonical($v);}private function hash(array $v):string{return hash('sha256',$this->json($v));}
|
||||
public function scope(int $actor,string $right='view',bool $ledger=false):array
|
||||
{
|
||||
abort_unless(config('cash_qa.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&in_array(ModuleRecords::active(),$ledger?['finance','accounting']:['finance'],true),403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(['view',$right] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'cash.'.$r,$s['unit_id']);abort_unless($this->binding()['scope']==$s,403);return $s;
|
||||
}
|
||||
public function binding():array
|
||||
{
|
||||
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/cash-binding.json';$pin=config('cash_qa.binding_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);$b=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($b['composition']===$p['composition']&&$b['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],403);return $b;
|
||||
}
|
||||
private function events(array $s,array $b){return DB::table('cash_events')->where($s)->where('session_key',$b['session_key']);}
|
||||
public function receipt(array $s,array $b):array
|
||||
{
|
||||
$r=DB::table('sr_records')->where($s)->where(['id'=>$b['receipt_id'],'invoice_id'=>$b['invoice_id'],'kind'=>'receipt'])->first();$confirm=$r?DB::table('sr_effects')->where(['record_id'=>$r->id,'kind'=>'receipt_confirm'])->first():null;$doc=DB::table('sd_outputs')->where($s)->where(['id'=>$b['source_output_id'],'kind'=>'invoice_receipt','invoice_id'=>$b['invoice_id']])->first();$allocation=$doc?DB::table('sr_effects')->where(['id'=>$doc->effect_id,'receipt_id'=>$b['receipt_id'],'kind'=>'allocate'])->first():null;
|
||||
abort_unless($r&&$confirm&&$doc&&$allocation&&$r->actor_id!==$confirm->actor_id&&ServiceReceivables::money($r->amount)==='1000.00'&&ServiceReceivables::money($allocation->amount)==='1000.00'&&!DB::table('sr_effects')->where('receipt_id',$r->id)->where('kind','reverse_receipt')->exists()&&!DB::table('sr_effects')->where('target_effect_id',$allocation->id)->exists(),409,'A origem foi alterada/revertida ou não está confirmada. Confira a custódia; não há reembolso automático.');
|
||||
abort_unless(hash_equals($doc->pdf_sha256,hash('sha256',base64_decode($doc->pdf_base64,true))),409);return ['receipt'=>(array)$r,'confirmation'=>(array)$confirm,'allocation'=>(array)$allocation,'document'=>['id'=>$doc->id,'invoice_id'=>$doc->invoice_id,'sha256'=>$doc->pdf_sha256]];
|
||||
}
|
||||
public function basis(array $s,array $b):array
|
||||
{
|
||||
$q=$this->events($s,$b);$balance=(int)(clone$q)->where(['action'=>'review','state'=>'approved'])->selectRaw("coalesce(sum(CASE kind WHEN 'receipt' THEN (data->>'amount_minor')::bigint WHEN 'expense' THEN -(data->>'amount_minor')::bigint ELSE 0 END),0) balance")->value('balance');$fingerprint=(clone$q)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->orderBy('id')->pluck('id')->all();
|
||||
return ['binding'=>$this->hash($b),'revision'=>(int)(clone$q)->max('version'),'open'=>(clone$q)->where(['kind'=>'open','action'=>'review','state'=>'approved'])->exists(),'closed'=>(clone$q)->where('state','closed')->exists(),'received'=>(clone$q)->where(['kind'=>'receipt','action'=>'review','state'=>'approved'])->exists(),'expense_recorded'=>(clone$q)->where(['kind'=>'expense','action'=>'review','state'=>'approved'])->exists(),'balance_minor'=>$balance,'movements_hash'=>$this->hash($fingerprint),'source'=>$this->hash($this->receipt($s,$b))];
|
||||
}
|
||||
private function lock(int $actor,array $s):void
|
||||
{
|
||||
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['user_id'=>$actor,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->lockForUpdate()->first(),403);DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->where(fn($q)=>$q->whereNull('unit_id')->orWhere('unit_id',$s['unit_id']))->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
abort_if(strlen(json_encode($input,JSON_THROW_ON_ERROR))>65536,422);$rules=['action'=>'required|in:prepare,review,close','kind'=>'required|in:open,receipt,expense,count','proposal_id'=>'nullable|integer|min:1','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:4000'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$d['action']);$b=$this->binding();
|
||||
return DB::transaction(function()use($actor,$s,$d,$key,$b){$this->lock($actor,$s);$this->scope($actor,$d['action']);$basis=$this->basis($s,$b);abort_unless($basis['revision']===$d['version'],409,'Versão da Caixa alterada.');$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$b,$d,$basis);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['input'=>$d,'key'=>$key,'actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'basis'=>$this->hash($basis),'impact'=>$out['impact'],'observed'=>time()];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
|
||||
}
|
||||
public function confirm(int $actor,string $preview):array
|
||||
{
|
||||
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$c['input'];$s=$this->scope($actor,$d['action']);$b=$this->binding();return DB::transaction(function()use($actor,$s,$d,$c,$b){$this->lock($actor,$s);$this->scope($actor,$d['action']);abort_unless($c['actor']===$actor&&$c['scope']==$s&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto ou prévia expirados.');$old=DB::table('cash_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409);return json_decode($old->result,true)+['replayed'=>true];}$basis=$this->basis($s,$b);abort_unless($this->hash($basis)===$c['basis'],409,'Caixa, origem, estado ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$b,$d,$basis);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');DB::table('cash_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('cash.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'reason'=>$d['reason']]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function exact(array $data,array $rules):array{abort_if(array_diff(array_keys($data),array_keys($rules)),422);return Validator::make($data,$rules)->validate();}
|
||||
private function execute(int $actor,array $s,array $b,array $d,array $basis):array
|
||||
{
|
||||
abort_if($basis['closed'],409,'Caixa fechada; não há reabertura.');$period=DocumentPeriod::open($s);$q=$this->events($s,$b);$data=[];$source=null;$proposal=null;$state='proposed';$kind=$d['kind'];$change=0;
|
||||
if($d['action']==='prepare'){
|
||||
abort_if($d['proposal_id']??null,422);abort_unless($kind==='open'?!$basis['open']:$basis['open'],409,'Confirme a abertura primeiro.');$common=['date'=>'required|date_format:Y-m-d|before_or_equal:today','evidence'=>'required|string|min:10|max:1000'];
|
||||
$data=$this->exact($d['data'],match($kind){'open'=>$common+['label'=>'required|string|min:3|max:100','opening'=>'required|string'],'receipt'=>$common+['receipt_id'=>'required|integer|min:1','medium'=>'required|in:cash'],'expense'=>$common+['amount'=>'required|string','beneficiary'=>'required|string|min:3|max:150','description'=>'required|string|min:10|max:1000'],'count'=>$common+['counted'=>'required|string']});
|
||||
if($kind==='open'){abort_unless(ServiceReceivables::money($data['opening'],false)==='0.00',422,'Abertura QA explicitamente zero.');$data['amount_minor']=0;}
|
||||
if($kind==='receipt'){abort_unless($data['receipt_id']===$b['receipt_id'],403,'Escolha a origem QA autorizada.');abort_if((clone$q)->where(['kind'=>'receipt','action'=>'review','state'=>'approved'])->exists(),409,'Recebimento já classificado.');$source=$b['receipt_id'];$data['amount_minor']=$b['amount_minor'];}
|
||||
if($kind==='expense'){abort_unless(ServiceReceivables::money($data['amount'])==='50.00'&&$basis['balance_minor']>=5000,422,'Despesa QA50 exige saldo suficiente.');abort_if((clone$q)->where(['kind'=>'expense','action'=>'review','state'=>'approved'])->exists(),409,'Despesa QA já confirmada.');$data['amount_minor']=5000;}
|
||||
if($kind==='count'){$data['counted_minor']=(int)\Brick\Math\BigDecimal::of(ServiceReceivables::money($data['counted'],false))->multipliedBy(100)->toInt();abort_if($data['counted_minor']<0,422);}
|
||||
if($kind!=='open'){$opened=(clone$q)->where(['kind'=>'open','action'=>'review','state'=>'approved'])->firstOrFail();abort_unless($data['date']>=json_decode($opened->data,true)['date'],422,'O facto não pode anteceder a abertura.');}
|
||||
if($kind==='receipt'){$original=$this->receipt($s,$b);$on=json_decode($original['receipt']['data'],true)['date']??null;abort_unless(is_string($on)&&$data['date']>=$on,422,'A classificação não pode anteceder o recebimento.');}
|
||||
if(in_array($kind,['expense','count'],true)){$lastDate=(clone$q)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->max('data->date');abort_unless($lastDate===null||$data['date']>=$lastDate,422,'O facto não pode anteceder os movimentos confirmados.');}
|
||||
}elseif($d['action']==='review'){
|
||||
$choice=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);$proposal=(clone$q)->where(['id'=>$d['proposal_id']??0,'action'=>'prepare','kind'=>$kind])->firstOrFail();abort_if($proposal->actor_id===$actor,403,'Revisão exige pessoa distinta.');abort_if((clone$q)->where('proposal_id',$proposal->id)->exists(),409,'Proposta já decidida.');$data=json_decode($proposal->data,true);$deps=json_decode($proposal->dependencies,true);$state=$choice['decision'];$source=$proposal->source_receipt_id;
|
||||
if($state==='approved'){
|
||||
DB::table('users')->where('id',$proposal->actor_id)->lockForUpdate()->firstOrFail();$this->permissions->require($proposal->actor_id,$s['tenant_id'],$s['company_id'],'cash.prepare',$s['unit_id']);abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$proposal->actor_id])->lockForUpdate()->first(),403);
|
||||
abort_unless($deps['source']===$basis['source'],409,'Origem alterada desde a preparação.');
|
||||
if($kind==='open')abort_if($basis['open'],409);else abort_unless($basis['open'],409);
|
||||
if($kind==='receipt'){abort_if((clone$q)->where(['kind'=>'receipt','action'=>'review','state'=>'approved'])->exists(),409);$change=100000;}
|
||||
if($kind==='expense'){abort_unless($basis['balance_minor']>=5000,409,'Saldo insuficiente.');abort_if((clone$q)->where(['kind'=>'expense','action'=>'review','state'=>'approved'])->exists(),409);$change=-5000;}
|
||||
if($kind==='count')abort_unless($deps['movements_hash']===$basis['movements_hash']&&$data['counted_minor']===$basis['balance_minor'],409,'Contagem divergente ou movimentos alterados; sem ajuste automático.');
|
||||
}
|
||||
}else{
|
||||
abort_unless($basis['received']&&$basis['expense_recorded']&&$basis['balance_minor']===95000,409,'Complete a classificação1000, despesa50 e contagem950 deste recorte QA.');
|
||||
abort_unless($kind==='count'&&$d['data']===[]&&$basis['open'],422);$proposal=(clone$q)->where(['id'=>$d['proposal_id']??0,'kind'=>'count','action'=>'review','state'=>'approved'])->firstOrFail();$count=(clone$q)->where('id',$proposal->proposal_id)->firstOrFail();abort_if($count->actor_id===$actor,403,'Quem contou não pode fechar.');$data=json_decode($proposal->data,true);$deps=json_decode($proposal->dependencies,true);abort_unless($data['counted_minor']===$basis['balance_minor']&&$deps['movements_hash']===$basis['movements_hash'],409,'Movimentos mudaram após a contagem.');
|
||||
abort_if((clone$q)->where('action','prepare')->whereNotIn('id',(clone$q)->where('action','review')->whereNotNull('proposal_id')->select('proposal_id'))->exists(),409,'Decisões pendentes impedem o fecho.');
|
||||
foreach((clone$q)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->get(['id','kind']) as $fact){$type=$fact->kind==='receipt'?'cash.receipt_classified':'cash.expense_confirmed';abort_unless(SourceBook::table('gl_journals')->where($s)->where('snapshot->source->effect_type',$type)->where('snapshot->source->source_id',$fact->id)->exists(),409,'Valorizações de Caixa pendentes no diário.');}$state='closed';
|
||||
}
|
||||
$id=DB::table('cash_events')->insertGetId($s+['session_key'=>$b['session_key'],'version'=>$basis['revision']+1,'action'=>$d['action'],'kind'=>$kind,'state'=>$state,'proposal_id'=>$proposal?->id,'source_receipt_id'=>$source,'actor_id'=>$actor,'data'=>$this->json($data),'dependencies'=>$this->json($basis+['period'=>$period]),'reason'=>$d['reason'],'created_at'=>now()]);
|
||||
return ['id'=>$id,'version'=>$basis['revision']+1,'state'=>$state,'impact'=>['action'=>$d['action'],'kind'=>$kind,'state'=>$state,'before_minor'=>$basis['balance_minor'],'after_minor'=>$basis['balance_minor']+$change,'change_minor'=>$change,'counted_minor'=>$data['counted_minor']??null,'money_real'=>false,'new_invoice_or_receipt'=>false,'journal_posted'=>false]];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);return DB::transaction(function()use($actor,$input,$s){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$d=$this->exact($input,['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','kind'=>'sometimes|in:open,receipt,expense,count','from'=>'sometimes|date_format:Y-m-d','to'=>'sometimes|date_format:Y-m-d']);abort_if(isset($d['from'],$d['to'])&&$d['from']>$d['to'],422);$b=$this->binding();$q=$this->events($s,$b);foreach(['kind'=>'kind'] as $k=>$column)if(isset($d[$k]))$q->where($column,$d[$k]);if(isset($d['from']))$q->where('data->date','>=',$d['from']);if(isset($d['to']))$q->where('data->date','<=',$d['to']);
|
||||
$basis=null;$blocked=null;try{$basis=$this->basis($s,$b);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){if($e->getStatusCode()!==409)throw $e;$blocked=$e->getMessage();}
|
||||
$p=$q->orderByDesc('version')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$decisions=$this->events($s,$b)->whereIn('proposal_id',$p->pluck('id')->all())->where('action','review')->get(['id','proposal_id','state'])->keyBy('proposal_id');$commandReferences=DB::table('cash_commands')->where($s)->where('actor_id',$actor)->whereIn('result->id',$p->pluck('id')->all())->get(['command_key','result'])->keyBy(fn($r)=>json_decode($r->result,true)['id']);foreach($p->items() as $row){$row->command_reference=$commandReferences->get($row->id)?->command_key;$row->data=json_decode($row->data,true);$row->dependencies=json_decode($row->dependencies,true);$row->decision=$decisions->get($row->id);}$caps=[];foreach(['prepare','review','close'] as $r)$caps[$r]=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'cash.'.$r,$s['unit_id']);
|
||||
$period=null;if($basis!==null){$from=$d['from']??'0001-01-01';$to=$d['to']??'9999-12-31';$period=(array)$this->events($s,$b)->where(['action'=>'review','state'=>'approved'])->whereIn('kind',['receipt','expense'])->selectRaw("coalesce(sum(CASE WHEN data->>'date' < ? THEN CASE kind WHEN 'receipt' THEN (data->>'amount_minor')::bigint ELSE -(data->>'amount_minor')::bigint END ELSE 0 END),0) opening_minor,coalesce(sum((data->>'amount_minor')::bigint) FILTER(WHERE kind='receipt' AND data->>'date'>=? AND data->>'date'<=?),0) receipts_minor,coalesce(sum((data->>'amount_minor')::bigint) FILTER(WHERE kind='expense' AND data->>'date'>=? AND data->>'date'<=?),0) expenses_minor",[$from,$from,$to,$from,$to])->first();foreach($period as $k=>$v)$period[$k]=(int)$v;$period['closing_minor']=$period['opening_minor']+$period['receipts_minor']-$period['expenses_minor'];}
|
||||
return ['basis'=>$basis,'blocked'=>$blocked,'events'=>$p,'period'=>$period,'binding'=>array_intersect_key($b,array_flip(['session_key','receipt_id','invoice_id','amount_minor','expense_minor'])),'capabilities'=>$caps,'actor_id'=>$actor,'simulation_only'=>true];
|
||||
});
|
||||
}
|
||||
public function commandReceipt(int $actor,string $key):array
|
||||
{
|
||||
$s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$row=DB::table('cash_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($row,404);$result=json_decode($row->result,true,32,JSON_THROW_ON_ERROR);abort_unless($this->events($s,$this->binding())->where('id',$result['id'])->exists(),403);return ['result'=>$result,'created_at'=>$row->created_at,'command_key'=>$key,'historical_receipt_only'=>true,'operation_executed'=>false];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance;
|
||||
use App\Modules\Accounting\SourceBook;use App\Modules\Services\DocumentPeriod;
|
||||
/** Bounded QA projection, with original Cash authority and native composition checks. */
|
||||
class FinancialIndicatorSource
|
||||
{
|
||||
public function snapshot(int $actor,array $scope):array
|
||||
{
|
||||
$cash=app(CashWorkspace::class)->listing($actor,['from'=>'2026-11-10','to'=>'2026-11-10','per_page'=>25]);abort_unless(!$cash['blocked']&&$cash['basis']&&$cash['basis']['closed']&&$cash['period'],409,'Caixa não conciliada/fechada ou origem alterada.');$period=DocumentPeriod::open($scope);$balances=[];
|
||||
foreach(['QA-CAIXA','QA-FORNECEDOR']as$code){$a=SourceBook::table('gl_accounts')->where($scope)->where('code',$code)->first();abort_unless($a&&$a->currency==='MZN',409,'Conta/moeda da fonte QA não conferida.');$q=SourceBook::table('gl_lines')->join('gl_journals as j',function($join){$join->on('j.id','=','gl_lines.journal_id');foreach(['tenant_id','company_id','unit_id']as$k)$join->on('j.'.$k,'=','gl_lines.'.$k);})->where(array_combine(array_map(fn($k)=>'gl_lines.'.$k,array_keys($scope)),array_values($scope)))->where('gl_lines.account_id',$a->id)->where('j.journal_on','<=','2026-11-10');$r=$q->selectRaw('coalesce(sum(gl_lines.debit_minor-gl_lines.credit_minor),0)::text net_debit_minor,count(*)::int line_count,max(gl_lines.id) last_line_id')->first();$balances[$code]=['account_id'=>$a->id,'code'=>$a->code,'currency'=>$a->currency]+(array)$r;}
|
||||
abort_unless($balances['QA-CAIXA']['net_debit_minor']===(string)$cash['period']['closing_minor'],409,'Caixa e diário não conciliam.');$v=['available'=>true,'kind'=>'composed_qa_sources','scope'=>$scope,'currency'=>'MZN','unit'=>'money','as_of'=>'2026-11-10','cash_balance_minor'=>(string)$cash['period']['closing_minor'],'cash_expenses_minor'=>(string)$cash['period']['expenses_minor'],'cash_receipts_minor'=>(string)$cash['period']['receipts_minor'],'supplier_credit_minor'=>(string)\Brick\Math\BigInteger::of($balances['QA-FORNECEDOR']['net_debit_minor'])->negated(),'cash_revision'=>$cash['basis']['revision'],'cash_source_hash'=>$cash['basis']['source'],'cash_session_key'=>$cash['binding']['session_key'],'ledger_accounts'=>$balances,'period'=>$period];$v['hash']=hash('sha256',\Tempo\Finance\Payables\Payables::canonical($v));return $v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance;
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use App\Modules\Identity\Services\{Permissions,Audit};use Illuminate\Support\Facades\{DB,Crypt,Validator};use Brick\Math\{BigRational,RoundingMode};use Tempo\Finance\Payables\Payables;
|
||||
final class FinancialIndicators
|
||||
{
|
||||
public const METRICS=['cash_coverage_single_payable','cash_expense_share'];
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions,private FinancialIndicatorSource $source){}
|
||||
private function json(array $v):string{return Payables::canonical($v);}private function hash(array $v):string{return hash('sha256',$this->json($v));}
|
||||
public static function templates():array{return ['cash_coverage_single_payable'=>['label'=>'Cobertura QA de uma obrigação por Caixa','numerator'=>'cash_balance','denominator'=>'supplier_credit_balance','denominator_direction'=>'credit','currency'=>'MZN','representation'=>'times_2','as_of'=>'2026-11-10'],'cash_expense_share'=>['label'=>'Despesa/entradas da Caixa QA','numerator'=>'cash_expenses','denominator'=>'cash_receipts','denominator_direction'=>'inflow','currency'=>'MZN','representation'=>'percent_2','as_of'=>'2026-11-10']];}
|
||||
public function scope(int $actor,string $right='view'):array
|
||||
{
|
||||
abort_unless(config('analysis_qa.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&ModuleRecords::active()==='finance',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(array_unique(['view',$right])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'financial_analysis.'.$r,$s['unit_id']);return $s;
|
||||
}
|
||||
private function criteria(array $s){return DB::table('an_criteria')->where($s);}
|
||||
private function row(object $r):array{$a=(array)$r;foreach(['definition','input','result','source_snapshot']as$k)if(isset($a[$k]))$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return $a;}
|
||||
private function currentCriteria(array $s){$q=$this->criteria($s);return $q->whereNotExists(function($sub){$sub->selectRaw('1')->from('an_criteria as newer');foreach(['tenant_id','company_id','unit_id','metric']as$k)$sub->whereColumn('newer.'.$k,'an_criteria.'.$k);$sub->whereColumn('newer.version','>','an_criteria.version');});}
|
||||
private function snapshot(int $actor,array $s):array
|
||||
{
|
||||
try{return $this->source->snapshot($actor,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==409)throw $e;$v=['available'=>false,'kind'=>'unavailable','pending'=>[$e->getMessage()],'scope'=>$s];$v['hash']=$this->hash($v);return $v;}
|
||||
}
|
||||
private function basis(int $actor,array $s,array $d):array{$source=$this->snapshot($actor,$s);$q=$this->criteria($s);$latest=isset($d['metric'])?(clone$q)->where('metric',$d['metric'])->orderByDesc('version')->first():null;$selected=isset($d['data']['criterion_id'])?(clone$q)->where('id',$d['data']['criterion_id'])->first():null;return ['source'=>$source,'latest'=>$latest?$this->row($latest):null,'selected'=>$selected?$this->row($selected):null,'criteria_count'=>(clone$q)->count(),'criteria_last_id'=>(int)(clone$q)->max('id')];}
|
||||
private function exact(array $d,array $rules):array{abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo fora dos indicadores aprovados.');return Validator::make($d,$rules)->validate();}
|
||||
private function lock(int $actor,array $s):void
|
||||
{
|
||||
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first(),403);DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
private function right(string $action):string{return $action==='review'?'review':'prepare';}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
abort_if(strlen(json_encode($input))>16384,422);$d=$this->exact($input,['action'=>'required|in:prepare,review,calculate','metric'=>'nullable|required_unless:action,calculate|in:'.implode(',',self::METRICS),'version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:4000']);$d['version']=(int)$d['version'];abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$this->right($d['action']));return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));$b=$this->basis($actor,$s,$d);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$d,$b);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'observed'=>time(),'key'=>$key,'input'=>$d,'basis'=>$this->hash($b),'impact'=>$out['impact']];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
|
||||
}
|
||||
public function confirm(int $actor,string $preview):array
|
||||
{
|
||||
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$c['input'];$s=$this->scope($actor,$this->right($d['action']));return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));abort_unless($c['actor']===$actor&&$c['scope']==$s&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto ou prévia expirados.');$old=DB::table('an_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409);return json_decode($old->result,true)+['replayed'=>true,'historical_receipt_only'=>true];}$b=$this->basis($actor,$s,$d);abort_unless($this->hash($b)===$c['basis'],409,'Critérios, fontes ou versão mudaram. Exige nova conferência.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('an_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('financial_analysis.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'before_revision'=>$d['version'],'reason'=>$d['reason'],'review_hash'=>$b['source']['hash']]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function execute(int $actor,array $s,array $d,array $b):array
|
||||
{
|
||||
if($d['action']==='calculate'){
|
||||
abort_if(!empty($d['metric'])||$d['version']!==0,422);$in=$this->exact($d['data'],['criterion_id'=>'nullable|integer|min:1','as_of'=>'required|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31']);abort_if(isset($in['criterion_id'])&&!$b['selected'],403,'Critério fora do contexto.');$result=$this->evaluate($s,$b['source'],$b['selected'],$in);$id=DB::table('an_results')->insertGetId($s+['actor_id'=>$actor,'criterion_id'=>$b['selected']['id']??null,'input'=>$this->json($in),'result'=>$this->json($result),'source_snapshot'=>$this->json($b['source']),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'result','version'=>0,'state'=>$result['state'],'impact'=>['action'=>'calculate','result'=>$result,'new_financial_effects'=>0,'real_company_criterion'=>false]];
|
||||
}
|
||||
$last=$b['latest'];abort_unless(($last['version']??0)===$d['version'],409,'Versão do critério alterada.');$prepared=$actor;
|
||||
if($d['action']==='prepare'){
|
||||
$template=self::templates()[$d['metric']];$def=$this->exact($d['data'],['numerator'=>'required|string','denominator'=>'required|string','denominator_direction'=>'required|string','currency'=>'required|in:MZN','representation'=>'required|string','as_of'=>'required|in:2026-11-10','scope_description'=>'required|string|min:10|max:2000','evidence'=>'required|string|min:10|max:2000']);foreach($template as$k=>$value)if($k!=='label')abort_unless(($def[$k]??null)===$value,422,'Fontes/direcção/fórmula fora dos dois indicadores QA aprovados.');$def['label']=$template['label'];$state='prepared';
|
||||
}else{
|
||||
$review=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação actual e revisor distinto obrigatórios.');$prepared=$last['prepared_by'];DB::table('users')->where('id',$prepared)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),403);abort_unless(DB::table('permission_grants')->where($s+['user_id'=>$prepared,'permission'=>'financial_analysis.prepare'])->lockForUpdate()->first(),403,'Preparador sem autoridade actual.');abort_unless($last['source_hash']===$b['source']['hash'],409,'Fontes mudaram depois da preparação.');abort_unless($b['source']['available'],409,'Fontes não conciliadas; não podem ser aprovadas por inferência.');$def=$last['definition'];$state=$review['decision'];
|
||||
}
|
||||
$version=$d['version']+1;$id=DB::table('an_criteria')->insertGetId($s+['metric'=>$d['metric'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'definition'=>$this->json($def),'source_hash'=>$b['source']['hash'],'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'criterion','version'=>$version,'state'=>$state,'impact'=>['action'=>$d['action'],'before_version'=>$d['version'],'before_state'=>$last['state']??null,'after_version'=>$version,'state'=>$state,'definition'=>$def,'sources'=>$b['source'],'new_financial_effects'=>0,'real_company_criterion'=>false]];
|
||||
}
|
||||
private function evaluate(array $s,array $source,?array $criterion,array $in):array
|
||||
{
|
||||
$pending=[];$def=$criterion['definition']??null;if(!$criterion)$pending[]='Escolha explícita de critério em falta.';else{if($criterion['state']!=='reviewed'||$this->criteria($s)->where('metric',$criterion['metric'])->orderByDesc('version')->value('id')!==$criterion['id'])$pending[]='Critério não revisto ou versão antiga.';if($criterion['source_hash']!==$source['hash'])$pending[]='Fontes mudaram; exige nova conferência do critério.';}
|
||||
if(!$source['available'])$pending[]='Fonte ausente/não conciliada; não é zero.';if(($source['currency']??null)!=='MZN')$pending[]='Moeda incompatível ou desconhecida.';if(($source['unit']??null)!=='money')$pending[]='Dimensão incompatível.';if($in['as_of']!=='2026-11-10'||($source['as_of']??null)!==$in['as_of'])$pending[]='Retrato temporal não disponível para essa data.';
|
||||
$num=null;$den=null;if($criterion&&$source['available']){if($criterion['metric']==='cash_coverage_single_payable'){$num=$source['cash_balance_minor']??null;$den=$source['supplier_credit_minor']??null;}else{$num=$source['cash_expenses_minor']??null;$den=$source['cash_receipts_minor']??null;}if($num===null||$den===null)$pending[]='Numerador/denominador sem fonte conhecida.';elseif(BigRational::of($den)->isLessThanOrEqualTo(0))$pending[]='Denominador nulo ou sem direcção positiva/credora comprovada.';elseif(BigRational::of($num)->isLessThan(0))$pending[]='Numerador com sinal fora do critério aprovado.';}
|
||||
if($pending)return ['state'=>'pending','pending'=>$pending,'value'=>null,'exact_fraction'=>null,'numerator_minor'=>null,'denominator_minor'=>null,'synthetic_only'=>true,'real_company_criterion'=>false,'previous_period'=>null,'trend'=>null];
|
||||
$ratio=BigRational::of($num)->dividedBy($den)->simplified();$percent=$criterion['metric']==='cash_expense_share';$represented=$ratio->multipliedBy($percent?100:1);$display=$represented->toScale(2,RoundingMode::Down);return ['state'=>'determined_qa','label'=>$def['label'],'metric'=>$criterion['metric'],'criterion_id'=>$criterion['id'],'criterion_version'=>$criterion['version'],'as_of'=>$in['as_of'],'numerator_minor'=>(string)$num,'denominator_minor'=>(string)$den,'currency'=>'MZN','exact_fraction'=>(string)$ratio,'value'=>(string)$display,'unit'=>$percent?'percent':'times','representation_difference_fraction'=>(string)$represented->minus(BigRational::of((string)$display))->simplified(),'formula'=>$percent?'despesa / entradas ×100':'Caixa / saldo credor QA-FORNECEDOR','source_hash'=>$source['hash'],'previous_period'=>null,'trend'=>null,'notes'=>['Indicador limitado ao exercício QA; não rácio geral de liquidez/margem ou recomendação de pagamento.','Sem série anterior validada; não se apresenta variação zero.'],'pending'=>[],'synthetic_only'=>true,'real_company_criterion'=>false];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=$this->exact($input,['view'=>'sometimes|in:criteria,history,results','metric'=>'sometimes|in:'.implode(',',self::METRICS),'state'=>'sometimes|in:prepared,reviewed,refused','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$view=$d['view']??'criteria';abort_if($view==='history'&&!isset($d['metric']),422);abort_if($view==='results'&&array_intersect(array_keys($d),['metric','state','search']),422);
|
||||
return DB::transaction(function()use($actor,$s,$d,$view){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$view==='results'?DB::table('an_results')->where($s):($view==='history'?$this->criteria($s):$this->currentCriteria($s));if(isset($d['metric']))$q->where('metric',$d['metric']);if(isset($d['state']))$q->where('state',$d['state']);if(!empty($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($w)=>$w->whereRaw("definition->>'label' ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("definition->>'evidence' ILIKE ? ESCAPE '!'",[$needle]));}$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$page->setCollection($page->getCollection()->map(fn($r)=>$this->row($r)));return ['templates'=>self::templates(),'view'=>$view,'records'=>$page->toArray(),'metric_version'=>isset($d['metric'])?(int)$this->criteria($s)->where('metric',$d['metric'])->max('version'):null,'source'=>$this->snapshot($actor,$s),'capabilities'=>array_combine(['prepare','review'],array_map(fn($r)=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'financial_analysis.'.$r,$s['unit_id']),['prepare','review'])),'real_company_criterion'=>false];});
|
||||
}
|
||||
public function detail(int $actor,int $id,string $type='criterion'):array
|
||||
{
|
||||
$s=$this->scope($actor);$row=DB::table($type==='result'?'an_results':'an_criteria')->where($s)->where('id',$id)->first();abort_unless($row,404);$a=$this->row($row);if($type==='criterion')$a['current_version']=(int)$this->criteria($s)->where('metric',$a['metric'])->max('version');else{$source=$this->snapshot($actor,$s);$criterion=$a['criterion_id']?$this->criteria($s)->where('id',$a['criterion_id'])->first():null;$a['current_evaluation']=$this->evaluate($s,$source,$criterion?$this->row($criterion):null,$a['input']);$a['historical_snapshot_unchanged']=true;}return $a;
|
||||
}
|
||||
public function receipt(int $actor,string $key):array{$s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$r=DB::table('an_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'created_at'=>$r->created_at,'historical_receipt_only'=>true,'operation_executed'=>false];}
|
||||
public function report(int $actor,int $id):array
|
||||
{
|
||||
$r=$this->detail($actor,$id,'result');$v=$r['result'];$fp=fopen('php://temp','w+');$rows=[['INDICADOR INTERNO QA — NAO E RACIO GERAL OU RECOMENDACAO'],['Estado historico',$v['state']],['Estado actual',$r['current_evaluation']['state']],['Data do retrato',$r['input']['as_of']],['Indicador',$v['label']??'Indeterminado'],['Numerador MZN',isset($v['numerator_minor'])?(string)\Brick\Math\BigDecimal::of($v['numerator_minor'])->dividedBy('100',2):'INDETERMINADO'],['Denominador MZN',isset($v['denominator_minor'])?(string)\Brick\Math\BigDecimal::of($v['denominator_minor'])->dividedBy('100',2):'INDETERMINADO'],['Fraccao exacta',$v['exact_fraction']??'INDETERMINADO'],['Representacao',$v['value']??'INDETERMINADO'],['Unidade',$v['unit']??'INDETERMINADO'],['Versao do criterio',(string)($v['criterion_version']??0)],['Serie anterior','INDETERMINADA'],['Tendencia','INDETERMINADA']];foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);$s=$this->scope($actor);Audit::record('financial_analysis.report_downloaded',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>0,'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance\Http;
|
||||
use App\Modules\Finance\BankReconciliation;use Illuminate\Http\Request;
|
||||
final class BankReconciliationController
|
||||
{
|
||||
private function response(\Closure $fn){try{$out=$fn();return $out instanceof \Symfony\Component\HttpFoundation\Response?$out:response()->json($out)->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){$state=$e->errorInfo[0]??null;if($state==='23505')return response()->json(['message'=>'Linha/referência já utilizada; confira a versão actual.'],409);if(!in_array($state,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Conferência ocupada. Recupere o mesmo pedido; versão alterada exige nova conferência.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,BankReconciliation $b){return $this->response(fn()=>$b->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,BankReconciliation $b){return $this->response(fn()=>$b->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,BankReconciliation $b){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$b->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,BankReconciliation $b,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$b->receipt($r->user()->id,strtolower($key)));}
|
||||
public function screen(Request $r,BankReconciliation $b){abort_if($r->query()!==[],422);$b->scope($r->user()->id);return response()->view('finance.bank',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
|
||||
public function report(Request $r,BankReconciliation $b){abort_if($r->query()!==[],422);return $this->response(function()use($r,$b){$data=$b->report($r->user()->id);return response($data['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="conferencia-extracto-QA.csv"')->header('X-Content-SHA256',$data['sha256'])->header('Cache-Control','no-store, private');});}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance\Http;
|
||||
use Illuminate\Http\Request;use App\Modules\Finance\CashWorkspace;
|
||||
final class CashController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Caixa ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,CashWorkspace $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
|
||||
public function receipt(Request $r,CashWorkspace $s,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$s->commandReceipt($r->user()->id,strtolower($key)));}
|
||||
public function screen(Request $r,CashWorkspace $s){abort_if($r->query()!==[],422);$s->scope($r->user()->id);return response()->view('finance.cash',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
|
||||
public function preview(Request $r,CashWorkspace $s){return $this->response(fn()=>$s->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,CashWorkspace $s){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$s->confirm($r->user()->id,$d['preview']));}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Modules\Finance\Http;
|
||||
use App\Modules\Finance\FinancialIndicators;use Illuminate\Http\Request;
|
||||
final class FinancialIndicatorsController
|
||||
{
|
||||
private function response(\Closure $fn){try{$out=$fn();return $out instanceof \Symfony\Component\HttpFoundation\Response?$out:response()->json($out)->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Critérios/indicadores ocupados. Recupere o mesmo pedido; versão alterada exige nova conferência.'],503)->header('Retry-After','5');}}
|
||||
public function screen(Request $r,FinancialIndicators $a){abort_if($r->query()!==[],422);$a->scope($r->user()->id);return response()->view('finance.indicators',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
|
||||
public function index(Request $r,FinancialIndicators $a){return $this->response(fn()=>$a->listing($r->user()->id,$r->query()));}
|
||||
public function detail(Request $r,FinancialIndicators $a,int $id){$d=$r->validate(['type'=>'sometimes|in:criterion,result']);abort_if(array_diff(array_keys($r->query()),['type']),422);return $this->response(fn()=>$a->detail($r->user()->id,$id,$d['type']??'criterion'));}
|
||||
public function preview(Request $r,FinancialIndicators $a){return $this->response(fn()=>$a->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,FinancialIndicators $a){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$a->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,FinancialIndicators $a,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$a->receipt($r->user()->id,strtolower($key)));}
|
||||
public function report(Request $r,FinancialIndicators $a,int $id){abort_if($r->query()!==[],422);return $this->response(function()use($r,$a,$id){$data=$a->report($r->user()->id,$id);return response($data['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="indicador-QA-'.$id.'.csv"')->header('X-Content-SHA256',$data['sha256'])->header('Cache-Control','no-store, private');});}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\HumanResources\Http;
|
||||
use App\Modules\HumanResources\Personnel;use Illuminate\Http\Request;
|
||||
final class PersonnelController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Conferência de pessoal ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
|
||||
public function index(Request $r,Personnel $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,Personnel $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,Personnel $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,Personnel $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
public function export(Request $r,Personnel $f,int $id){abort_if($r->query()!==[],422);$out=$f->export($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-rh-pessoal-interno-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('Cache-Control','no-store, private');}
|
||||
public function readable(Request $r,Personnel $f,int $id){abort_if($r->query()!==[],422);$out=$f->readable($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-rh-resumo-legivel-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('X-Source-SHA256',$out['source_sha256'])->header('X-SGF-Read-Format',$out['format'])->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\HumanResources\Http;
|
||||
use App\Modules\HumanResources\Remunerations;use Illuminate\Http\Request;
|
||||
final class RemunerationsController
|
||||
{
|
||||
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Processamento de remunerações ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
|
||||
public function index(Request $r,Remunerations $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,Remunerations $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,Remunerations $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
|
||||
public function receipt(Request $r,Remunerations $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
public function export(Request $r,Remunerations $f,int $id){abort_if($r->query()!==[],422);$out=$f->export($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-remuneracao-interna-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
namespace App\Modules\HumanResources;
|
||||
use App\Modules\Identity\Services\{Audit,Permissions};
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\{CompositionRuntime,HumanResourcesProfiles};
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
/** Versioned administrative facts only. No payroll/payment/ledger effects. */
|
||||
final class Personnel
|
||||
{
|
||||
public const POLICY='SGF-HR-PERSONNEL-QA-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}
|
||||
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
|
||||
private function area(string $kind):string{return match($kind){'contract'=>'contracts','entitlement','leave'=>'leave','day','report'=>'attendance',default=>abort(422)};}
|
||||
public function scope(int $actor,string $kind,string $action='view'):array{
|
||||
abort_unless(config('hr_personnel.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='human_resources',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);HumanResourcesProfiles::assertActor($actor,$s,$action);
|
||||
foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($kind).'.'.$r,$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.view',$s['unit_id']);
|
||||
if($kind==='report')foreach(['contracts','leave']as$a)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$a.'.view',$s['unit_id']);
|
||||
$p=json_decode(file_get_contents(base_path('docs/integration/sgf-human-resources-personnel-access-proposal.json')),true);abort_unless(($p['approved']??false)===true&&($p['authorization']['decision']??null)==='Autorizar o bloco e as30 concessões QA apresentadas'&&hash_equals((string)config('hr_personnel.access_sha256'),$this->hash(['scope'=>$p['scope'],'grants'=>$p['grants_per_book']])),503,'Acesso de pessoal não conferido.');$m=DB::table('hp_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('hr_personnel.schema_sha256')),503,'Schema de pessoal não conferido.');return$s;
|
||||
}
|
||||
private function identity(int $actor,array $s):string{$u=DB::table('users')->where('id',$actor)->first(['id','name','email']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);}
|
||||
private function lock(int $actor,array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();}
|
||||
private function rows(array $s){return DB::table('hp_versions')->where($s);}
|
||||
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$k)$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return$a;}
|
||||
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
|
||||
private function effective(array $s,string $kind){return$this->rows($s)->where('kind',$kind)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('hp_versions as newer');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('newer.'.$k,'hp_versions.'.$k);$n->where('newer.state','approved')->whereColumn('newer.version','>','hp_versions.version');});}
|
||||
private function personQuery($q,string $worker){return$q->whereRaw("data->'input'->>'worker_key'=?",[$worker]);}
|
||||
private function worker(array $s,string $key):array{$q=DB::table('rm_versions')->where($s)->where(['record_key'=>$key,'kind'=>'worker']);$last=(clone$q)->orderByDesc('version')->first();$a=(clone$q)->where('state','approved')->orderByDesc('version')->first();abort_unless($a&&$last,409,'Escolha um cadastro RH revisto deste âmbito.');$data=json_decode($a->data,true);return['record_key'=>$key,'latest_id'=>$last->id,'approved_id'=>$a->id,'version'=>$a->version,'code'=>$data['input']['code'],'status'=>$data['status']];}
|
||||
private function input(array $v):array{
|
||||
if(is_string($v['reason']??null))$v['reason']=trim($v['reason']);$d=$this->exact($v,['kind'=>'required|in:contract,day,entitlement,leave,report','action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];$d['record_key']=strtolower($d['record_key']);
|
||||
if($d['action']==='review'){$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;}
|
||||
$base=['operation'=>'required|in:establish,amend,terminate,cancel,take','worker_key'=>'required|uuid','evidence'=>'required|string|min:10|max:1000'];$date='required|date_format:Y-m-d|after_or_equal:2000-01-01|before_or_equal:2100-12-31';
|
||||
$rules=match($d['kind']){
|
||||
'contract'=>['reference'=>'required|string|regex:/^QA-[A-Z0-9-]{2,60}$/D','mode'=>'required|in:fixed,open,unknown_end','valid_from'=>$date,'valid_to'=>'present|nullable|date_format:Y-m-d|after_or_equal:valid_from','gross'=>'required|string|regex:/^[0-9]{1,9}\.[0-9]{2}$/D'],
|
||||
'day'=>['contract_key'=>'required|uuid','date'=>$date,'reference_on'=>$date,'planned'=>'present|nullable|array|max:8','actual'=>'present|nullable|array|max:8','leave_conferred'=>'required|boolean'],
|
||||
'entitlement'=>['contract_key'=>'required|uuid','valid_from'=>$date,'valid_to'=>$date.'|after_or_equal:valid_from','minutes'=>'present|nullable|integer|min:0|max:527040'],
|
||||
'leave'=>['contract_key'=>'required|uuid','entitlement_key'=>'required|uuid','day_key'=>'required|uuid','date'=>$date,'reference_on'=>$date,'intervals'=>'required|array|min:1|max:8'],
|
||||
'report'=>['period'=>'required|date_format:Y-m']};
|
||||
$d['data']=$this->exact($d['data'],$base+$rules);foreach(['worker_key','contract_key','entitlement_key','day_key']as$k)if(isset($d['data'][$k]))$d['data'][$k]=strtolower($d['data'][$k]);$op=$d['data']['operation'];abort_if($op==='take'&&$d['kind']!=='leave'||$op==='terminate'&&$d['kind']!=='contract',422);
|
||||
if($d['kind']==='contract'){abort_unless((int)str_replace('.','',$d['data']['gross'])>0,422);abort_unless(($d['data']['mode']==='fixed')===($d['data']['valid_to']!==null),422,'Prazo exige fim explícito; sem termo/fim desconhecido conserva fim nulo.');}
|
||||
if($d['kind']==='day'){foreach(['planned','actual']as$k)if($d['data'][$k]!==null)$d['data'][$k]=PersonnelTime::intervals($d['data'][$k]);$d['data']['leave_conferred']=(bool)$d['data']['leave_conferred'];}
|
||||
if($d['kind']==='leave')$d['data']['intervals']=PersonnelTime::intervals($d['data']['intervals']);if($d['kind']==='entitlement'&&$d['data']['minutes']!==null)$d['data']['minutes']=(int)$d['data']['minutes'];return$d;
|
||||
}
|
||||
private function basis(int $actor,array $s,array $d):array{
|
||||
$last=$this->latest($s,$d['record_key']);$effective=$this->latest($s,$d['record_key'],true);$in=$d['action']==='prepare'?$d['data']:($last['data']['input']??[]);abort_unless(isset($in['worker_key']),409);$worker=$this->worker($s,$in['worker_key']);$facts=$this->personQuery($this->rows($s),$in['worker_key'])->where('record_key','<>',$d['record_key'])->where('kind','<>','report')->where('state','approved')->selectRaw('count(*) count,coalesce(max(id),0) last_id')->first();$pay=DB::table('rm_versions')->where($s)->where('kind','payroll')->whereRaw("data->'input'->>'worker_key'=?",[$in['worker_key']])->selectRaw('count(*) count,coalesce(max(id),0) last_id')->first();$refs=[];foreach(['contract_key','day_key','entitlement_key']as$k)if(isset($in[$k])){$x=$this->latest($s,$in[$k]);$refs[$k]=['key'=>$in[$k],'latest_id'=>$x['id']??null];}$deps=['worker'=>$worker,'personnel_marker'=>(array)$facts,'referenced_versions'=>$refs,'payroll_marker'=>(array)$pay];return['last'=>$last,'effective'=>$effective,'dependencies'=>$deps,'actor_identity'=>$this->identity($actor,$s)];
|
||||
}
|
||||
private function source(array $s,string $key,string $kind,string $worker):array{$r=$this->latest($s,$key,true);abort_unless($r&&$r['kind']===$kind&&$r['data']['input']['worker_key']===$worker&&$r['data']['status']!=='cancelled',409,'Fonte revista da pessoa/contexto deixou de estar disponível.');return$r;}
|
||||
private function covers(array $contract,string $from,string $to):bool{$d=$contract['data']['input'];return$contract['data']['status']!=='cancelled'&&$d['mode']!=='unknown_end'&&$d['valid_from']<=$from&&($d['valid_to']===null||$d['valid_to']>=$to);}
|
||||
private function leaveForDay(array $s,string $worker,string $date,?string $except=null):array{$q=$this->personQuery($this->effective($s,'leave'),$worker)->whereRaw("data->'input'->>'date'=? AND data->>'status'<>'cancelled'",[$date]);if($except)$q->where('record_key','<>',$except);$ranges=[];foreach($q->cursor()as$r)foreach(json_decode($r->data,true)['input']['intervals']as$range)$ranges[]=$range;abort_if(count($ranges)>64,409,'Mais de64 intervalos: requer consolidação individual dos pedidos.');return$this->union($ranges);}
|
||||
private function union(array $ranges):array{usort($ranges,fn($a,$b)=>$a['from']<=>$b['from']);$out=[];foreach($ranges as$r){$i=count($out)-1;if($i>=0){abort_if($r['from']<$out[$i]['to'],409,'Pedidos de férias sobrepostos.');if($r['from']===$out[$i]['to']){$out[$i]['to']=$r['to'];continue;}}$out[]=$r;}abort_if(count($out)>8,409,'Mais de oito intervalos efectivos num dia.');return$out;}
|
||||
private function balance(array $s,string $entitlement,?string $except=null,?array $candidate=null):array{$e=$this->latest($s,$entitlement,true);abort_unless($e&&$e['kind']==='entitlement'&&$e['data']['status']!=='cancelled',409,'Condição de férias revista obrigatória.');$q=$this->effective($s,'leave')->whereRaw("data->'input'->>'entitlement_key'=?",[$entitlement]);if($except)$q->where('record_key','<>',$except);$tot=$q->selectRaw("coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->first();$reserved=(int)$tot->reserved;$taken=(int)$tot->taken;if($candidate){if($candidate['status']==='reserved')$reserved+=$candidate['minutes'];if($candidate['status']==='taken')$taken+=$candidate['minutes'];}$initial=$e['data']['input']['minutes'];$available=$initial===null?null:$initial-$reserved-$taken;abort_if($available!==null&&$available<0,409,'Saldo de férias insuficiente.');return['entitlement_key'=>$entitlement,'entitlement_version'=>$e['version'],'entitlement_minutes'=>$initial,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$available,'legal_entitlement_validated'=>false,'salary_effect'=>false];}
|
||||
|
||||
private function plan(int $actor,array $s,array $d,array $b):array{
|
||||
$last=$b['last'];$eff=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$eff['data']??null;
|
||||
if($d['action']==='prepare'){$in=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];HumanResourcesProfiles::assertActor($prepared,$s,'prepare');abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador mudou.');foreach(['view','prepare']as$a)$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($d['kind']).'.'.$a,$s['unit_id']);$in=$last['data']['input'];if($d['data']['decision']==='refused')return$this->refusal($s,$d,$b,$prepared,$identity);abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Fontes mudaram desde a preparação. Prepare novamente.');}
|
||||
$op=$in['operation'];$kind=$d['kind'];$worker=$in['worker_key'];if($op==='establish')abort_if($eff,409,'Já revisto. Use correcção neste registo.');else abort_unless($old&&$old['status']!=='cancelled',409,'Operação exige origem efectiva não cancelada.');if($last)abort_unless($worker===$last['data']['input']['worker_key'],409,'Pessoa imutável neste registo.');if($op!=='cancel')abort_unless($b['dependencies']['worker']['status']==='active',409,'Cadastro suspenso.');
|
||||
if(in_array($op,['cancel','take'],true)){foreach($old['input']as$k=>$v)if(!in_array($k,['operation','evidence','reference_on'],true))abort_unless($this->hash(['v'=>$in[$k]??null])===$this->hash(['v'=>$v]),409,'Esta decisão conserva a origem e os valores revistos.');}
|
||||
$status=$op==='cancel'?'cancelled':'active';$result=[];
|
||||
if($kind==='contract'){
|
||||
if($old)foreach(['reference','valid_from']as$k)abort_unless($in[$k]===$old['input'][$k],409,'Referência/início estáveis; não mudar identidade contratual.');
|
||||
abort_if($this->rows($s)->where('kind','contract')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'reference'=?",[$in['reference']])->exists(),409,'Referência já utilizada.');
|
||||
if($op==='terminate'){abort_unless($in['mode']==='fixed'&&$in['valid_to']!==null&&$in['gross']===$old['input']['gross']&&($old['input']['valid_to']===null||$in['valid_to']<=$old['input']['valid_to']),409,'Terminação exige fim explícito sem alterar bruto.');$status='terminated';}
|
||||
if($status!=='cancelled')abort_if($this->personQuery($this->effective($s,'contract'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'valid_from'<=? AND coalesce(data->'input'->>'valid_to','2100-12-31')>=?",[$in['valid_to']??'2100-12-31',$in['valid_from']])->exists(),409,'Contrato efectivo sobreposto ou fim desconhecido.');
|
||||
$dependents=0;foreach(['day','entitlement','leave']as$k){$q=$this->effective($s,$k)->whereRaw("data->'input'->>'contract_key'=? AND data->>'status'<>'cancelled'",[$d['record_key']]);$dependents+=(clone$q)->count();if($status==='cancelled')abort_if((clone$q)->exists(),409,'Há dependentes efectivos; conferir/cancelar individualmente antes do contrato.');else{$lo=$k==='entitlement'?'valid_from':'date';$hi=$k==='entitlement'?'valid_to':'date';abort_if($q->where(function($x)use($in,$lo,$hi){$x->whereRaw("data->'input'->>? < ?",[$lo,$in['valid_from']]);if($in['valid_to']!==null)$x->orWhereRaw("data->'input'->>? > ?",[$hi,$in['valid_to']]);if($in['mode']==='unknown_end')$x->orWhereRaw('true');})->exists(),409,'Alteração exclui datas de dependentes revistos.');}}
|
||||
$bound=DB::table('rm_versions')->where($s)->where('kind','payroll')->where('state','approved')->whereRaw("data->'input'->>'contract_key'=?",[$d['record_key']])->count();if($status==='cancelled')abort_if($bound>0,409,'Processamento contratual revisto exige conferência própria antes de cancelar.');$result=['dependent_records'=>$dependents,'historical_bound_payroll_versions'=>$bound,'mode'=>$in['mode'],'legal_contract_validated'=>false];
|
||||
}else{
|
||||
if($kind!=='report'){$contract=$this->source($s,$in['contract_key'],'contract',$worker);$from=$in['valid_from']??$in['date'];$to=$in['valid_to']??$in['date'];if($op!=='cancel')abort_unless($this->covers($contract,$from,$to),409,'Contrato não cobre a vigência/dia explicitamente.');if($old)abort_unless($in['contract_key']===$old['input']['contract_key'],409,'Contrato de origem imutável.');}
|
||||
if($kind==='day'){
|
||||
if($last)abort_unless($in['date']===$last['data']['input']['date'],409,'Dia imutável.');abort_if($this->personQuery($this->rows($s)->where('kind','day'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'date'=?",[$in['date']])->exists(),409,'Dia já registado; reutilize a origem.');
|
||||
abort_unless($in['reference_on']<=(new \DateTimeImmutable('today',new \DateTimeZone(PersonnelTime::ZONE)))->format('Y-m-d'),422,'Referência futura não comprova declaração.');if($in['actual']!==null)abort_unless($in['date']<=$in['reference_on'],422,'Não declarar trabalho futuro.');$leaves=$this->leaveForDay($s,$worker,$in['date']);if($status==='cancelled')abort_if($leaves!==[],409,'Há férias efectivas neste dia.');$result=PersonnelTime::day($in['date'],$in['planned'],$in['actual'],$in['leave_conferred']?$leaves:null);if($leaves!==[])PersonnelTime::day($in['date'],$in['planned'],$in['actual'],$leaves);
|
||||
}
|
||||
if($kind==='entitlement'){
|
||||
if($op!=='cancel')abort_if($this->personQuery($this->effective($s,'entitlement'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'valid_from'<=? AND data->'input'->>'valid_to'>=?",[$in['valid_to'],$in['valid_from']])->exists(),409,'Condições de férias sobrepostas.');
|
||||
$q=$this->effective($s,'leave')->whereRaw("data->'input'->>'entitlement_key'=? AND data->>'status'<>'cancelled'",[$d['record_key']]);if($op==='cancel')abort_if((clone$q)->exists(),409,'Reservas/gozos efectivos exigem conferência individual primeiro.');else abort_if((clone$q)->where(fn($x)=>$x->whereRaw("data->'input'->>'date'<?",[$in['valid_from']])->orWhereRaw("data->'input'->>'date'>?",[$in['valid_to']]))->exists(),409,'Nova vigência exclui pedido efectivo.');
|
||||
$tot=$q->selectRaw("coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->first();$avail=$in['minutes']===null?null:$in['minutes']-(int)$tot->reserved-(int)$tot->taken;abort_if($avail!==null&&$avail<0,409,'Condição insuficiente para pedidos efectivos.');$result=['entitlement_minutes'=>$in['minutes'],'reserved_minutes'=>(int)$tot->reserved,'taken_minutes'=>(int)$tot->taken,'available_minutes'=>$avail,'legal_entitlement_validated'=>false];
|
||||
}
|
||||
if($kind==='leave'){
|
||||
$ent=$this->source($s,$in['entitlement_key'],'entitlement',$worker);$day=$this->source($s,$in['day_key'],'day',$worker);abort_unless($ent['data']['input']['contract_key']===$in['contract_key']&&$day['data']['input']['contract_key']===$in['contract_key']&&$day['data']['input']['date']===$in['date'],409,'Pedido tem de usar plano/condição do mesmo contrato e dia.');abort_unless($in['date']>=$ent['data']['input']['valid_from']&&$in['date']<=$ent['data']['input']['valid_to'],409,'Pedido fora da condição de férias.');
|
||||
if($last)foreach(['date','entitlement_key','day_key']as$k)abort_unless($in[$k]===$last['data']['input'][$k],409,'Origem e dia imutáveis; corrija os intervalos.');$status=$op==='cancel'?'cancelled':($op==='take'?'taken':($old['status']??'reserved'));if($op==='take')abort_unless($old['status']==='reserved'&&$in['date']<=$in['reference_on'],409,'Só declarar gozo de uma reserva já revista na data aplicável.');abort_unless($in['reference_on']<=(new \DateTimeImmutable('today',new \DateTimeZone(PersonnelTime::ZONE)))->format('Y-m-d'),422);
|
||||
$ranges=$op==='cancel'?[]:$in['intervals'];$others=$this->leaveForDay($s,$worker,$in['date'],$d['record_key']);$union=$this->union([...$others,...$ranges]);abort_unless($day['data']['input']['planned']!==null,409,'Plano explícito obrigatório.');PersonnelTime::day($in['date'],$day['data']['input']['planned'],$day['data']['input']['actual'],$union);$minutes=array_sum(array_map(fn($r)=>$r['to']-$r['from'],$in['intervals']));$balance=$this->balance($s,$in['entitlement_key'],$d['record_key'],['status'=>$status,'minutes'=>$minutes]);abort_unless($balance['available_minutes']!==null||$status==='cancelled',409,'Saldo inicial desconhecido: decisão de férias bloqueada.');$result=['minutes'=>$minutes,'balance_after'=>$balance,'plan_version'=>$day['version'],'contract_version'=>$contract['version'],'legal_leave_validated'=>false];
|
||||
}
|
||||
if($kind==='report'){if($last)abort_unless($in['period']===$last['data']['input']['period'],409,'Período imutável.');abort_if($this->personQuery($this->rows($s)->where('kind','report'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'period'=?",[$in['period']])->exists(),409,'Conferência deste período já tem origem.');$result=$this->period($actor,$s,$worker,$in['period']);}
|
||||
}
|
||||
$data=['input'=>$in,'status'=>$status,'dependencies'=>$b['dependencies'],'result'=>$result,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;if($state==='approved'){$csv=$this->csv($kind,$d['record_key'],$version,$data);$data['artifact_csv']=$csv;$data['artifact_sha256']=hash('sha256',$csv);}
|
||||
$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$kind,'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
|
||||
}
|
||||
|
||||
private function refusal(array $s,array $d,array $b,int $prepared,string $identity):array{
|
||||
$last=$b['last'];$old=$b['effective']['data']??null;$data=$last['data'];unset($data['artifact_csv'],$data['artifact_sha256']);$currentBalance=null;if($d['kind']==='leave'){try{$currentBalance=$this->balance($s,$data['input']['entitlement_key']);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){if($e->getStatusCode()!==409)throw$e;}}$data['refusal_conference']=['current_balance'=>$currentBalance,'observed_dependencies'=>$b['dependencies'],'original_source_changed'=>$this->hash($last['data']['dependencies'])!==$this->hash($b['dependencies']),'proposal_applied'=>false];$version=$d['version']+1;$impact=['before'=>['version'=>$last['version'],'state'=>$last['state'],'effective'=>$old],'after'=>['version'=>$version,'state'=>'refused','proposal'=>$data,'effective'=>$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$d['kind'],'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id'],'action'=>'review','state'=>'refused','prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
|
||||
}
|
||||
public function period(int $actor,array $s,string $worker,string $period):array{
|
||||
$this->scope($actor,'report');$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');$q=$this->personQuery($this->effective($s,'day'),$worker)->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'date'>=? AND data->'input'->>'date'<=?",[$from,$to])->orderByRaw("data->'input'->>'date'");$days=[];$known=0;$unknown=0;$unaccounted=0;$contracts=[];$entitlements=[];
|
||||
foreach($q->cursor()as$r){abort_if(count($days)>=31,409,'Mais de um registo efectivo por dia exige conferência.');$row=$this->row($r);$i=$row['data']['input'];$leaves=$this->leaveForDay($s,$worker,$i['date']);$calc=PersonnelTime::day($i['date'],$i['planned'],$i['actual'],$i['leave_conferred']?$leaves:null);$days[]=['record_key'=>$r->record_key,'version'=>$r->version,'contract_key'=>$i['contract_key'],'calculation'=>$calc];if($calc['unaccounted_minutes']===null)$unknown++;else{$known++;$unaccounted+=$calc['unaccounted_minutes'];}$contracts[$i['contract_key']]=true;}
|
||||
$contractSources=[];foreach(array_keys($contracts)as$key){$c=$this->source($s,$key,'contract',$worker);$contractSources[]=['record_key'=>$key,'version'=>$c['version'],'reference'=>$c['data']['input']['reference'],'status'=>$c['data']['status']];}
|
||||
$entQ=$this->personQuery($this->effective($s,'entitlement'),$worker)->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'valid_from'<=? AND data->'input'->>'valid_to'>=?",[$to,$from]);foreach($entQ->cursor()as$e){abort_if(count($entitlements)>=31,409,'Condições efectivas incompatíveis.');$entitlements[]=$this->balance($s,$e->record_key);}
|
||||
$pay=DB::table('rm_versions')->where($s)->where(['kind'=>'payroll','state'=>'approved'])->whereRaw("data->'input'->>'worker_key'=? AND data->'input'->>'period'=?",[$worker,$period])->whereNotExists(function($n){$n->selectRaw('1')->from('rm_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'rm_versions.'.$k);$n->where('next.state','approved')->whereColumn('next.version','>','rm_versions.version');});$payroll=[];foreach($pay->cursor()as$r){abort_if(count($payroll)>=1,409,'Mais de um processamento efectivo.');$v=json_decode($r->data,true);$payroll[]=['record_key'=>$r->record_key,'version'=>$r->version,'status'=>$v['status'],'net_cents'=>$v['calculation']['net_cents'],'contract_link_proven'=>isset($v['input']['contract_key']),'contract_key'=>$v['input']['contract_key']??null];}
|
||||
return['period'=>$period,'worker'=>$this->worker($s,$worker),'days'=>$days,'contracts'=>$contractSources,'leave_balances'=>$entitlements,'payroll'=>$payroll,'known_days'=>count($days),'known_conferred_days'=>$known,'days_with_unknown_conference'=>$unknown,'other_calendar_days_unknown'=>(int)substr($to,-2)-count($days),'unaccounted_known_minutes'=>$unaccounted,'complete_month_proven'=>false,'basis'=>'explicit_days_only_not_legal_calendar','salary_effect'=>false];
|
||||
}
|
||||
private function csv(string $kind,string $key,int $version,array $data):string{$rows=[['SGF QA - RH INTERNO - NAO E DOCUMENTO LEGAL'],['Tipo',$kind,'Registo',$key,'Versao',$version],['Pessoa ficticia',$data['dependencies']['worker']['code']],['Estado',$data['status']],['Sem salario/pagamento/lancamento automaticos'],['Input',$this->json($data['input'])],['Resultado',$this->json($data['result'])],['Fontes',$this->json($data['dependencies'])]];return implode("\r\n",array_map(fn($r)=>implode(';',array_map(fn($v)=>'"'.str_replace('"','""',"'".(string)$v).'"',$r)),$rows))."\r\n";}
|
||||
private function approvedExportRecord(int $actor,int $id):array{$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];$r=$this->row($this->rows($s)->where(['id'=>$id,'state'=>'approved'])->first());abort_unless($r,403);$this->scope($actor,$r['kind']);$d=$r['data'];abort_unless(hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return$r;}
|
||||
public function export(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);return['bytes'=>$r['data']['artifact_csv'],'sha256'=>$r['data']['artifact_sha256']];}
|
||||
public function readable(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);$bytes=PersonnelReadableCsv::render($r);return['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes),'source_sha256'=>$r['data']['artifact_sha256'],'format'=>PersonnelReadableCsv::VERSION];}
|
||||
public function payrollContract(int $actor,string $key,string $worker,string $period,string $gross):array{$s=$this->scope($actor,'contract');$r=$this->source($s,$key,'contract',$worker);$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');abort_unless($this->covers($r,$from,$to)&&$r['data']['input']['gross']===$gross,409,'Contrato revisto não cobre o mês/bruto escolhidos.');return['record_key'=>$key,'version'=>$r['version'],'id'=>$r['id'],'latest_id'=>$this->latest($s,$key)['id'],'reference'=>$r['data']['input']['reference'],'gross'=>$gross,'valid_from'=>$r['data']['input']['valid_from'],'valid_to'=>$r['data']['input']['valid_to']];}
|
||||
public function listing(int $actor,array $input):array{
|
||||
$d=$this->exact($input,['kind'=>'required|in:contract,day,entitlement,leave,report','worker_key'=>'sometimes|uuid','record_key'=>'sometimes|uuid','period'=>'sometimes|date_format:Y-m','date'=>'sometimes|date_format:Y-m-d','state'=>'sometimes|in:prepared,approved,refused','search'=>'sometimes|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$s=$this->scope($actor,$d['kind']);
|
||||
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$worker=isset($d['worker_key'])?$this->worker($s,strtolower($d['worker_key'])):null;$q=$this->rows($s)->where('kind',$d['kind']);if($worker)$q=$this->personQuery($q,$worker['record_key']);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403);}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('hp_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'hp_versions.'.$k);$n->whereColumn('next.version','>','hp_versions.version');});
|
||||
if(isset($d['period']))$q->whereRaw("coalesce(data->'input'->>'period',substring(data->'input'->>'date' from 1 for 7))=?",[$d['period']]);if(isset($d['date']))$q->whereRaw("data->'input'->>'date'=?",[$d['date']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("(coalesce(data->'input'->>'reference','')||' '||coalesce(data->'dependencies'->'worker'->>'code','')) ILIKE ? ESCAPE '!'",[$needle]);}
|
||||
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->all();$effective=$this->effective($s,$d['kind'])->whereIn('record_key',$keys)->get()->keyBy('record_key');$balances=[];if($d['kind']==='entitlement'&&$keys!==[]){$totals=$this->effective($s,'leave')->whereIn(DB::raw("data->'input'->>'entitlement_key'"),$keys)->selectRaw("data->'input'->>'entitlement_key' balance_key,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->groupByRaw("data->'input'->>'entitlement_key'")->get()->keyBy('balance_key');foreach($effective as$key=>$row){$e=$this->row($row);if($e['data']['status']==='cancelled')continue;$sum=$totals->get($key);$initial=$e['data']['input']['minutes'];$reserved=(int)($sum?->reserved??0);$taken=(int)($sum?->taken??0);$balances[$key]=['effective_entitlement_version'=>$e['version'],'entitlement_minutes'=>$initial,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$initial===null?null:$initial-$reserved-$taken,'basis'=>'current_effective_requests_at_read'];}}
|
||||
$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$a['current_balance']=$balances[$r->record_key]??null;$items[]=$a;}$page->setCollection(collect($items));$area=$this->area($d['kind']);return['policy'=>self::POLICY,'kind'=>$d['kind'],'worker'=>$worker,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.review',$s['unit_id'])],'synthetic_only'=>true,'salary_effect'=>false,'legal_framework_validated'=>false];});
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array{$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);$b=$this->basis($actor,$s,$d);$out=$this->plan($actor,$s,$d,$b);$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($c)),'impact'=>$out['impact']];});}
|
||||
public function confirm(int $actor,string $token):array{
|
||||
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('hp_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('hp_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('hp_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.'.$this->area($d['kind']).'.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason'],'area'=>$this->area($d['kind'])]);return$result+['replayed'=>false];});
|
||||
}
|
||||
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,'report');$old=DB::table('hp_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace App\Modules\HumanResources;
|
||||
/** A human-readable derivation of one immutable approved version, never a new decision. */
|
||||
final class PersonnelReadableCsv
|
||||
{
|
||||
public const VERSION='SGF-HR-READABLE-CSV-v1';
|
||||
private static function time(mixed $value):string{return$value===null?'Desconhecido':sprintf('%dh %02dmin (%d minutos)',intdiv((int)$value,60),(int)$value%60,(int)$value);}
|
||||
public static function render(array $row):string
|
||||
{
|
||||
$d=$row['data'];$i=$d['input'];$r=$d['result'];$states=['active'=>'Activo','terminated'=>'Terminado','cancelled'=>'Cancelado','reserved'=>'Reservado','taken'=>'Gozado'];$titles=['contract'=>'Contrato interno QA','day'=>'Plano e assiduidade QA','entitlement'=>'Condição de férias QA','leave'=>'Pedido e gozo de férias QA','report'=>'Conferência do período QA'];
|
||||
$lines=[['SGF — RECURSOS HUMANOS — RESUMO INTERNO QA'],['Documento',$titles[$row['kind']]],['Natureza','Vista legível derivada da versão conservada; não é documento legal nem nova decisão.'],['Colaborador fictício',$d['dependencies']['worker']['code']],['Registo',$row['record_key']],['Versão revista',$row['version']],['Estado nesta versão',$states[$d['status']]??$d['status']],['Preparação','QA'.$row['prepared_by']],['Autor desta revisão','QA'.$row['actor_id']],['Data da revisão (origem UTC)',$row['created_at']],['Fundamento',$row['reason']],['Referência da evidência',$i['evidence']],['Formato de leitura',self::VERSION],['SHA do CSV original conservado',$d['artifact_sha256']],[]];
|
||||
if($row['kind']==='contract'){$lines=[...$lines,['CONDIÇÕES DO CONTRATO'],['Referência',$i['reference']],['Modalidade interna',['fixed'=>'Prazo explícito','open'=>'Sem termo declarado','unknown_end'=>'Fim desconhecido'][$i['mode']]],['Início',$i['valid_from']],['Fim',$i['valid_to']??'Não confirmado'],['Bruto mensal declarado',$i['gross'].' MZN'],['Registos dependentes observados nesta decisão',$r['dependent_records']],['Validação jurídica','Não demonstrada']];}
|
||||
if($row['kind']==='day'){$lines=[...$lines,['ASSIDUIDADE DO DIA'],['Dia local',$i['date']],['Zona',$r['zone']],['Referência da declaração',$i['reference_on']],['Tempo previsto',self::time($r['planned_minutes'])],['Tempo declarado',self::time($r['declared_minutes'])],['Férias conferidas',self::time($r['leave_minutes'])],['Tempo por conferir',self::time($r['unaccounted_minutes'])],['Fora do plano (sem autorização de horas extra)',self::time($r['outside_plan_minutes'])]];foreach(['planned'=>'Intervalos previstos','actual'=>'Intervalos declarados']as$key=>$title){$lines[]=[$title];if($i[$key]===null)$lines[]=['Informação','Desconhecida'];elseif($i[$key]===[])$lines[]=['Informação','Conjunto explicitamente vazio'];else foreach($i[$key]as$range)$lines[]=['Desde / até (hora local)',sprintf('%02d:%02d',intdiv($range['from'],60),$range['from']%60),sprintf('%02d:%02d',intdiv($range['to'],60),$range['to']%60)];}}
|
||||
if(in_array($row['kind'],['entitlement','leave'],true)){$b=$r['balance_after']??$r;$lines[]=['FÉRIAS — VALORES CONSERVADOS NESTA VERSÃO'];if($row['kind']==='entitlement')$lines[]=['Vigência',$i['valid_from'].' a '.$i['valid_to']];else{$lines[]=['Dia',$i['date']];$lines[]=['Pedido',self::time($r['minutes'])];}$lines=[...$lines,['Orçamento explícito',self::time($b['entitlement_minutes']??null)],['Reservado',self::time($b['reserved_minutes'])],['Gozado',self::time($b['taken_minutes'])],['Disponível',self::time($b['available_minutes'])],['Limite','Orçamento fictício; não comprova direito legal anual. Não é saldo actual se decisões posteriores existirem.']];}
|
||||
if($row['kind']==='report'){$lines=[...$lines,['CONFERÊNCIA DO PERÍODO'],['Período',$r['period']],['Dias com registos',$r['known_days']],['Dias conferidos',$r['known_conferred_days']],['Dias com conferência desconhecida',$r['days_with_unknown_conference']],['Outros dias sem cobertura comprovada',$r['other_calendar_days_unknown']],['Mês completo','Não demonstrado; dias sem registos não são faltas presumidas.'],[],['Dia local','Versão do dia','Previsto','Declarado','Férias conferidas','Por conferir']];foreach($r['days']as$day){$c=$day['calculation'];$lines[]=[$c['date'],$day['version'],self::time($c['planned_minutes']),self::time($c['declared_minutes']),self::time($c['leave_minutes']),self::time($c['unaccounted_minutes'])];}$lines[]=[];$lines[]=['SALDOS DE FÉRIAS NESTA CONFERÊNCIA','Versão da condição','Orçamento','Reservado','Gozado','Disponível'];foreach($r['leave_balances']as$b)$lines[]=['Condição QA',$b['entitlement_version'],self::time($b['entitlement_minutes']),self::time($b['reserved_minutes']),self::time($b['taken_minutes']),self::time($b['available_minutes'])];$lines[]=[];$lines[]=['PROCESSAMENTOS OBSERVADOS','Versão','Líquido interno (centavos)','Ligação contratual comprovada'];foreach($r['payroll']as$p)$lines[]=['Processamento QA',$p['version'],$p['net_cents']??'Indeterminado',$p['contract_link_proven']?'Sim, escolhida na origem':'Não; sem atribuição retroactiva'];}
|
||||
$lines=[...$lines,[],['Limites','Sem salário, dívida, pagamento, lançamento ou submissão automáticos. Sem conformidade legal presumida.'],['Fonte','Apenas esta versão revista. Não consulta nem recalcula o estado actual.']];
|
||||
// Apostrophe protects all free text, including formula prefixes after whitespace. BOM supports local Excel.
|
||||
return "\xEF\xBB\xBF".implode("\r\n",array_map(fn($line)=>implode(';',array_map(fn($cell)=>'"'.str_replace('"','""',"'".(string)$cell).'"',$line)),$lines))."\r\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace App\Modules\HumanResources;
|
||||
/** Pure interval arithmetic. No calendar, legal entitlement, payroll or persistence assumptions. */
|
||||
final class PersonnelTime
|
||||
{
|
||||
public const ZONE='Africa/Johannesburg';
|
||||
public static function intervals(array $input):array
|
||||
{
|
||||
abort_if(count($input)>8,422,'Máximo de oito intervalos por dia.');$result=[];
|
||||
foreach($input as$r){abort_unless(is_array($r)&&count($r)===2&&!array_diff(array_keys($r),['from','to']),422,'Intervalo deve indicar apenas início/fim em minutos locais.');abort_unless(is_int($r['from'])&&is_int($r['to'])&&$r['from']>=0&&$r['from']<1440&&$r['to']>0&&$r['to']<=1440&&$r['from']<$r['to'],422,'Intervalo fora do dia ou sem duração positiva.');$result[]=$r;}
|
||||
usort($result,fn($a,$b)=>$a['from']<=>$b['from']);$out=[];foreach($result as$r){$i=count($out)-1;if($i>=0){abort_if($r['from']<$out[$i]['to'],409,'Intervalos sobrepostos exigem correcção explícita.');if($r['from']===$out[$i]['to']){$out[$i]['to']=$r['to'];continue;}}$out[]=$r;}return$out;
|
||||
}
|
||||
private static function minutes(array $ranges):int{return array_sum(array_map(fn($r)=>$r['to']-$r['from'],$ranges));}
|
||||
private static function intersection(array $a,array $b):int{$sum=0;foreach($a as$x)foreach($b as$y)$sum+=max(0,min($x['to'],$y['to'])-max($x['from'],$y['from']));return$sum;}
|
||||
public static function day(string $date,?array $planned,?array $actual,?array $approvedLeave):array
|
||||
{
|
||||
$zone=new \DateTimeZone(self::ZONE);$day=\DateTimeImmutable::createFromFormat('!Y-m-d',$date,$zone);abort_unless($day&&$day->format('Y-m-d')===$date,422,'Data local inválida.');
|
||||
$p=$planned===null?null:self::intervals($planned);$a=$actual===null?null:self::intervals($actual);$l=$approvedLeave===null?null:self::intervals($approvedLeave);
|
||||
if($a!==null&&$l!==null)abort_if(self::intersection($a,$l)>0,409,'Trabalho e férias sobrepostos exigem nova conferência.');
|
||||
if($p!==null&&$l!==null)abort_unless(self::intersection($p,$l)===self::minutes($l),409,'Férias têm de corresponder a intervalos previstos explicitamente.');
|
||||
$unaccounted=($p===null||$a===null||$l===null)?null:self::minutes($p)-self::intersection($p,$a)-self::intersection($p,$l);
|
||||
$utc=$p===null?null:array_map(fn($r)=>['from'=>$day->modify('+'.$r['from'].' minutes')->setTimezone(new \DateTimeZone('UTC'))->format(DATE_ATOM),'to'=>$day->modify('+'.$r['to'].' minutes')->setTimezone(new \DateTimeZone('UTC'))->format(DATE_ATOM)],$p);
|
||||
return['date'=>$date,'zone'=>self::ZONE,'planned'=>$p,'actual'=>$a,'approved_leave'=>$l,'planned_utc'=>$utc,'planned_minutes'=>$p===null?null:self::minutes($p),'declared_minutes'=>$a===null?null:self::minutes($a),'leave_minutes'=>$l===null?null:self::minutes($l),'outside_plan_minutes'=>($a===null||$p===null)?null:self::minutes($a)-self::intersection($p,$a),'unaccounted_minutes'=>$unaccounted,'salary_effect'=>false,'overtime_authorized'=>false,'legal_classification'=>null];
|
||||
}
|
||||
public static function leaveBalance(?int $entitlement,array $requests):array
|
||||
{
|
||||
abort_if($entitlement!==null&&$entitlement<0,422);$seen=[];$reserved=0;$taken=0;
|
||||
foreach($requests as$r){abort_unless(is_array($r)&&isset($r['key'],$r['minutes'],$r['state'])&&is_string($r['key'])&&$r['key']!==''&&is_int($r['minutes'])&&$r['minutes']>0&&in_array($r['state'],['reserved','taken','cancelled'],true),422);abort_if(isset($seen[$r['key']]),409,'Cada pedido contribui uma única vez pelo estado efectivo.');$seen[$r['key']]=true;if($r['state']==='reserved')$reserved+=$r['minutes'];if($r['state']==='taken')$taken+=$r['minutes'];}
|
||||
$available=$entitlement===null?null:$entitlement-$reserved-$taken;abort_if($available!==null&&$available<0,409,'Saldo de férias insuficiente.');return['entitlement_minutes'=>$entitlement,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$available,'unit'=>'minutes','legal_entitlement_validated'=>false,'salary_effect'=>false];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
namespace App\Modules\HumanResources;
|
||||
use App\Modules\Identity\Services\{Audit,Permissions};
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
/** Explicit artificial payroll only: no legal tables, debt, payment or ledger writer. */
|
||||
final class Remunerations
|
||||
{
|
||||
public const POLICY='SGF-REMUNERATIONS-QA-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}
|
||||
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
|
||||
public function scope(int $actor,string $action='view'):array{
|
||||
abort_unless(config('remunerations.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='human_resources',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.'.$r,$s['unit_id']);
|
||||
\App\Shared\Installation\HumanResourcesProfiles::assertActor($actor,$s,$action);
|
||||
$m=DB::table('rm_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('remunerations.schema_sha256')),503,'Schema de remunerações não conferido.');return$s;
|
||||
}
|
||||
private function identity(int $actor,array $s):string{$u=DB::table('users')->where('id',$actor)->first(['id','name','email']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);}
|
||||
private function lock(int $actor,array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();}
|
||||
private function rows(array $s){return DB::table('rm_versions')->where($s);}
|
||||
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$k)$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return$a;}
|
||||
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
|
||||
private function input(array $v):array{
|
||||
if(is_string($v['reason']??null))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','kind'=>'required|in:worker,rules,payroll','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];$d['record_key']=strtolower($d['record_key']);
|
||||
if($d['action']==='review'){$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;}
|
||||
$common=['operation'=>'required|in:establish,amend,cancel'];
|
||||
$rules=match($d['kind']){
|
||||
'worker'=>['code'=>'required|string|regex:/^QA-[A-Z0-9-]{2,60}$/D','status'=>'required|in:active,suspended'],
|
||||
'rules'=>['label'=>'required|string|min:5|max:100','valid_from'=>'required|date_format:Y-m-d','valid_to'=>'required|date_format:Y-m-d|after_or_equal:valid_from','worker_rate'=>'present|nullable|numeric|min:0|max:100|regex:/^[0-9]{1,3}(?:\.[0-9]{1,4})?$/D','employer_rate'=>'present|nullable|numeric|min:0|max:100|regex:/^[0-9]{1,3}(?:\.[0-9]{1,4})?$/D','retention_rate'=>'present|nullable|numeric|min:0|max:100|regex:/^[0-9]{1,3}(?:\.[0-9]{1,4})?$/D','source'=>'required|string|min:10|max:500'],
|
||||
'payroll'=>['contract_key'=>'sometimes|uuid','worker_key'=>'required|uuid','rules_key'=>'required|uuid','period'=>'required|date_format:Y-m','gross'=>'required|string|regex:/^[0-9]{1,9}\.[0-9]{2}$/D']};
|
||||
$d['data']=$this->exact($d['data'],$common+$rules);if($d['kind']!=='payroll')abort_if($d['data']['operation']==='cancel',422,'Use o estado do cadastro ou uma versão de regras, sem apagar a origem.');
|
||||
if($d['kind']==='payroll'){foreach(['worker_key','rules_key','contract_key']as$k)if(isset($d['data'][$k]))$d['data'][$k]=strtolower($d['data'][$k]);abort_unless((int)str_replace('.','',$d['data']['gross'])>0,422);}
|
||||
return$d;
|
||||
}
|
||||
private function dependency(array $s,string $key,string $kind):array{$last=$this->latest($s,$key);$a=$this->latest($s,$key,true);abort_unless($last&&$a&&$last['kind']===$kind&&$a['kind']===$kind,409,'Origem escolhida sem revisão actual disponível.');return['latest_id'=>$last['id'],'approved_id'=>$a['id'],'version'=>$a['version'],'record_key'=>$key,'data'=>$a['data']];}
|
||||
private function basis(int $actor,array $s,array $d):array{
|
||||
$last=$this->latest($s,$d['record_key']);$effective=$this->latest($s,$d['record_key'],true);$data=$d['action']==='prepare'?$d['data']:($last['data']['input']??[]);$deps=[];
|
||||
if($d['kind']==='payroll'){abort_unless(isset($data['worker_key'],$data['rules_key']),409);$deps=['worker'=>$this->dependency($s,$data['worker_key'],'worker'),'rules'=>$this->dependency($s,$data['rules_key'],'rules')];if(isset($data['contract_key']))$deps['contract']=app(Personnel::class)->payrollContract($actor,$data['contract_key'],$data['worker_key'],$data['period'],$data['gross']);}
|
||||
return['last'=>$last,'effective'=>$effective,'dependencies'=>$deps,'actor_identity'=>$this->identity($actor,$s)];
|
||||
}
|
||||
public static function calculate(string $gross,array $rates):array{
|
||||
$base=\Brick\Math\BigInteger::of(str_replace('.','',$gross));$lines=[];$sum=\Brick\Math\BigInteger::zero();$complete=true;
|
||||
foreach(['worker_rate','employer_rate','retention_rate']as$key){$rate=$rates[$key]??null;if($rate===null){$complete=false;$lines[$key]=['rate'=>null,'cents'=>null,'exact'=>null,'difference'=>null];continue;}$r=\Brick\Math\BigRational::of($rate)->dividedBy(100)->multipliedBy($base);$c=$r->getNumerator()->quotient($r->getDenominator());$lines[$key]=['rate'=>(string)$rate,'cents'=>(string)$c,'exact'=>(string)$r->simplified(),'difference'=>(string)$r->minus($c)->simplified()];if($key!=='employer_rate')$sum=$sum->plus($c);}
|
||||
$net=$complete?$base->minus($sum):null;abort_if($net&&$net->isNegative(),422,'Descontos excedem o bruto.');return['gross_cents'=>(string)$base,'rubrics'=>$lines,'complete'=>$complete,'deductions_cents'=>$complete?(string)$sum:null,'net_cents'=>$net===null?null:(string)$net,'employer_cost_cents'=>$lines['employer_rate']['cents']===null?null:(string)$base->plus($lines['employer_rate']['cents']),'policy'=>'truncate_each_rubric_to_cents_preserve_exact_difference','synthetic_only'=>true,'legal_calculation_validated'=>false];
|
||||
}
|
||||
private function plan(int $actor,array $s,array $d,array $b):array{
|
||||
$last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$effective['data']??null;
|
||||
if($d['action']==='prepare'){$input=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.prepare',$s['unit_id']);$input=$last['data']['input'];abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Cadastro ou regras mudaram. Prepare novamente.');}
|
||||
$op=$input['operation'];if($op==='establish')abort_if($effective,409,'Registo já aprovado. Use correcção.');else abort_unless($old&&$old['status']!=='cancelled',409,'Operação exige registo activo já aprovado.');
|
||||
$result=null;$status='active';$kind=$d['kind'];
|
||||
if($kind==='worker'){$status=$input['status'];if($old)abort_unless($input['code']===$old['input']['code'],409,'Código imutável.');$duplicate=$this->rows($s)->where('kind','worker')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'code'=?",[$input['code']])->exists();abort_if($duplicate,409,'Código já usado; reutilize o cadastro.');}
|
||||
if($kind==='payroll'){
|
||||
$w=$b['dependencies']['worker'];$r=$b['dependencies']['rules'];$begin=$input['period'].'-01';$end=(new \DateTimeImmutable($begin))->modify('last day of this month')->format('Y-m-d');
|
||||
if($op!=='cancel'){abort_unless($w['data']['status']==='active',409,'Cadastro suspenso.');abort_unless($r['data']['input']['valid_from']<=$begin&&$r['data']['input']['valid_to']>=$end,409,'Regras não cobrem o período inteiro.');}
|
||||
if($old){foreach(['worker_key','period']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Pessoa/período imutáveis; corrija neste registo.');}
|
||||
abort_if($this->rows($s)->where('kind','payroll')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'worker_key'=? AND data->'input'->>'period'=?",[$input['worker_key'],$input['period']])->exists(),409,'Pessoa/período já registados. Reutilize a origem.');
|
||||
if($op==='cancel'){foreach(['rules_key','gross']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Cancelamento conserva os valores aprovados.');$result=$old['calculation'];$status='cancelled';}else$result=self::calculate($input['gross'],$r['data']['input']);
|
||||
if($d['action']==='review'&&$d['data']['decision']==='approved'&&$op!=='cancel')abort_unless($result['complete'],409,'Parâmetros ausentes: líquido indeterminado; aprovação completa bloqueada.');
|
||||
}
|
||||
$data=['input'=>$input,'status'=>$status,'dependencies'=>$b['dependencies'],'calculation'=>$result,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;
|
||||
if($kind==='payroll'&&$state==='approved'){$csv=$this->csv($data,$d['record_key'],$version);$data['artifact_csv']=$csv;$data['artifact_sha256']=hash('sha256',$csv);}
|
||||
$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$kind,'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
|
||||
}
|
||||
private function csv(array $d,string $key,int $version):string{
|
||||
$c=$d['calculation'];$rows=[['SGF QA - REMUNERACOES INTERNAS - NAO E RECIBO SALARIAL LEGAL'],['Registo',$key,'Versao',$version],['Estado interno',$d['status']],['Codigo ficticio',"'".$d['dependencies']['worker']['data']['input']['code']],['Periodo',$d['input']['period']],['Origem regras',"'".$d['dependencies']['rules']['data']['input']['source'],'Versao',$d['dependencies']['rules']['version']],['Rubrica','Base em centavos','Percentagem artificial','Valor em centavos','Exacto em centavos','Diferenca em centavos'],['Bruto',$c['gross_cents']]];
|
||||
foreach($c['rubrics']as$k=>$v)$rows[]=[$k,$c['gross_cents'],$v['rate']??'Indeterminado',$v['cents']??'Indeterminado',$v['exact']??'Indeterminado',$v['difference']??'Indeterminado'];$rows[]=['Contrato explicitamente escolhido',isset($d['dependencies']['contract'])?($d['dependencies']['contract']['reference'].' v'.$d['dependencies']['contract']['version']):'Sem ligacao contratual historica comprovada'];$rows[]=['Descontos',$c['deductions_cents']??'Indeterminado'];$rows[]=['Liquido interno',$c['net_cents']??'Indeterminado'];$rows[]=['Custo interno',$c['employer_cost_cents']??'Indeterminado'];return implode("\r\n",array_map(fn($r)=>implode(';',array_map(fn($v)=>'"'.str_replace('"','""',(string)$v).'"',$r)),$rows))."\r\n";
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array{$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['action']);$b=$this->basis($actor,$s,$d);$out=$this->plan($actor,$s,$d,$b);$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($c)),'impact'=>$out['impact']];});}
|
||||
public function confirm(int $actor,string $token):array{
|
||||
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('rm_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('rm_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('rm_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.remunerations.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
|
||||
}
|
||||
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$old=DB::table('rm_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];}
|
||||
public function export(int $actor,int $id):array{$s=$this->scope($actor);$r=$this->row($this->rows($s)->where(['id'=>$id,'kind'=>'payroll','state'=>'approved'])->first());abort_unless($r,403);$d=$r['data'];abort_unless(isset($d['artifact_csv'],$d['artifact_sha256'])&&hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return['bytes'=>$d['artifact_csv'],'sha256'=>$d['artifact_sha256']];}
|
||||
public function listing(int $actor,array $input):array{
|
||||
$s=$this->scope($actor);$d=$this->exact($input,['kind'=>'required|in:worker,rules,payroll','record_key'=>'sometimes|uuid','period'=>'sometimes|date_format:Y-m','state'=>'sometimes|in:prepared,approved,refused','search'=>'sometimes|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
|
||||
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$this->rows($s)->where('kind',$d['kind']);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403);}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('rm_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'rm_versions.'.$k);$n->whereColumn('next.version','>','rm_versions.version');});
|
||||
if(isset($d['period']))$q->whereRaw("data->'input'->>'period'=?",[$d['period']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("coalesce(data->'input'->>'code',data->'input'->>'label',data->'dependencies'->'worker'->'data'->'input'->>'code','') ILIKE ? ESCAPE '!'",[$needle]);}
|
||||
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$a=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('rm_versions as e');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('e.'.$k,'rm_versions.'.$k);$n->where('e.state','approved')->whereColumn('e.version','>','rm_versions.version');})->get()->keyBy('record_key');
|
||||
$items=[];$totals=['gross_cents'=>'0','net_cents'=>'0','employer_cost_cents'=>'0','approved_active_on_page'=>0,'incomplete_on_page'=>0];foreach($page->items()as$r){$row=$this->row($r);$eff=$this->row($a->get($r->record_key));$row['effective']=$eff;$items[]=$row;if($d['kind']==='payroll'&&!isset($d['record_key'])&&$eff&&$eff['data']['status']==='active'){$c=$eff['data']['calculation'];if(!$c['complete'])$totals['incomplete_on_page']++;else{$totals['approved_active_on_page']++;foreach(['gross_cents','net_cents','employer_cost_cents']as$k)$totals[$k]=(string)\Brick\Math\BigInteger::of($totals[$k])->plus($c[$k]);}}}$page->setCollection(collect($items));return['policy'=>self::POLICY,'kind'=>$d['kind'],'records'=>$page->toArray(),'page_totals'=>$totals,'totals_basis'=>'effective_approved_active_payroll_on_this_page_only','capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.review',$s['unit_id'])],'synthetic_only'=>true,'legal_payroll_validated'=>false];});
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ final class RequireCompanyContext
|
||||
|
||||
app(\App\Shared\Context\RequireModuleContext::class)->check($request, $this->context);
|
||||
$response = $next($request);
|
||||
if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments'], true) && $response->isSuccessful()) {
|
||||
if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments','purchasing/internal-requisitions'], true) && $response->isSuccessful()) {
|
||||
$bar = '<nav aria-label="Contexto SGF" style="padding:12px;background:#edf4f3;color:#163e38"><strong>Módulo activo: Compras</strong> · <a href="/" data-sgf-module-back>Voltar à navegação SGF</a></nav><script src="/sgf-module-context.js" defer></script>';
|
||||
$response->setContent(preg_replace('/(<body\b[^>]*>)/i', '$1'.$bar, $response->getContent(), 1));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,36 @@ final class IdentityWorkflow
|
||||
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
|
||||
'token' => $selected['token'], 'grants' => $grants,
|
||||
'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context),
|
||||
'module_id' => $selected['module_id'] ?? null];
|
||||
'module_id' => $selected['module_id'] ?? null,
|
||||
'single_installation'=>app()->environment('budget-testing')&&config('sgf_single_installation.enabled')===true,
|
||||
'geography_enabled'=>app()->environment('budget-testing')&&config('geography.enabled')===true,
|
||||
'analysis_qa_enabled'=>app()->environment('budget-testing')&&config('analysis_qa.enabled')===true,
|
||||
'bank_qa_enabled'=>app()->environment('budget-testing')&&config('bank_qa.enabled')===true,
|
||||
'fiscal_rules_qa_enabled'=>app()->environment('budget-testing')&&config('fiscal_rules_qa.enabled')===true,
|
||||
'multiline_invoices_enabled'=>app()->environment('budget-testing')&&config('multiline_invoices.enabled')===true,
|
||||
'fixed_assets_enabled'=>app()->environment('budget-testing')&&config('fixed_assets.enabled')===true,
|
||||
'internal_statements_enabled'=>app()->environment('budget-testing')&&config('internal_statements.enabled')===true,
|
||||
'hr_personnel_enabled'=>app()->environment('budget-testing')&&config('hr_personnel.enabled')===true,
|
||||
'remunerations_enabled'=>app()->environment('budget-testing')&&config('remunerations.enabled')===true,
|
||||
'fiscal_obligations_enabled'=>app()->environment('budget-testing')&&config('fiscal_obligations.enabled')===true,
|
||||
'fiscal_qa_enabled'=>app()->environment('budget-testing')&&config('fiscal_qa.enabled')===true,
|
||||
'cash_enabled'=>app()->environment('budget-testing')&&config('cash_qa.enabled')===true,
|
||||
'service_documents_enabled'=>app()->environment('budget-testing')&&config('service_documents.enabled')===true,
|
||||
'commercial_quotes_enabled'=>app()->environment('budget-testing')&&config('commercial_quotes.enabled')===true,
|
||||
'workshop_catalogue_enabled'=>app()->environment('budget-testing')&&config('workshop_catalogue.enabled')===true,
|
||||
'workshop_schedule_enabled'=>app()->environment('budget-testing')&&config('workshop_schedule.enabled')===true,
|
||||
'stock'=>['catalogue'=>app()->environment('budget-testing')&&config('stock_catalogue.enabled')===true,'inactive_report'=>app()->environment('budget-testing')&&config('receipt_qa.stock_read_enabled')===true&&config('receipt_qa.stock_inactive_enabled')===true,'completion'=>app()->environment('budget-testing')&&config('stock_completion.enabled')===true,'final'=>app()->environment('budget-testing')&&config('stock_operations.final_enabled')===true,'period_report'=>app()->environment('budget-testing')&&config('receipt_qa.stock_read_enabled')===true&&config('receipt_qa.stock_period_enabled')===true,'operations'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('stock_operations.enabled')===true],
|
||||
'purchasing' => ['internal_requisitions' => app()->environment('budget-testing')
|
||||
&& config('internal_requisitions.qa_enabled') === true,
|
||||
'receipts'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true,
|
||||
'orders'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.orders_enabled')===true,
|
||||
'supplier_obligations'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.obligations_enabled')===true,
|
||||
'supplier_invoices'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.invoices_enabled')===true,
|
||||
'source_requests'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.requests_enabled')===true,
|
||||
'suppliers'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.suppliers_enabled')===true,
|
||||
'sourcing'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.sourcing_enabled')===true],
|
||||
'accounting' => ['single_book'=>app()->environment('budget-testing')&&config('sgf_single_installation.ledger_enabled')===true,'stock_valuation_read' => app()->environment('budget-testing')
|
||||
&& config('stock_ledger.read_sources.'.$selected['tenant_id'].':'.$selected['company_id'].':'.($selected['unit_id']??'')) !== null]];
|
||||
if ($result['context']['module_id'] && !in_array($result['context']['module_id'], array_column($result['context']['modules'], 'id'), true)) {
|
||||
$result['context']['module_id'] = null;
|
||||
$result['context']['token'] = bin2hex(random_bytes(24));
|
||||
|
||||
@@ -119,6 +119,12 @@ final class IndustrialCosts {
|
||||
$id=DB::table('ic_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$lot,'version'=>$r->version,'kind'=>'decision'];
|
||||
});
|
||||
}
|
||||
/** Current owner conference for one lot; no listing universe or writer lock. */
|
||||
public function conference(int $lot,int $actor):array {
|
||||
self::guard();$s=$this->scope($actor,'view');$l=$this->lot($lot,$s);$r=DB::table('ic_results')->where($s)->where('lot_id',$lot)->orderByDesc('version')->first();$snapshot=$r?json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR):null;
|
||||
$ids=array_column(array_filter($snapshot['refs']??[]),'id');$inputs=DB::table('ic_inputs')->where($s)->where('lot_id',$lot)->whereIn('id',$ids)->orderBy('id')->get();$decisions=DB::table('ic_decisions')->whereIn('input_id',$ids)->orderBy('id')->get();$decision=$r?DB::table('ic_decisions')->where('result_id',$r->id)->first():null;
|
||||
return ['lot'=>(array)$l,'product'=>(array)DB::table('ic_products')->where($s)->where('id',$l->product_id)->first(),'result'=>$r?(array)$r:null,'snapshot'=>$snapshot,'decision'=>$decision?(array)$decision:null,'current'=>$r?$this->resultCurrent($r):false,'inputs'=>$inputs->map(fn($i)=>(array)$i)->all(),'input_decisions'=>$decisions->map(fn($i)=>(array)$i)->all()];
|
||||
}
|
||||
public function listing(int $actor): array {
|
||||
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'lots'=>[],'notice'=>self::NOTICE];self::guard();
|
||||
return DB::transaction(function()use($s){
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use App\Modules\Purchasing\BudgetAdapter;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require_once __DIR__.'/../../../packages/inventory-read/bootstrap.php';
|
||||
|
||||
/** Observe the composed receipt book; never borrow its writer or tenant mutex. */
|
||||
final class CompositionStockRead
|
||||
{
|
||||
public function index(Request $r, CompanyContext $context, Permissions $permissions)
|
||||
{
|
||||
abort_unless(config('receipt_qa.stock_read_enabled') === true && config('receipt_qa.enabled') === true,404);
|
||||
abort_unless(app()->environment('budget-testing'),403);
|
||||
BudgetAdapter::guard();
|
||||
$d=$r->validate(['view'=>'sometimes|in:items,warehouses,balances,movements,period,inactive','search'=>'nullable|string|max:100',
|
||||
'warehouse_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000',
|
||||
'per_page'=>'sometimes|integer|in:25,100','snapshot'=>'sometimes|string|max:8192',
|
||||
'as_of'=>'required_if:view,inactive|prohibited_unless:view,inactive|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31','days'=>'required_if:view,inactive|prohibited_unless:view,inactive|integer|min:1|max:3650',
|
||||
'from'=>'required_if:view,period|prohibited_unless:view,period|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31',
|
||||
'to'=>'required_if:view,period|prohibited_unless:view,period|date_format:Y-m-d|after_or_equal:from|before_or_equal:9998-12-31']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
|
||||
$filters=['view'=>$d['view']??'balances','search'=>$d['search']??'',
|
||||
'warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
|
||||
if($filters['view']==='period'){
|
||||
abort_unless(config('receipt_qa.stock_period_enabled')===true,404);
|
||||
$filters+=['from'=>$d['from'],'to'=>$d['to']];
|
||||
}elseif($filters['view']==='inactive'){abort_unless(config('receipt_qa.stock_inactive_enabled')===true,404);$filters+=['as_of'=>$d['as_of'],'days'=>(int)$d['days']];}else abort_if(array_key_exists('from',$d)||array_key_exists('to',$d),422);
|
||||
$host=DB::connection();$c=$host->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$host->getDatabaseName(),$m)
|
||||
&&($c['username']??null)==='ir_composition_'.$m[1]&&preg_match('/^[a-f0-9]{12}$/D',$run)
|
||||
&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??'public')==='public',403);
|
||||
$identity=$host->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->db===$host->getDatabaseName()&&$identity->role===$c['username']&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
$scope=$context->identifiers();$unit=$context->unitId();$user=$context->userId();
|
||||
abort_unless($unit&&$r->user()&&(int)$r->user()->id===$user,403);
|
||||
// Hold only shared host authority locks. Unrelated readers remain concurrent.
|
||||
try {
|
||||
return $host->transaction(function()use($host,$scope,$unit,$user,$permissions,$m,$run,$r,$filters,$d){
|
||||
$host->statement("SET LOCAL lock_timeout='2s'");
|
||||
$host->statement("SET LOCAL statement_timeout='3s'");
|
||||
abort_unless($host->table('users')->where('id',$user)->sharedLock()->first(),403);
|
||||
abort_unless($host->table('company_memberships')->where($scope+['user_id'=>$user])->sharedLock()->first(),403);
|
||||
foreach(['company.view'=>null,'unit.view'=>$unit,'stock.view'=>$unit]as$right=>$u){
|
||||
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'unit_id'=>$u,'permission'=>$right])->sharedLock()->first(),403);
|
||||
$permissions->require($user,$scope['tenant_id'],$scope['company_id'],$right,$u);
|
||||
}
|
||||
$root='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
|
||||
$profile=realpath($root.'/stock-binding.json');
|
||||
abort_unless($profile&&!is_link($root.'/stock-binding.json')&&strtolower(str_replace('\\','/',$profile))===strtolower($root.'/stock-binding.json'),503,'Fonte de stock indisponível.');
|
||||
$p=json_decode(file_get_contents($profile),true,32,JSON_THROW_ON_ERROR);
|
||||
$key=implode(':',[$scope['tenant_id'],$scope['company_id'],$unit,$user]);
|
||||
$binding=$p['bindings'][$key]??null;abort_unless(is_array($binding),403);
|
||||
abort_unless(is_int($binding['source_tenant_id']??null)&&$binding['source_tenant_id']>0
|
||||
&&is_array($binding['warehouse_ids']??null)&&count($binding['warehouse_ids'])>0&&count($binding['warehouse_ids'])<=1000
|
||||
&&!array_filter($binding['warehouse_ids'],fn($id)=>!is_int($id)||$id<1),503,'Fonte de stock incompatível.');
|
||||
$c=$p['connection']??[];
|
||||
abort_unless(($c['driver']??null)==='pgsql'&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439
|
||||
&&($c['database']??null)==='compras_receipts_test_'.$m[1]&&($c['username']??null)==='stock_receipt_read_'.$m[1]
|
||||
&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??null)==='public',403);
|
||||
// Purge avoids retaining an earlier profile/credential after a configuration change.
|
||||
DB::purge('composition_stock');config(['database.connections.composition_stock'=>$c]);$source=DB::connection('composition_stock');
|
||||
$i=$source->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,current_setting('default_transaction_read_only') read_only,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$i->elevated&&$i->db===$c['database']&&$i->role===$c['username']&&$i->cluster==='sgf_modular_regression_'.$run&&$i->read_only==='on',403);
|
||||
$rights=$source->selectOne("SELECT EXISTS(SELECT FROM pg_tables WHERE schemaname='public' AND (has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') OR (tablename NOT IN ('stock_items','warehouses','stock_movements') AND has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'SELECT')))) forbidden,
|
||||
EXISTS(SELECT FROM pg_trigger WHERE tgrelid='public.stock_movements'::regclass AND tgname='composition_stock_append_only' AND tgenabled='O' AND NOT tgisinternal AND (tgtype::int & 26)=26) append_only");
|
||||
abort_if($rights->forbidden,403);abort_unless($rights->append_only,503,'Fonte de stock incompatível.');
|
||||
abort_unless(($binding['schema']??null)==='public'&&($binding['append_only']??false)===true,503);
|
||||
if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
|
||||
$binding['physical_source']=[$i->db,$i->role,$i->cluster];
|
||||
$fingerprint=hash('sha256',json_encode([$scope,$unit,$user,$r->session()->get('company_context.token'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
|
||||
$result=$filters['view']==='inactive' ?(new StockInactiveReport($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null) :($filters['view']==='period'
|
||||
?(new StockPeriodReport($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null)
|
||||
:(new \Tempo\Inventory\InventoryReadSession($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null));
|
||||
$result['capabilities']['receipt_trace']=config('receipt_qa.stock_trace_enabled')===true
|
||||
&&$permissions->has($user,$scope['tenant_id'],$scope['company_id'],'purchasing_receipts.view',$unit);
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
});
|
||||
}catch(\PDOException $e){
|
||||
$state=$e->errorInfo[0]??(string)$e->getCode();
|
||||
if(!str_starts_with($state,'08')&&!in_array($state,['28P01','3D000','42P01','42703','42883','55P03','57014'],true))throw $e;
|
||||
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use App\Modules\Purchasing\ReceiptRuntime;
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,RequisitionWorkflow};
|
||||
|
||||
final class StockCatalogueController
|
||||
{
|
||||
public const POLICY='SGF-STOCK-CATALOGUE-QA-v1';
|
||||
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
|
||||
private function exact(array $v,array $rules):array{abort_if(array_diff(array_keys($v),array_keys($rules)),422);return Validator::make($v,$rules)->validate();}
|
||||
private function q(array $p){return DB::table('sc_catalogue_versions')->where($p['scope']);}
|
||||
private function latest(array $p){return $this->q($p)->whereNotExists(function($q){$q->selectRaw('1')->from('sc_catalogue_versions as newer')->whereColumn('newer.record_key','sc_catalogue_versions.record_key')->whereColumn('newer.tenant_id','sc_catalogue_versions.tenant_id')->whereColumn('newer.company_id','sc_catalogue_versions.company_id')->whereColumn('newer.unit_id','sc_catalogue_versions.unit_id')->whereColumn('newer.version','>','sc_catalogue_versions.version');});}
|
||||
private function run(Request $r,ReceiptRuntime $rt,string $right,\Closure $fn):mixed
|
||||
{
|
||||
abort_unless(config('stock_catalogue.enabled')===true,404);CompositionRuntime::assert();$physical=CompositionRuntime::physical();$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];abort_unless(ModuleRecords::active()==='stock',403);
|
||||
$file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$physical['composition'].'/stock-catalogue-binding.json';$pin=config('stock_catalogue.binding_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);$p=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($p['composition']===$physical['composition']&&$p['scope']===$scope,403);
|
||||
$host=(int)$r->user()->id;$generation=$this->hash($r->session()->get('company_context',[]));
|
||||
try{return $rt->run($right,function($a,$b,$binding)use($p,$host,$generation,$right,$fn){
|
||||
$a=app(CurrentActor::class)->lock($a);abort_if(app(OrganizationAccess::class)->maintenanceOnly($a),403);abort_unless($a->tenant_id===$p['scope']['tenant_id']&&!empty($b['warehouse_ids']),403);
|
||||
if($right!=='stock.view'){$prepare=$right==='stock.items.prepare';abort_unless($host===$p[$prepare?'preparer_host_id':'reviewer_host_id']&&$a->id===$p[$prepare?'preparer_native_id':'reviewer_native_id'],403);}
|
||||
$meta=DB::table('sc_catalogue_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$p['composition']&&hash_equals($meta->source_hash,(string)config('stock_catalogue.schema_sha256')),503,'Schema de catálogo não conferido.');
|
||||
return $fn($a,$p,$host,$generation,$this->hash([$binding,$p]));
|
||||
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Catálogo ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}
|
||||
}
|
||||
private function input(array $v):array
|
||||
{
|
||||
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:2000']);$d['record_key']=strtolower($d['record_key']);$d['version']=(int)$d['version'];
|
||||
if($d['action']==='prepare'){$data=$d['data'];foreach(['code','name','unit']as$key)if(isset($data[$key])&&is_string($data[$key]))$data[$key]=trim($data[$key]);$d['data']=$this->exact($data,['code'=>['required','string','max:30','regex:/^[A-Z0-9][A-Z0-9_.-]{1,29}$/D'],'name'=>'required|string|min:3|max:150','unit'=>['required','string','max:12','regex:/^[A-Z][A-Z0-9]{0,11}$/D']]);}
|
||||
else $d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;
|
||||
}
|
||||
private function basis(array $p,string $key,bool $lock=false):array
|
||||
{
|
||||
$last=$this->q($p)->where('record_key',$key)->orderByDesc('version')->first();$approved=$this->q($p)->where(['record_key'=>$key,'state'=>'approved'])->orderByDesc('version')->first();$item=null;if($approved){$q=DB::table('stock_items')->where(['tenant_id'=>$p['scope']['tenant_id'],'id'=>$approved->stock_item_id]);if($lock)$q->lockForUpdate();$item=$q->first(['id','code','name','unit']);abort_unless($item,409,'Artigo de origem indisponível.');$saved=json_decode($approved->data,true);abort_unless($item->code===$saved['code']&&$item->unit===$saved['unit']&&$item->name===$saved['name'],409,'Catálogo alterado fora desta versão; exige conferência.');}
|
||||
return ['version'=>(int)($last?->version??0),'last'=>$last?(array)$last:null,'approved'=>$approved?(array)$approved:null,'item'=>$item?(array)$item:null];
|
||||
}
|
||||
private function execute(object $a,array $p,int $host,array $d,array $before):array
|
||||
{
|
||||
abort_unless($before['version']===$d['version'],409,'A versão mudou. Confira novamente.');$last=$before['last'];$approved=$before['approved'];$item=$before['item'];$data=$d['data'];$itemId=$item['id']??null;$willCreate=false;$willRename=false;
|
||||
if($d['action']==='prepare'){
|
||||
abort_unless($host===$p['preparer_host_id'],403);$state='prepared';$prepared=$host;$preparedNative=$a->id;
|
||||
if($approved){abort_unless($data['code']===$item['code']&&$data['unit']===$item['unit'],422,'Código e unidade são imutáveis.');abort_if($data['name']===$item['name'],422,'Indique uma alteração de designação.');}
|
||||
else abort_if(DB::table('stock_items')->where(['tenant_id'=>$a->tenant_id,'code'=>$data['code']])->exists(),409,'Código já existente; não associar artigo anterior automaticamente.');
|
||||
}else{
|
||||
abort_unless($last&&$last['state']==='prepared'&&$host!==$last['prepared_host_id']&&$a->id!==$last['prepared_native_id'],403,'Revisão exige preparação alheia actual.');$state=$data['decision'];$data=json_decode($last['data'],true);$prepared=$last['prepared_host_id'];$preparedNative=$last['prepared_native_id'];
|
||||
if($state==='approved'){
|
||||
if($item){abort_unless($data['code']===$item['code']&&$data['unit']===$item['unit'],409);$changed=DB::table('stock_items')->where(['id'=>$itemId,'tenant_id'=>$a->tenant_id,'code'=>$item['code'],'unit'=>$item['unit'],'name'=>$item['name']])->update(['name'=>$data['name'],'updated_at'=>now()->utc()]);abort_unless($changed===1,409);$willRename=true;}
|
||||
else{abort_if(DB::table('stock_items')->where(['tenant_id'=>$a->tenant_id,'code'=>$data['code']])->exists(),409,'Código entretanto utilizado; confira novamente.');$itemId=DB::table('stock_items')->insertGetId(['tenant_id'=>$a->tenant_id,'created_by'=>$a->id,'code'=>$data['code'],'name'=>$data['name'],'unit'=>$data['unit'],'created_at'=>now()->utc(),'updated_at'=>now()->utc()]);$willCreate=true;}
|
||||
}
|
||||
}
|
||||
$version=$before['version']+1;$id=DB::table('sc_catalogue_versions')->insertGetId($p['scope']+['record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_host_id'=>$prepared,'prepared_native_id'=>$preparedNative,'host_actor_id'=>$host,'actor_id'=>$a->id,'stock_item_id'=>$itemId,'data'=>json_encode($data,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],'created_at'=>now()->utc()]);
|
||||
$impact=['before'=>['version'=>$before['version'],'state'=>$last['state']??null,'item'=>$item],'after'=>['version'=>$version,'state'=>$state,'data'=>$data],'creates_article'=>$willCreate,'renames_owned_article'=>$willRename,'stock_movements'=>0,'journal_entries'=>0,'initial_balance'=>null];
|
||||
return ['id'=>$id,'record_key'=>$d['record_key'],'version'=>$version,'state'=>$state,'stock_item_id'=>$itemId,'impact'=>$impact];
|
||||
}
|
||||
public function preview(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$d=$this->input($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$rt,'stock.items.'.($d['action']==='prepare'?'prepare':'review'),function($a,$p,$host,$generation,$binding)use($d,$key){$before=$this->basis($p,$d['record_key'],true);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($a,$p,$host,$d,$before);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$host,'generation'=>$generation,'binding'=>$binding,'input'=>$d,'key'=>$key,'observed'=>time(),'basis'=>$this->hash($before),'impact'=>$out['impact']];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']])->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function confirm(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$body=$this->exact($r->all(),['preview'=>'required|string|max:65536']);try{$cut=json_decode(Crypt::decryptString($body['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($cut)&&is_array($cut['input']??null),409);$d=$this->input($cut['input']);return$this->run($r,$rt,'stock.items.'.($d['action']==='prepare'?'prepare':'review'),function($a,$p,$host,$generation,$binding)use($cut,$d){abort_unless(($cut['actor']??null)===$host&&($cut['generation']??null)===$generation&&($cut['binding']??null)===$binding&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia/contexto alterados.');$scope=$p['scope']+['host_actor_id'=>$host,'command_key'=>$cut['key']];$hash=$this->hash($d);$old=DB::table('sc_catalogue_commands')->where($scope)->first();if($old){abort_unless(hash_equals($old->request_hash,$hash)&&hash_equals($old->binding_hash,$binding),409,'Chave utilizada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true]);}$before=$this->basis($p,$d['record_key'],true);abort_unless(hash_equals($cut['basis'],$this->hash($before)),409,'Catálogo alterado desde a prévia.');$out=$this->execute($a,$p,$host,$d,$before);abort_unless($out['impact']===$cut['impact'],409,'Impacto alterado.');app(RequisitionWorkflow::class)->audit($a,'sgf.stock.catalogue.command',null,$before,['host_actor_id'=>$host,'record_key'=>$d['record_key'],'operation_key'=>$cut['key'],'reason'=>$d['reason'],'after'=>$out]);DB::table('sc_catalogue_commands')->insert($scope+['actor_id'=>$a->id,'request_hash'=>$hash,'binding_hash'=>$binding,'input'=>json_encode($d,JSON_THROW_ON_ERROR),'result'=>json_encode($out,JSON_THROW_ON_ERROR),'created_at'=>now()->utc()]);return response()->json($out+['replayed'=>false],201)->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function receipt(Request $r,ReceiptRuntime $rt,string $key)
|
||||
{
|
||||
abort_if($r->query()!==[],422);$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$rt,'stock.view',function($a,$p,$host,$g,$binding)use($key){$old=DB::table('sc_catalogue_commands')->where($p['scope']+['host_actor_id'=>$host,'command_key'=>$key])->first();abort_unless($old,404);abort_unless(hash_equals($old->binding_hash,$binding),409);return response()->json(['result'=>json_decode($old->result,true),'reason'=>json_decode($old->input,true)['reason'],'created_at'=>$old->created_at,'operation_executed'=>false,'historical_receipt_only'=>true])->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){abort_if($r->query()!==[],422);return$this->run($r,$rt,'stock.view',fn()=>response()->view('stock-catalogue-workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store'));}
|
||||
public function index(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$d=$this->exact($r->query(),['view'=>'sometimes|in:items,proposals,history','search'=>'sometimes|nullable|string|max:100','state'=>'sometimes|in:prepared,approved,refused','record_key'=>'required_if:view,history|prohibited_unless:view,history|uuid','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$hostDb=DB::connection();return$this->run($r,$rt,'stock.view',function($a,$p,$host)use($d,$hostDb){$view=$d['view']??'proposals';$size=(int)($d['per_page']??25);$page=(int)($d['page']??1);if($view==='items'){$approved=$this->q($p)->where('state','approved')->whereNotExists(fn($q)=>$q->selectRaw('1')->from('sc_catalogue_versions as next')->whereColumn('next.record_key','sc_catalogue_versions.record_key')->whereColumn('next.version','>','sc_catalogue_versions.version')->whereColumn('next.tenant_id','sc_catalogue_versions.tenant_id')->whereColumn('next.company_id','sc_catalogue_versions.company_id')->whereColumn('next.unit_id','sc_catalogue_versions.unit_id')->where('next.state','approved'));$q=DB::table('stock_items as i')->where('i.tenant_id',$a->tenant_id)->leftJoinSub($approved,'v','v.stock_item_id','=','i.id')->select(['i.id','i.code','i.name','i.unit','v.record_key','v.version','v.state']);$code='i.code';$name='i.name';$order='i.id';}
|
||||
else{$q=$view==='history'?$this->q($p)->where('record_key',$d['record_key']):$this->latest($p);if($view==='history')abort_unless((clone$q)->exists(),403,'Histórico fora deste contexto.');if(isset($d['state']))$q->where('state',$d['state']);$code="data->>'code'";$name="data->>'name'";$order='id';}
|
||||
if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($v)=>$v->whereRaw("$code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("$name ILIKE ? ESCAPE '!'",[$needle]));}$rows=$q->orderByDesc($order)->paginate($size,['*'],'page',$page);foreach($rows->items()as$row)if(isset($row->data))$row->data=json_decode($row->data,true);return response()->json(['view'=>$view,'records'=>$rows,'capabilities'=>['prepare'=>$host===$p['preparer_host_id']&&$hostDb->table('permission_grants')->where($p['scope']+['user_id'=>$host,'permission'=>'stock.items.prepare'])->exists(),'review'=>$host===$p['reviewer_host_id']&&$hostDb->table('permission_grants')->where($p['scope']+['user_id'=>$host,'permission'=>'stock.items.review'])->exists()],'notice'=>'Artigos existentes sem proveniência deste percurso são apenas consultados. Preparar não cria artigo ou saldo.'])->header('Cache-Control','no-store');});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use App\Modules\Purchasing\ReceiptRuntime;
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Purchasing\Embedded\Models\{SupplyRecord,StockItem,Warehouse};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,SupplyPlanning,InventoryDispositions,StockWorkflow,RequisitionWorkflow};
|
||||
|
||||
/** Explicit QA policy and supplier return commands over the original native services. */
|
||||
final class StockCompletionController
|
||||
{
|
||||
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
|
||||
private function run(Request $r,ReceiptRuntime $rt,string $right,\Closure $fn):mixed
|
||||
{
|
||||
abort_unless(config('stock_completion.enabled')===true,404);$physical=CompositionRuntime::physical();
|
||||
$file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$physical['composition'].'/stock-completion-binding.json';$pin=config('stock_completion.binding_sha256');
|
||||
abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);
|
||||
$p=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);$host=$r->user()->id;$generation=$this->hash($r->session()->get('company_context',[]));
|
||||
abort_unless($p['composition']===$physical['composition']&&$p['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],403);
|
||||
try{return $rt->run($right,function($a,$b,$binding)use($fn,$p,$host,$generation){
|
||||
$a=app(CurrentActor::class)->lock($a);abort_if(app(OrganizationAccess::class)->maintenanceOnly($a),403);
|
||||
$review=$host===$p['reviewer_host_id'];abort_unless(in_array($host,[$p['dispatcher_host_id'],$p['reviewer_host_id']],true)&&$a->id===($review?$p['reviewer_native_id']:$p['dispatcher_native_id'])&&in_array($p['warehouse_id'],$b['warehouse_ids'],true),403);
|
||||
return $fn($a,$p,$host,$generation,$binding,$review);
|
||||
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Operação ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}
|
||||
}
|
||||
private function family(string $family):string{abort_unless(in_array($family,['policy','supplier_return'],true),422);return $family==='policy'?'stock.planning':'stock.disposition';}
|
||||
private function query(array $p,string $family){return app(SupplyPlanning::class)->latest($family==='policy'?'policy':'stock_event')->where('tenant_id',1)->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id']);}
|
||||
private function basis(array $p):array
|
||||
{
|
||||
return ['policy'=>$this->hash($p),'balance'=>app(StockWorkflow::class)->balance($p['stock_item_id'],$p['warehouse_id']),
|
||||
'revision'=>(int)SupplyRecord::where('tenant_id',1)->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id'])->max('id'),
|
||||
'item'=>StockItem::findOrFail($p['stock_item_id'])->only(['id','code','name','unit']),
|
||||
'warehouse'=>Warehouse::findOrFail($p['warehouse_id'])->only(['id','code','name'])];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){return $this->run($r,$rt,'stock.planning.view',fn()=>response()->view('stock-completion-workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store'));}
|
||||
public function index(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$d=$r->validate(['family'=>'required|in:policy,supplier_return','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','state'=>'nullable|in:draft,returned,approved,suspended,executed','record_key'=>'sometimes|uuid']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $this->run($r,$rt,$this->family($d['family']).'.view',function($a,$p,$host,$g,$binding,$review)use($d){
|
||||
abort_unless($d['family']==='policy'?app(SupplyPlanning::class)->allows($a,'supply_read'):app(InventoryDispositions::class)->allows($a,'read'),403);
|
||||
$q=isset($d['record_key'])?SupplyRecord::where('tenant_id',1)->where('kind',$d['family']==='policy'?'policy':'stock_event')->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id'])->where('record_key',$d['record_key']):$this->query($p,$d['family']);if(!empty($d['state']))$q->where('state',$d['state']);
|
||||
$page=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));
|
||||
$basis=$this->basis($p);return response()->json(['basis'=>$basis,'revision'=>$this->hash($basis),'reviewer'=>$review,'records'=>$page,'receipt_item_id'=>$p['receipt_item_id'],'capabilities'=>['prepare'=>!$review,'review'=>$review,'execute'=>!$review]])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
private function right(array $d):string{return $d['action']==='execute'?'stock.operations.confirm':$this->family($d['family']).'.'.($d['action']==='save'?'prepare':'review');}
|
||||
public function preview(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$d=$r->validate(['family'=>'required|in:policy,supplier_return','action'=>'required|in:save,approve,return,suspend,execute','operation_key'=>'required|uuid','record_key'=>'nullable|uuid','version'=>'required|integer|min:0','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:4000','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);
|
||||
return $this->run($r,$rt,$this->right($d),function($a,$p,$host,$g,$binding,$review)use($d){
|
||||
abort_unless($d['revision']===$this->hash($this->basis($p)),409,'O estado mudou. Confira novamente.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();
|
||||
try{$result=$this->execute($a,$p,$d,$review);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
$cut=['input'=>$d,'host'=>$host,'native'=>$a->id,'generation'=>$g,'binding'=>$binding,'observed'=>time(),'impact'=>$result['impact']];
|
||||
return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$result['impact']])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
public function confirm(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);
|
||||
try{$cut=json_decode(Crypt::decryptString($r->input('preview')),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}
|
||||
abort_unless(is_array($cut['input']??null),409);
|
||||
return $this->run($r,$rt,$this->right($cut['input']),function($a,$p,$host,$g,$binding,$review)use($cut){
|
||||
abort_unless($cut['host']===$host&&$cut['native']===$a->id&&$cut['generation']===$g&&$cut['binding']===$binding&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Contexto ou prévia expirados.');$d=$cut['input'];$db=DB::connection();
|
||||
$old=$db->table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.completion.command'])->where('after->operation_key',$d['operation_key'])->first();
|
||||
if($old){$v=json_decode($old->after,true);abort_unless($v['request_hash']===$this->hash($d)&&$v['host_actor_id']===$host&&($v['policy']??null)===$this->hash($p),409);return response()->json($v['result']+['replayed'=>true]);}
|
||||
$before=$this->basis($p);abort_unless($this->hash($before)===$d['revision'],409,'Estado alterado desde a conferência.');$result=$this->execute($a,$p,$d,$review);abort_unless($result['impact']===$cut['impact'],409,'Impacto alterado.');
|
||||
app(RequisitionWorkflow::class)->audit($a,'sgf.stock.completion.command',null,$before,['operation_key'=>$d['operation_key'],'request_hash'=>$this->hash($d),'host_actor_id'=>$host,'policy'=>$this->hash($p),'reason'=>$d['reason'],'result'=>$result,'after'=>$this->basis($p)]);
|
||||
return response()->json($result+['replayed'=>false],201)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
public function receipt(Request $r,ReceiptRuntime $rt,string $key)
|
||||
{
|
||||
abort_if($r->query()!==[],422);abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);
|
||||
return $this->run($r,$rt,'stock.planning.view',function($a,$p,$host)use($key){
|
||||
$row=DB::table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.completion.command'])->where('after->operation_key',$key)->first();abort_unless($row,404);$v=json_decode($row->after,true);abort_unless($v['host_actor_id']===$host&&$v['policy']===$this->hash($p),403);
|
||||
return response()->json(['result'=>$v['result'],'reason'=>$v['reason'],'created_at'=>$row->created_at,'historical_receipt_only'=>true,'operation_executed'=>false])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
private function execute(object $a,array $p,array $d,bool $review):array
|
||||
{
|
||||
$family=$d['family'];$action=$d['action'];abort_unless($family==='policy'?in_array($action,['save','approve','return','suspend'],true):in_array($action,['save','approve','return','execute'],true),422);
|
||||
abort_unless($review===in_array($action,['approve','return','suspend'],true),403);
|
||||
abort_if(SupplyRecord::where('operation_key',$d['operation_key'])->exists(),409,'Chave utilizada. Recupere a confirmação original.');
|
||||
if(!empty($d['record_key'])){$old=$this->query($p,$family)->where('record_key',$d['record_key'])->firstOrFail();abort_unless($old->version===$d['version'],409,'Versão alterada.');}
|
||||
else abort_unless($d['version']===0&&$action==='save',409);
|
||||
if($action==='save'){
|
||||
abort_unless(($d['data']['stock_item_id']??null)===$p['stock_item_id']&&($d['data']['warehouse_id']??null)===$p['warehouse_id'],403);
|
||||
if($family==='supplier_return')abort_unless(($d['data']['kind']??null)==='supplier_return'&&($d['data']['receipt_item_id']??null)===$p['receipt_item_id']&&empty($d['data']['lot_id']),403);
|
||||
else abort_unless(($d['data']['forecast_method']??null)==='manual'&&empty($d['data']['agreement_key'])&&($d['data']['origin']??null)==='local'&&($d['data']['purpose']??null)==='internal',422);
|
||||
if(!empty($d['data']['predecessor_key']))abort_unless($this->query($p,'policy')->where('record_key',$d['data']['predecessor_key'])->where('version',$d['data']['predecessor_version']??0)->exists(),403);
|
||||
}
|
||||
// No automatic purchasing rule may act on this selected QA source.
|
||||
abort_if(app(SupplyPlanning::class)->latest('auto_rule')->where('stock_item_id',$p['stock_item_id'])->where('warehouse_id',$p['warehouse_id'])->where('state','approved')->exists(),409,'Existe regra automática. É necessária conferência própria.');
|
||||
$envelope=array_intersect_key($d,array_flip(['operation_key','record_key','version','action','reason','data']));$before=app(StockWorkflow::class)->balance($p['stock_item_id'],$p['warehouse_id']);
|
||||
$record=$family==='policy'?app(SupplyPlanning::class)->save($a,'policy',$envelope):app(InventoryDispositions::class)->save($a,$envelope);
|
||||
$after=app(StockWorkflow::class)->balance($p['stock_item_id'],$p['warehouse_id']);
|
||||
return ['record_id'=>$record->id,'record_key'=>$record->record_key,'version'=>$record->version,'impact'=>['family'=>$family,'action'=>$action,'state'=>$record->state,'before_milli'=>$before['quantity'],'after_milli'=>$after['quantity'],'physical_change_milli'=>$after['quantity']-$before['quantity'],'financial_credit_created'=>false,'financial_posting'=>false]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use App\Modules\Purchasing\ReceiptRuntime;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Purchasing\Embedded\Models\{StockItem,StockOperation,StockMovement,SupplyRecord,Warehouse};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,OrganizationAccess,InventoryLots,InventoryCounts,StockWorkflow,WarehouseReservations,RequisitionWorkflow};
|
||||
|
||||
/** Scoped QA transport for the original lot/count services and one append-only transit protocol. */
|
||||
final class StockFinalController
|
||||
{
|
||||
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
|
||||
private function policy():array
|
||||
{
|
||||
abort_unless(config('stock_operations.final_enabled')===true,404);$p=CompositionRuntime::physical();
|
||||
$path='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$p['composition'].'/stock-final-binding.json';$pin=config('stock_operations.final_sha256');
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&is_file($path)&&!is_link($path)&&hash_equals($pin,hash_file('sha256',$path)),503);
|
||||
$v=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);abort_unless($v['composition']===$p['composition']&&$v['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&isset($v['lot_item_id'],$v['transfer_item_id']),403);return $v;
|
||||
}
|
||||
private function run(Request $r,ReceiptRuntime $rt,string $right,\Closure $fn):mixed
|
||||
{
|
||||
$p=$this->policy();$hostActor=$r->user()->id;$generation=$this->hash($r->session()->get('company_context',[]));
|
||||
try{return $rt->run($right,function($a,$b,$binding)use($fn,$p,$hostActor,$generation){
|
||||
$a=app(CurrentActor::class)->lock($a);abort_if(app(OrganizationAccess::class)->maintenanceOnly($a),403);
|
||||
abort_unless(in_array($hostActor,[$p['dispatcher_host_id'],$p['reviewer_host_id']],true),403);
|
||||
$review=$hostActor===$p['reviewer_host_id'];abort_unless($a->id===($review?$p['reviewer_native_id']:$p['dispatcher_native_id']),403);
|
||||
foreach([$p['source_warehouse_id'],$p['destination_warehouse_id']] as $id)abort_unless(in_array($id,$b['warehouse_ids'],true),403);
|
||||
return $fn($a,$p,$hostActor,$generation,$binding,$review);
|
||||
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Operação ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}
|
||||
}
|
||||
private function basis(array $p):array
|
||||
{
|
||||
$scope=$p['scope'];$db=DB::connection();$w=app(StockWorkflow::class);
|
||||
return ['transfer_source'=>$w->balance($p['transfer_item_id'],$p['source_warehouse_id']),'transfer_destination'=>$w->balance($p['transfer_item_id'],$p['destination_warehouse_id']),
|
||||
'lot_balance'=>$w->balance($p['lot_item_id'],$p['source_warehouse_id']),
|
||||
'lot_context'=>app(InventoryLots::class)->context($p['lot_item_id'],$p['source_warehouse_id']),
|
||||
'lot_revision'=>(int)SupplyRecord::where('tenant_id',1)->where('stock_item_id',$p['lot_item_id'])->where('warehouse_id',$p['source_warehouse_id'])->max('id'),
|
||||
'transit_revision'=>(int)$db->table('sgf_stock_transit_events')->where($scope)->max('id'),'policy'=>$this->hash($p)];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){return $this->run($r,$rt,'stock.view',fn()=>response()->view('stock-final-workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store'));}
|
||||
public function history(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $this->run($r,$rt,'stock.view',function($a,$p)use($d){$q=DB::table('audit_logs')->where(['tenant_id'=>1,'action'=>'sgf.stock.final.command'])->whereIn('user_id',[$p['dispatcher_native_id'],$p['reviewer_native_id']])->whereIn('after->host_actor_id',[$p['dispatcher_host_id'],$p['reviewer_host_id']])->where('before->policy',$this->hash($p));
|
||||
$page=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['id','user_id','before','after','created_at'],'page',(int)($d['page']??1));
|
||||
$page->getCollection()->transform(function($row){$v=json_decode($row->after,true);return ['id'=>$row->id,'author_id'=>$row->user_id,'host_author_id'=>$v['host_actor_id'],'created_at'=>$row->created_at,'reason'=>$v['reason'],'operation_key'=>$v['operation_key'],'result'=>$v['result'],'before'=>json_decode($row->before,true),'after'=>$v['after']];});return response()->json($page)->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function index(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $this->run($r,$rt,'stock.view',function($a,$p,$host,$g,$binding,$review)use($d){
|
||||
abort_unless(app(InventoryLots::class)->allows($a,'inventory_lot_read'),403);$basis=$this->basis($p);$q=DB::table('sgf_stock_transit_events')->where($p['scope'])->where('action','dispatch');
|
||||
if(($d['search']??'')!=='')$q->whereRaw("transfer_key::text LIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']);
|
||||
$page=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$ids=$page->pluck('transfer_key')->all();
|
||||
$settled=DB::table('sgf_stock_transit_events')->where($p['scope'])->whereIn('transfer_key',$ids)->where('action','<>','dispatch')->groupBy('transfer_key')->selectRaw('transfer_key,sum(quantity_milli) quantity')->get()->keyBy('transfer_key');
|
||||
$page->getCollection()->transform(function($row)use($settled){$row->remaining_milli=(int)$row->quantity_milli-(int)($settled->get($row->transfer_key)->quantity??0);return $row;});
|
||||
$records=SupplyRecord::where('tenant_id',1)->where('stock_item_id',$p['lot_item_id'])->where('warehouse_id',$p['source_warehouse_id'])->orderByDesc('id')->limit(100)->get();
|
||||
$counts=StockOperation::where('tenant_id',1)->where('stock_item_id',$p['lot_item_id'])->where('warehouse_id',$p['source_warehouse_id'])->where('kind','count')->whereNotIn('id',StockOperation::where('tenant_id',1)->whereNotNull('count_id')->select('count_id'))->orderByDesc('id')->limit(100)->get(['id','reference','quantity_milli']);
|
||||
$inTransit=(int)DB::table('sgf_stock_transit_events')->where($p['scope'])->selectRaw("coalesce(sum(CASE WHEN action='dispatch' THEN quantity_milli ELSE -quantity_milli END),0) quantity")->value('quantity');
|
||||
return response()->json(['basis'=>$basis,'revision'=>$this->hash($basis),'reviewer'=>$review,'policy'=>$p,'items'=>StockItem::whereIn('id',[$p['lot_item_id'],$p['transfer_item_id']])->get(['id','code','name','unit']),'warehouses'=>Warehouse::whereIn('id',[$p['source_warehouse_id'],$p['destination_warehouse_id']])->get(['id','name']),'transfers'=>$page,'lot_records'=>$records,'counts'=>$counts,'lot_count_source'=>app(InventoryCounts::class)->source($p['lot_item_id'],$p['source_warehouse_id']),'in_transit_milli'=>$inTransit])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
private function right(string $action):string{return match($action){'receive'=>'stock.transfers.receive','activate','release','adjustment'=>'stock.lots.review',default=>'stock.operations.prepare'};}
|
||||
public function preview(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$d=$r->validate(['action'=>'required|in:dispatch,receive,return,prepare,activate,identify,hold,release,count,adjustment','operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:4000','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);
|
||||
return $this->run($r,$rt,$this->right($d['action']),function($a,$p,$host,$g,$binding,$review)use($d){
|
||||
abort_unless(hash_equals($this->hash($this->basis($p)),$d['revision']),409,'O estado mudou. Confira novamente.');
|
||||
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$result=$this->execute($a,$p,$host,$review,$d);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
// Generated IDs are not a promise: preview inserts are rolled back.
|
||||
$cut=['input'=>$d,'host'=>$host,'native'=>$a->id,'generation'=>$g,'binding'=>$binding,'observed'=>time(),'impact'=>$result['impact']];
|
||||
return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$result['impact']])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
public function confirm(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);
|
||||
try{$cut=json_decode(Crypt::decryptString($r->input('preview')),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}
|
||||
abort_unless(in_array($cut['input']['action']??null,['dispatch','receive','return','prepare','activate','identify','hold','release','count','adjustment'],true),409);
|
||||
return $this->run($r,$rt,$this->right($cut['input']['action']),function($a,$p,$host,$g,$binding,$review)use($cut){
|
||||
abort_unless($cut['host']===$host&&$cut['native']===$a->id&&$cut['generation']===$g&&$cut['binding']===$binding&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Contexto ou prévia expirados.');$d=$cut['input'];$db=DB::connection();
|
||||
$old=$db->table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.final.command'])->where('after->operation_key',$d['operation_key'])->first();
|
||||
if($old){$v=json_decode($old->after,true);abort_unless($v['request_hash']===$this->hash($d)&&$v['host_actor_id']===$host,409);return response()->json($v['result']+['replayed'=>true]);}
|
||||
$before=$this->basis($p);abort_unless(hash_equals($this->hash($before),$d['revision']),409,'Estado alterado desde a conferência.');
|
||||
$result=$this->execute($a,$p,$host,$review,$d);abort_unless($result['impact']===$cut['impact'],409,'Impacto alterado.');
|
||||
app(RequisitionWorkflow::class)->audit($a,'sgf.stock.final.command',null,$before,['operation_key'=>$d['operation_key'],'request_hash'=>$this->hash($d),'host_actor_id'=>$host,'reason'=>$d['reason'],'result'=>$result,'after'=>$this->basis($p)]);
|
||||
return response()->json($result+['replayed'=>false],201)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
public function receipt(Request $r,ReceiptRuntime $rt,string $key)
|
||||
{
|
||||
abort_if($r->query()!==[],422);abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);
|
||||
return $this->run($r,$rt,'stock.view',function($a,$p,$host)use($key){$row=DB::table('audit_logs')->where(['tenant_id'=>1,'user_id'=>$a->id,'action'=>'sgf.stock.final.command'])->where('after->operation_key',$key)->first();abort_unless($row,404);$v=json_decode($row->after,true);abort_unless($v['host_actor_id']===$host,403);return response()->json(['command_key'=>$key,'created_at'=>$row->created_at,'reason'=>$v['reason'],'result'=>$v['result'],'operation_executed'=>false,'historical_receipt_only'=>true])->header('Cache-Control','no-store');});
|
||||
}
|
||||
private function execute(object $a,array $p,int $host,bool $review,array $d):array
|
||||
{
|
||||
$action=$d['action'];$data=$d['data'];$w=app(StockWorkflow::class);$lots=app(InventoryLots::class);
|
||||
abort_if(StockOperation::where('idempotency_key',$d['operation_key'])->exists()||SupplyRecord::where('operation_key',$d['operation_key'])->exists(),409,'Chave já utilizada. Consulte o recibo original; não altere o conteúdo.');
|
||||
if(in_array($action,['dispatch','receive','return'],true))return $this->transfer($a,$p,$host,$review,$d);
|
||||
abort_unless($review===in_array($action,['activate','release','adjustment'],true),403);
|
||||
if(in_array($action,['prepare','activate','identify','hold','release'],true)){
|
||||
$v=validator($data,['record_key'=>'nullable|uuid','version'=>'required|integer|min:0','data'=>'present|array'])->validate();abort_if(array_diff(array_keys($data),['record_key','version','data']),422);
|
||||
if($action==='prepare'){abort_unless(($v['data']['stock_item_id']??null)===$p['lot_item_id']&&($v['data']['warehouse_id']??null)===$p['source_warehouse_id'],403);}
|
||||
else{$old=SupplyRecord::where('tenant_id',1)->where('record_key',$v['record_key']??'')->orderByDesc('version')->firstOrFail();abort_unless($old->stock_item_id===$p['lot_item_id']&&$old->warehouse_id===$p['source_warehouse_id'],403);}
|
||||
$record=$lots->save($a,['action'=>$action,'operation_key'=>$d['operation_key'],'record_key'=>$v['record_key']??null,'version'=>(int)$v['version'],'reason'=>$d['reason'],'data'=>$v['data']]);
|
||||
return ['record_id'=>$record->id,'record_key'=>$record->record_key,'version'=>$record->version,'impact'=>['action'=>$action,'physical_change_milli'=>0,'state'=>$record->state,'stock_item_id'=>$p['lot_item_id'],'warehouse_id'=>$p['source_warehouse_id']]];
|
||||
}
|
||||
abort_unless(in_array($action,['count','adjustment'],true),422);
|
||||
$v=validator($data,['quantity'=>'required|string|regex:/^\d{1,9}(\.\d{1,3})?$/','ledger_version'=>'required|integer|min:0','lot_version'=>'nullable|string|size:64','lot_counts'=>'sometimes|array|max:100','count_id'=>'nullable|integer|min:1'])->validate();abort_if(array_diff(array_keys($data),array_keys($v)),422);
|
||||
if($action==='adjustment'){$count=StockOperation::where('tenant_id',1)->findOrFail($v['count_id']??0);abort_unless($count->kind==='count'&&$count->stock_item_id===$p['lot_item_id']&&$count->warehouse_id===$p['source_warehouse_id'],403);}
|
||||
$before=$w->balance($p['lot_item_id'],$p['source_warehouse_id']);
|
||||
$op=$w->record($a,$v+['kind'=>$action,'idempotency_key'=>$d['operation_key'],'reference'=>'QA-LOTS-'.$d['operation_key'],'stock_item_id'=>$p['lot_item_id'],'warehouse_id'=>$p['source_warehouse_id'],'responsible_id'=>$a->id,'reason'=>$d['reason']]);
|
||||
$after=$w->balance($p['lot_item_id'],$p['source_warehouse_id']);
|
||||
return ['operation_id'=>$op->id,'impact'=>['action'=>$action,'physical_before_milli'=>$before['quantity'],'physical_after_milli'=>$after['quantity'],'physical_change_milli'=>$after['quantity']-$before['quantity'],'counted_milli'=>$op->quantity_milli,'financial_posting'=>false]];
|
||||
}
|
||||
private function transfer(object $a,array $p,int $host,bool $review,array $d):array
|
||||
{
|
||||
$action=$d['action'];abort_unless($review===($action==='receive'),403);if(!$review)abort_unless($a->hasFunctions(['warehouse']),403);
|
||||
$v=validator($d['data'],['quantity'=>'required|string|regex:/^\d{1,9}(\.\d{1,3})?$/','transfer_key'=>'required|uuid','stock_item_id'=>'required|integer','source_warehouse_id'=>'required|integer','destination_warehouse_id'=>'required|integer'])->validate();abort_if(array_diff(array_keys($d['data']),array_keys($v)),422);
|
||||
abort_unless($v['stock_item_id']===$p['transfer_item_id']&&$v['source_warehouse_id']===$p['source_warehouse_id']&&$v['destination_warehouse_id']===$p['destination_warehouse_id'],403);
|
||||
$db=DB::connection();$q=$db->table('sgf_stock_transit_events')->where($p['scope'])->where('transfer_key',$v['transfer_key']);$dispatch=(clone $q)->where('action','dispatch')->first();$qty=\Tempo\Purchasing\Embedded\Services\ReceiptWorkflow::milli($v['quantity']);abort_unless($qty>0,422);
|
||||
$w=app(StockWorkflow::class);$warehouse=$action==='receive'?$p['destination_warehouse_id']:$p['source_warehouse_id'];$before=$w->balance($p['transfer_item_id'],$warehouse);$remaining=0;
|
||||
if($action==='dispatch'){
|
||||
abort_if($dispatch!==null,409,'Expedição já registada.');abort_if(app(InventoryLots::class)->setup($p['transfer_item_id'],$warehouse)!==null,409,'Este protocolo exige artigo sem lotes.');
|
||||
$free=app(WarehouseReservations::class)->free($p['transfer_item_id'],$warehouse);abort_unless($before['quantity']>=$qty&&$free>=$qty,409,'Quantidade disponível insuficiente.');$remaining=$qty;
|
||||
}else{
|
||||
abort_unless($dispatch&&$dispatch->host_actor_id!==$host||($dispatch&&$action==='return'),403);
|
||||
$remaining=(int)$dispatch->quantity_milli-(int)(clone $q)->where('action','<>','dispatch')->sum('quantity_milli');abort_unless($qty<=$remaining,409,'A parcela excede o trânsito por receber.');$remaining-=$qty;
|
||||
}
|
||||
$sign=$action==='dispatch'?-1:1;$impact=['action'=>$action,'quantity_milli'=>$qty,'physical_before_milli'=>$before['quantity'],'physical_after_milli'=>$before['quantity']+$sign*$qty,'in_transit_after_milli'=>$remaining,'ownership_change_milli'=>0,'financial_posting'=>false];
|
||||
$snapshot=['sgf_internal_transit'=>['scope'=>$p['scope'],'transfer_key'=>$v['transfer_key'],'action'=>$action,'host_actor_id'=>$host,'source_warehouse_id'=>$p['source_warehouse_id'],'destination_warehouse_id'=>$p['destination_warehouse_id']],'development_only'=>true,'budget_checked'=>false,'impact'=>$impact];
|
||||
$op=StockOperation::create(['tenant_id'=>1,'created_by'=>$a->id,'responsible_id'=>$a->id,'stock_item_id'=>$p['transfer_item_id'],'warehouse_id'=>$warehouse,'destination_warehouse_id'=>$action==='dispatch'?$p['destination_warehouse_id']:null,'kind'=>'transfer','reference'=>'QA-TRANSIT-'.$d['operation_key'],'idempotency_key'=>$d['operation_key'],'payload_hash'=>$this->hash($d),'reason'=>$d['reason'],'quantity_milli'=>$qty,'theoretical_milli'=>$before['quantity'],'ledger_version'=>$before['version'],'snapshot'=>$snapshot,'recorded_at'=>now()]);
|
||||
$movement=StockMovement::create(['tenant_id'=>1,'stock_operation_id'=>$op->id,'stock_item_id'=>$p['transfer_item_id'],'warehouse_id'=>$warehouse,'moved_by'=>$a->id,'movement_type'=>$action==='dispatch'?'transfer_out':'transfer_in','quantity_milli'=>$sign*$qty,'snapshot'=>$snapshot,'moved_at'=>now()]);
|
||||
$id=$db->table('sgf_stock_transit_events')->insertGetId($p['scope']+['transfer_key'=>$v['transfer_key'],'command_key'=>$d['operation_key'],'request_hash'=>$this->hash($d),'action'=>$action,'actor_id'=>$a->id,'host_actor_id'=>$host,'stock_item_id'=>$p['transfer_item_id'],'source_warehouse_id'=>$p['source_warehouse_id'],'destination_warehouse_id'=>$p['destination_warehouse_id'],'quantity_milli'=>$qty,'movement_id'=>$movement->id,'reason'=>$d['reason'],'snapshot'=>json_encode($snapshot),'created_at'=>now()]);
|
||||
return ['event_id'=>$id,'operation_id'=>$op->id,'movement_id'=>$movement->id,'transfer_key'=>$v['transfer_key'],'impact'=>$impact];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
use Illuminate\Database\ConnectionInterface;use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/** Inactivity of known article/warehouse pairs; never invents placement or availability. */
|
||||
final class StockInactiveReport
|
||||
{
|
||||
public function __construct(private ConnectionInterface $db){}
|
||||
public function read(array $binding,string $fingerprint,array $f,?string $token):array
|
||||
{
|
||||
if($f['warehouse_id']!==null)abort_unless(in_array($f['warehouse_id'],$binding['warehouse_ids'],true),403);
|
||||
return $this->db->transaction(function()use($binding,$fingerprint,$f,$token){abort_unless($this->db->transactionLevel()===1,503);$this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');$this->db->statement("SET LOCAL statement_timeout='3s'");
|
||||
$reader=new \Tempo\Inventory\InventoryReader($this->db,$binding['schema'],$binding['source_tenant_id'],$binding['warehouse_ids']);try{$revision=$reader->revision();}catch(\UnexpectedValueException){abort(503,'Fonte de stock incompatível.');}$observed=time();
|
||||
if($token!==null){try{$c=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Referência de inactividade inválida. Actualize a consulta.');}abort_unless(($c['fingerprint']??null)===$fingerprint&&($c['revision']??null)===$revision&&is_int($c['observed']??null)&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'A origem ou o contexto mudou. Actualize o relatório.');$observed=$c['observed'];}
|
||||
$asOf=new \DateTimeImmutable($f['as_of'],new \DateTimeZone('UTC'));$until=$asOf->modify('+1 day')->format('Y-m-d').' 00:00:00+00';$cut=$asOf->modify('-'.($f['days']-1).' days')->format('Y-m-d').' 00:00:00+00';
|
||||
$q=$this->db->table('public.stock_movements as m')->join('public.stock_items as i',fn($j)=>$j->on('i.id','=','m.stock_item_id')->on('i.tenant_id','=','m.tenant_id'))->join('public.warehouses as w',fn($j)=>$j->on('w.id','=','m.warehouse_id')->on('w.tenant_id','=','m.tenant_id'))->where('m.tenant_id',$binding['source_tenant_id'])->whereIn('m.warehouse_id',$binding['warehouse_ids'])->where('m.moved_at','<',$until);
|
||||
if($f['warehouse_id']!==null)$q->where('m.warehouse_id',$f['warehouse_id']);if($f['search']!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$f['search']).'%';$q->where(fn($s)=>$s->whereRaw("i.code ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("i.name ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
$q->select(['m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code as warehouse_code','w.name as warehouse_name'])->selectRaw('SUM(m.quantity_milli::numeric)::text physical_quantity_milli,MAX(m.moved_at) last_registered_at')->selectRaw("(?::date-(MAX(m.moved_at) AT TIME ZONE 'UTC')::date)::integer days_without_record",[$f['as_of']])->groupBy('m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code','w.name')->havingRaw('MAX(m.moved_at) < ?',[$cut])->orderBy('m.stock_item_id')->orderBy('m.warehouse_id');
|
||||
$total=(int)$this->db->query()->fromSub((clone$q)->reorder(),'stock_inactive_pairs')->count();$rows=$q->limit($f['per_page'])->offset(($f['page']-1)*$f['per_page'])->get()->map(fn($r)=>(array)$r)->all();
|
||||
return ['status'=>'available','synthetic_only'=>true,'report'=>'known_pairs_inactivity','as_of'=>$f['as_of'],'minimum_days'=>$f['days'],'timezone'=>'UTC','rows'=>$rows,'pagination'=>['page'=>$f['page'],'per_page'=>$f['per_page'],'total'=>$total,'last_page'=>max(1,(int)ceil($total/$f['per_page']))],'observed_at'=>gmdate('c',$observed),'ledger_revision'=>$revision,'snapshot'=>Crypt::encryptString(json_encode(['fingerprint'=>$fingerprint,'revision'=>$revision,'observed'=>$observed],JSON_THROW_ON_ERROR)),'capabilities'=>['write'=>false,'valuation'=>false,'reserved'=>false,'blocked'=>false],'notice'=>'Último registo físico UTC em pares artigo/armazém com história conhecida até ao corte. Metadados actuais. Quantidade física não é disponível. Sem história não se atribui armazém nem se presume inactividade, saldo zero ou ausência de actividade real. Sem somar unidades; corte futuro não prevê movimentos por registar.'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use App\Modules\Purchasing\ReceiptRuntime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt, DB, Validator};
|
||||
use Tempo\Purchasing\Embedded\Models\{AuditLog, StockItem, StockMovement, StockOperation, User, Warehouse};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor, OrganizationAccess, RequisitionWorkflow, StockWorkflow};
|
||||
|
||||
/** SGF transport over the pinned physical workflow; never an independent stock ledger. */
|
||||
final class StockOperationsController
|
||||
{
|
||||
private function run(ReceiptRuntime $rt,string $right,\Closure $work): mixed
|
||||
{
|
||||
try { return $rt->run($right,$work); }
|
||||
catch(\Illuminate\Database\QueryException $e){
|
||||
if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;
|
||||
return response()->json(['message'=>'Operação ocupada. Recupere a mesma confirmação; não crie outra chave.'],503)->header('Cache-Control','no-store')->header('Retry-After','5');
|
||||
}
|
||||
}
|
||||
private function guard(): void { abort_unless(config('stock_operations.enabled') === true,404); }
|
||||
private function hash(array $value): string { return hash('sha256',json_encode($value,JSON_THROW_ON_ERROR)); }
|
||||
private function generation(): string { return $this->hash(request()->session()->get('company_context',[])); }
|
||||
|
||||
private function operator(object $actor): object
|
||||
{
|
||||
$actor=app(CurrentActor::class)->lock($actor,['warehouse']);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor),403);
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function entities(array $data,array $binding,object $actor): array
|
||||
{
|
||||
$warehouses=array_values(array_unique([$data['warehouse_id'],...($data['kind']==='transfer'?[$data['destination_warehouse_id']]:[])]));
|
||||
foreach($warehouses as $id) abort_unless(in_array($id,$binding['warehouse_ids']??[],true),403,'Armazém fora do contexto autorizado.');
|
||||
$item=StockItem::where('tenant_id',$actor->tenant_id)->findOrFail($data['stock_item_id']);
|
||||
$rows=Warehouse::where('tenant_id',$actor->tenant_id)->whereIn('id',$warehouses)->orderBy('id')->get();
|
||||
abort_unless($rows->count()===count($warehouses),403);
|
||||
$responsible=User::where('tenant_id',$actor->tenant_id)->findOrFail($data['responsible_id']);
|
||||
$basis=['item'=>$item->only(['id','code','name','unit']),'warehouses'=>$rows->map->only(['id','code','name'])->all(),
|
||||
'responsible'=>$responsible->only(['id','name']), 'balances'=>array_map(fn($id)=>['warehouse_id'=>$id]+app(StockWorkflow::class)->balance($item->id,$id),$warehouses)];
|
||||
if($data['kind']==='adjustment'){
|
||||
$count=StockOperation::where('tenant_id',$actor->tenant_id)->where('kind','count')->where('stock_item_id',$item->id)->where('warehouse_id',$data['warehouse_id'])->findOrFail($data['count_id']);
|
||||
$basis['count']=$count->toArray();$basis['count_applied']=StockOperation::where('tenant_id',$actor->tenant_id)->where('count_id',$count->id)->exists();
|
||||
}
|
||||
return $basis;
|
||||
}
|
||||
|
||||
private function execute(array $data,Request $http,object $actor): array
|
||||
{
|
||||
$input=Request::create('/embedded/stock-operation','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);
|
||||
$input->setUserResolver(fn($guard=null)=>$actor);
|
||||
if($http->hasSession()) $input->setLaravelSession($http->session());
|
||||
$response=(new \Tempo\Purchasing\Embedded\Http\Controllers\StockOperationController)->store($input,app(StockWorkflow::class));
|
||||
$operation=StockOperation::findOrFail($response->getData(true)['id']);
|
||||
$difference=match($operation->kind){'count'=>0,'adjustment'=>(int)$operation->quantity_milli-(int)$operation->theoretical_milli,default=>-(int)$operation->quantity_milli};
|
||||
return ['operation'=>$operation,'impact'=>[
|
||||
'kind'=>$operation->kind,'reference'=>$operation->reference,'quantity_milli'=>$operation->quantity_milli,
|
||||
'stock_item_id'=>$operation->stock_item_id,'warehouse_id'=>$operation->warehouse_id,
|
||||
'destination_warehouse_id'=>$operation->destination_warehouse_id,'destination'=>$operation->destination,
|
||||
'theoretical_milli'=>$operation->theoretical_milli,'snapshot'=>$operation->snapshot,
|
||||
'change_milli'=>$difference,'quantity_after_milli'=>(int)$operation->theoretical_milli+$difference,
|
||||
'destination_quantity_after_milli'=>$operation->kind==='transfer'?app(StockWorkflow::class)->balance($operation->stock_item_id,$operation->destination_warehouse_id)['quantity']:null,
|
||||
'reason'=>$operation->reason,'financial_posting'=>false,
|
||||
]];
|
||||
}
|
||||
|
||||
public function screen(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$this->guard();$access=$this->run($rt,'stock.view',fn($a)=>$this->operator($a));
|
||||
if($access instanceof \Symfony\Component\HttpFoundation\Response)return $access;
|
||||
return response()->view('stock-operations-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()])->header('Cache-Control','no-store');
|
||||
}
|
||||
|
||||
public function index(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$this->guard();$d=$r->validate(['view'=>'required|in:items,warehouses,people,equipment,counts,history','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','warehouse_id'=>'required_if:view,counts|prohibited_unless:view,counts|integer|min:1','stock_item_id'=>'required_if:view,counts|prohibited_unless:view,counts|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $this->run($rt,'stock.view',function($a,$b)use($d){
|
||||
$a=$this->operator($a);$kind=$d['view'];
|
||||
if($kind==='counts')abort_unless(in_array((int)$d['warehouse_id'],$b['warehouse_ids']??[],true),403);
|
||||
$q=match($kind){'items'=>StockItem::where('tenant_id',$a->tenant_id),'warehouses'=>Warehouse::where('tenant_id',$a->tenant_id)->whereIn('id',$b['warehouse_ids']??[]),'people'=>User::where('tenant_id',$a->tenant_id),'equipment'=>\Tempo\Purchasing\Embedded\Models\Equipment::where('tenant_id',$a->tenant_id),'counts'=>StockOperation::where('tenant_id',$a->tenant_id)->where('kind','count')->where('warehouse_id',$d['warehouse_id'])->where('stock_item_id',$d['stock_item_id'])->whereNotIn('id',StockOperation::where('tenant_id',$a->tenant_id)->whereNotNull('count_id')->select('count_id')),default=>StockOperation::where('tenant_id',$a->tenant_id)->whereIn('warehouse_id',$b['warehouse_ids']??[])->where(fn($q)=>$q->whereNull('destination_warehouse_id')->orWhereIn('destination_warehouse_id',$b['warehouse_ids']??[]))};
|
||||
if(($d['search']??'')!==''){
|
||||
$search='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';
|
||||
$q->whereRaw((in_array($kind,['history','counts'],true)?'reference':($kind==='equipment'?'description':'name'))." ILIKE ? ESCAPE '!'",[$search]);
|
||||
}
|
||||
$fields=match($kind){'items'=>['id','code','name','unit'],'warehouses'=>['id','code','name'],'people'=>['id','name'],'equipment'=>['id','code','description as name'],'counts'=>['id','reference as name','quantity_milli','ledger_version'],default=>['id','kind','reference','stock_item_id','warehouse_id','destination_warehouse_id','quantity_milli','reason','recorded_at']};
|
||||
return response()->json($q->orderBy('id',$kind==='history'?'desc':'asc')->paginate((int)($d['per_page']??25),$fields,'page',(int)($d['page']??1)))->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
|
||||
public function basis(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$this->guard();$d=$this->data($r->query());
|
||||
return $this->run($rt,'stock.view',function($a,$b)use($d){$a=$this->operator($a);$basis=$this->entities($d,$b,$a);return response()->json(['basis'=>$basis,'revision'=>$this->hash($basis)])->header('Cache-Control','no-store');});
|
||||
}
|
||||
|
||||
private function data(array $data): array
|
||||
{
|
||||
// Returns retain their own declaration and independent reception workflow.
|
||||
$validated=Validator::make($data,[
|
||||
'kind'=>'required|in:issue,transfer,count,adjustment','warehouse_id'=>'required|integer|min:1','stock_item_id'=>'required|integer|min:1',
|
||||
'responsible_id'=>'required|integer|min:1','destination_warehouse_id'=>'required_if:kind,transfer|prohibited_unless:kind,transfer|integer|min:1',
|
||||
'count_id'=>'required_if:kind,adjustment|prohibited_unless:kind,adjustment|integer|min:1',
|
||||
])->after(function($v)use($data){if(array_diff(array_keys($data),['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id']))$v->errors()->add('data','Campos não reconhecidos.');})->validate();
|
||||
foreach(['warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id'] as $key) if(isset($validated[$key])) $validated[$key]=(int)$validated[$key];
|
||||
return $validated;
|
||||
}
|
||||
|
||||
public function preview(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','data'=>'required|array:kind,warehouse_id,stock_item_id,responsible_id,destination_warehouse_id,quantity,reference,reason,destination,equipment_id,count_id,ledger_version']);
|
||||
abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);
|
||||
$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();
|
||||
return $this->run($rt,'stock.operations.prepare',function($a,$b,$binding)use($d,$r,$generation){
|
||||
$a=$this->operator($a);$data=$d['data'];$keys=['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id'];
|
||||
$basis=$this->entities($this->data(array_intersect_key($data,array_flip($keys))),$b,$a);
|
||||
abort_unless(hash_equals($this->hash($basis),$d['revision']),409,'O saldo ou o contexto mudou. Confira novamente.');
|
||||
abort_if(StockOperation::where('idempotency_key',$d['operation_key'])->exists(),409,'Operação já registada. Consulte o histórico.');
|
||||
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();
|
||||
try{$out=$this->execute($data+['idempotency_key'=>$d['operation_key']],$r,$a);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
$cut=$d+['actor'=>$a->id,'binding'=>$binding,'generation'=>$generation,'observed'=>time(),'impact'=>$this->hash($out['impact'])];
|
||||
return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'basis'=>$basis,'impact'=>$out['impact']])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
|
||||
public function confirm(Request $r,ReceiptRuntime $rt)
|
||||
{
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422);
|
||||
try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}
|
||||
$generation=$this->generation();
|
||||
return $this->run($rt,'stock.operations.confirm',function($a,$b,$binding,$origin)use($cut,$generation,$r){
|
||||
$a=$this->operator($a);
|
||||
abort_unless(($cut['actor']??null)===$a->id&&($cut['binding']??null)===$binding&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia expirada ou contexto alterado.');
|
||||
$keys=['kind','warehouse_id','stock_item_id','responsible_id','destination_warehouse_id','count_id'];
|
||||
$basis=$this->entities($this->data(array_intersect_key($cut['data'],array_flip($keys))),$b,$a);
|
||||
$hash=$this->hash([$cut['operation_key'],$cut['data']]);
|
||||
$old=AuditLog::where('tenant_id',$a->tenant_id)->where('user_id',$a->id)->where('action','sgf.stock.command')->where('after->operation_key',$cut['operation_key'])->first();
|
||||
if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$binding,409);return response()->json($old->after['result']+['replayed'=>true])->header('Cache-Control','no-store');}
|
||||
abort_unless(hash_equals($this->hash($basis),$cut['revision']),409,'Movimentos posteriores à conferência. Actualize o saldo.');
|
||||
abort_if(StockOperation::where('idempotency_key',$cut['operation_key'])->exists(),409,'Chave utilizada fora desta conferência.');
|
||||
$out=$this->execute($cut['data']+['idempotency_key'=>$cut['operation_key']],$r,$a);
|
||||
abort_unless(hash_equals($cut['impact'],$this->hash($out['impact'])),409,'Dependências alteradas. Confira novamente.');
|
||||
$result=['operation_id'=>$out['operation']->id,'kind'=>$out['operation']->kind,'reference'=>$out['operation']->reference];
|
||||
app(RequisitionWorkflow::class)->audit($a,'sgf.stock.command',null,$basis,$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$binding,'reason'=>$out['operation']->reason,'result'=>$result,'impact'=>$out['impact']]);
|
||||
return response()->json($result+['replayed'=>false],201)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/** Physical registration-period quantities, projected from the canonical book only. */
|
||||
final class StockPeriodReport
|
||||
{
|
||||
public function __construct(private ConnectionInterface $db){}
|
||||
public function read(array $binding,string $fingerprint,array $filters,?string $token):array
|
||||
{
|
||||
return $this->db->transaction(function()use($binding,$fingerprint,$filters,$token){
|
||||
abort_unless($this->db->transactionLevel()===1,503);
|
||||
$this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$this->db->statement("SET LOCAL statement_timeout='3s'");
|
||||
$reader=new \Tempo\Inventory\InventoryReader($this->db,$binding['schema'],$binding['source_tenant_id'],$binding['warehouse_ids']);
|
||||
try{$revision=$reader->revision();}catch(\UnexpectedValueException){abort(503,'Fonte de stock incompatível.');}
|
||||
$observed=time();
|
||||
if($token!==null){
|
||||
try{$cut=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Referência de relatório inválida. Actualize a consulta.');}
|
||||
abort_unless(($cut['fingerprint']??null)===$fingerprint&&($cut['revision']??null)===$revision
|
||||
&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A fonte ou o contexto mudou. Actualize o relatório.');
|
||||
$observed=$cut['observed'];
|
||||
}
|
||||
$from=$filters['from'].' 00:00:00';
|
||||
$until=(new \DateTimeImmutable($filters['to'],new \DateTimeZone('UTC')))->modify('+1 day')->format('Y-m-d').' 00:00:00';
|
||||
$q=$this->db->table('public.stock_movements AS m')
|
||||
->join('public.stock_items AS i',fn($j)=>$j->on('i.id','=','m.stock_item_id')->on('i.tenant_id','=','m.tenant_id'))
|
||||
->join('public.warehouses AS w',fn($j)=>$j->on('w.id','=','m.warehouse_id')->on('w.tenant_id','=','m.tenant_id'))
|
||||
->where('m.tenant_id',$binding['source_tenant_id'])->whereIn('m.warehouse_id',$binding['warehouse_ids'])->where('m.moved_at','<',$until);
|
||||
if($filters['warehouse_id']!==null)$q->where('m.warehouse_id',$filters['warehouse_id']);
|
||||
if($filters['search']!==''){
|
||||
$literal='%'.str_replace(['\\','%','_'],['\\\\','\\%','\\_'],$filters['search']).'%';
|
||||
$q->where(fn($s)=>$s->where('i.code','ilike',$literal)->orWhere('i.name','ilike',$literal));
|
||||
}
|
||||
$q->select(['m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code AS warehouse_code','w.name AS warehouse_name'])
|
||||
->selectRaw('SUM(CASE WHEN m.moved_at < ? THEN m.quantity_milli::numeric ELSE 0 END)::text AS opening_milli',[$from])
|
||||
->selectRaw('SUM(CASE WHEN m.moved_at >= ? AND m.quantity_milli > 0 THEN m.quantity_milli::numeric ELSE 0 END)::text AS incoming_milli',[$from])
|
||||
->selectRaw('SUM(CASE WHEN m.moved_at >= ? AND m.quantity_milli < 0 THEN -m.quantity_milli::numeric ELSE 0 END)::text AS outgoing_milli',[$from])
|
||||
->selectRaw('SUM(m.quantity_milli)::text AS closing_milli')
|
||||
->selectRaw('COUNT(*) FILTER (WHERE m.moved_at >= ?)::text AS period_movements',[$from])
|
||||
->groupBy('m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code','w.name')
|
||||
->orderBy('m.stock_item_id')->orderBy('m.warehouse_id');
|
||||
$total=(int)$this->db->query()->fromSub((clone $q)->reorder(),'stock_period_pairs')->count();
|
||||
$rows=$q->limit($filters['per_page'])->offset(($filters['page']-1)*$filters['per_page'])->get()->map(fn($r)=>(array)$r)->all();
|
||||
return ['status'=>'available','synthetic_only'=>true,'report'=>'physical_period','period'=>['from'=>$filters['from'],'to'=>$filters['to'],'timezone'=>'UTC','basis'=>'registration_time'],
|
||||
'rows'=>$rows,'pagination'=>['total'=>$total,'page'=>$filters['page'],'per_page'=>$filters['per_page'],'last_page'=>max(1,(int)ceil($total/$filters['per_page']))],
|
||||
'observed_at'=>gmdate('c',$observed),'snapshot'=>Crypt::encryptString(json_encode(['fingerprint'=>$fingerprint,'revision'=>$revision,'observed'=>$observed],JSON_THROW_ON_ERROR)),
|
||||
'ledger_revision'=>$revision,'capabilities'=>['write'=>false,'valuation'=>false,'reserved'=>false,'blocked'=>false],
|
||||
'notice'=>'Quantidades por data de registo UTC; designações actuais. Não há total somado entre unidades diferentes. Sem valorização, reservas ou disponibilidade. Pares sem história até ao fecho não são saldos zero.'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ final class StockReadController
|
||||
{
|
||||
public function index(Request $r, CompanyContext $context, Permissions $permissions)
|
||||
{
|
||||
if (config('receipt_qa.stock_read_enabled') === true) {
|
||||
return app(CompositionStockRead::class)->index($r, $context, $permissions);
|
||||
}
|
||||
$d = $r->validate(['view' => 'sometimes|in:items,warehouses,balances,movements','search' => 'nullable|string|max:100',
|
||||
'warehouse_id' => 'sometimes|integer|min:1','page' => 'sometimes|integer|min:1|max:100000','per_page' => 'sometimes|integer|min:1|max:100',
|
||||
'snapshot' => 'sometimes|string|max:8192']);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Embedded\Models\PurchaseOrder;
|
||||
use Tempo\Purchasing\Embedded\Services\SupplierPayables;
|
||||
|
||||
/** Financial effects remain in the original authoritative obligation book. */
|
||||
final class ComposedFinance
|
||||
{
|
||||
private const ACTIONS=['view'=>['finance_prepare','finance_review','read_only'],'payment-prepare'=>['finance_prepare'],'allocation-prepare'=>['finance_prepare'],'reversal-prepare'=>['finance_prepare'],'payment-decide'=>['finance_review'],'allocation-decide'=>['finance_review'],'reversal-confirm'=>['finance_review']];
|
||||
public function run(string $action,\Closure $work):mixed
|
||||
{
|
||||
CompositionRuntime::assert();abort_unless(config('sgf_single_installation.finance_enabled')===true,404);abort_unless(isset(self::ACTIONS[$action]),404);
|
||||
abort_unless(request()->session()->get('company_context.module_id')==='finance',409,'Seleccione Financeiro.');
|
||||
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$host=DB::connection();$user=$ctx->userId();
|
||||
try{return $host->transaction(function()use($action,$work,$scope,$host,$user){
|
||||
$host->statement("SET LOCAL lock_timeout='5s'");$host->table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$profile=$host->table('sgf_presentation_profiles')->where($scope)->where('user_id',$user)->first();abort_unless($profile&&in_array($profile->profile_key,self::ACTIONS[$action],true),403);
|
||||
return app(ReceiptRuntime::class)->run('supplier_payables.view',function($actor,$binding,$revision,$origin)use($action,$work,$scope,$host,$user,$profile){
|
||||
$r=DB::table('gl_sgf_composition')->where('tenant_id',$actor->tenant_id)->first();abort_unless($r&&$r->host_database===$host->getDatabaseName()&&(int)$r->company_id===$scope['company_id']&&(int)$r->unit_id===$scope['unit_id'],403);
|
||||
$orders=PurchaseOrder::visibleTo($actor)->where(fn($q)=>$q->whereIn('id',$binding['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)));
|
||||
$unknown=DB::table('pa_sources as s')->join('pa_obligations as o','o.source_id','=','s.id')->join('invoices as i','i.id','=','s.local_source_id')->where(['s.tenant_id'=>$actor->tenant_id,'s.company_id'=>0,'s.unit_id'=>0])->whereNotIn('i.purchase_order_id',$orders->select('id'))->exists();
|
||||
abort_if($unknown,503,'Há origens financeiras sem unidade conferida; a partição tem de ser resolvida explicitamente.');
|
||||
$service=app(SupplierPayables::class);$service->readContext($actor);$engine=$service->engine($actor);
|
||||
return $work($engine,$engine->settlements(),$actor,$origin+['sgf_host_user'=>$user,'sgf_host_scope'=>$scope,'profile'=>$profile->profile_key],$orders);
|
||||
});
|
||||
});}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Livro ocupado. Recupere a mesma operação, conservando a chave e os dados.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Invoice,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\RequisitionWorkflow;
|
||||
|
||||
final class ComposedFinanceController
|
||||
{
|
||||
public function index(Request $r,ComposedFinance $finance)
|
||||
{
|
||||
$d=$r->validate(['document_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','payments_page'=>'sometimes|integer|min:1|max:100000','allocation_proposals_page'=>'sometimes|integer|min:1|max:100000','allocations_page'=>'sometimes|integer|min:1|max:100000','payment_events_page'=>'sometimes|integer|min:1|max:100000','payment_per_page'=>'sometimes|integer|in:25,100','payment_q'=>'sometimes|string|max:100','payment_state'=>'sometimes|in:all,proposed,confirmed,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $finance->run('view',function($engine,$settlements,$actor,$origin,$orders)use($r,$d){
|
||||
$q=Invoice::where('tenant_id',$actor->tenant_id)->whereIn('purchase_order_id',$orders->select('id'));if(isset($d['document_id']))$q->where('id',(int)$d['document_id']);$page=$q->orderBy('id')->paginate(25,['id','number'],'page',(int)($d['page']??1));if(isset($d['document_id']))abort_unless($page->total()===1,403,'Documento fora da origem financeira autorizada.');
|
||||
$base=$engine->positionsPage(array_column($page->items(),'id'));$money=$settlements->listingPage(array_diff_key($d,['page'=>true,'document_id'=>true]));
|
||||
$numbers=DB::table('pa_sources as s')->join('invoices as i','i.id','=','s.local_source_id')->where('s.tenant_id',$actor->tenant_id)->whereIn('s.id',array_column($base['obligations'],'source_id'))->pluck('i.number','s.id');
|
||||
foreach($base['obligations'] as &$o)$o['document_number']=$numbers->get($o['source_id']);unset($o);
|
||||
$data=['obligations'=>$base['obligations'],'money'=>$money,'profile'=>$origin['profile'],'pagination'=>['page'=>$page->currentPage(),'last_page'=>$page->lastPage()]];
|
||||
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
|
||||
return response()->view('finance.composed-payables',['data'=>$data,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
|
||||
public function command(string $action,Request $r,ComposedFinance $finance)
|
||||
{
|
||||
$key=strtolower((string)$r->header('Idempotency-Key'));abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$data=$r->all();abort_if(strlen(json_encode($data))>65536,422);
|
||||
try{return $finance->run($action,function($engine,$settlements,$actor,$origin)use($action,$key,$data){
|
||||
$hash=hash('sha256',Payables::canonical([$action,$data]));$old=AuditLog::where('tenant_id',$actor->tenant_id)->where('action','sgf.finance.command')->where('after->operation_key',$key)->first();
|
||||
if($old)abort_unless(($old->after['sgf_context_key']??null)===$origin['sgf_context_key']&&($old->after['request_hash']??null)===$hash,409,'Chave pertence a outro contexto/conteúdo.');
|
||||
$result=$settlements->command($action,$data,$key);
|
||||
if(!$old)app(RequisitionWorkflow::class)->audit($actor,'sgf.finance.command',null,null,$origin+['operation_key'=>$key,'request_hash'=>$hash,'command'=>$action,'result'=>$result,'reason'=>$data['reason']??'']);
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
});}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(409,$e->getMessage());}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Requisition,Quotation,PurchaseAward,PurchaseOrder,GoodsReceipt,Invoice,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\SupplierPayables;
|
||||
|
||||
/** Read-only view of existing facts, with independent authority for every stage. */
|
||||
final class DossierController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.dossiers_enabled')===true&&config('receipt_qa.requests_enabled')===true,404); }
|
||||
private function source(int $id,array $binding,object $actor):Requisition {
|
||||
abort_unless($actor->hasFunctions(['requester','procurement','finance','department_head'])&&RequisitionAccess::allowed($id,$binding),403);
|
||||
return Requisition::visibleTo($actor)->find($id)??abort(403,'Requisição fora do contexto autorizado.');
|
||||
}
|
||||
private function page($query,array $columns,string $name,array $input):array {
|
||||
$page=$query->orderBy('id')->paginate(25,$columns,$name,(int)($input[$name]??1));
|
||||
return ['accessible'=>true,'data'=>$page->items(),'pagination'=>['page'=>$page->currentPage(),'last_page'=>$page->lastPage(),'total'=>$page->total(),'per_page'=>25]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$runtime->run('purchasing_requests.view',function($a){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);});
|
||||
return response()->view('purchasing-dossier',['context'=>$r->session()->get('company_context.token')]);
|
||||
}
|
||||
public function index(Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'sometimes|in:draft,pending,approved,rejected','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_requests.view',function($a,$b)use($d){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(lower(?) in lower(number))>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','process_version'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$rules=[];foreach(['quotes','awards','orders','receipts','invoices','obligations','events']as$key)$rules[$key.'_page']='sometimes|integer|min:1|max:10000';$d=$r->validate($rules);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
$host=DB::connection();$c=app(\App\Shared\Context\CompanyContext::class);$scope=$c->identifiers()+['unit_id'=>$c->unitId(),'user_id'=>$c->userId()];
|
||||
return $runtime->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d,$host,$scope){
|
||||
$request=$this->source($id,$b,$a);$allowed=[];
|
||||
foreach(['sourcing','orders','receipts','invoices','obligations']as$stage){$flag=$stage==='receipts'?'enabled':$stage.'_enabled';$allowed[$stage]=config('receipt_qa.'.$flag)===true&&$host->table('permission_grants')->where($scope)->where('permission','purchasing_'.$stage.'.view')->lockForUpdate()->first()!==null;}
|
||||
$viewer=$a->hasFunctions(['admin','procurement','finance','department_head']);$finance=$a->hasFunctions(['admin','finance']);$allowed['sourcing']=$allowed['sourcing']&&$viewer;$allowed['orders']=$allowed['orders']&&$viewer;$allowed['receipts']=$allowed['receipts']&&Gate::allows('viewAny',GoodsReceipt::class);$allowed['invoices']=$allowed['invoices']&&$finance;$allowed['obligations']=$allowed['obligations']&&$finance;
|
||||
$orders=fn()=>PurchaseOrder::where('tenant_id',$a->tenant_id)->where('requisition_id',$id)->select('id');
|
||||
$invoices=fn()=>Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$orders());
|
||||
$restricted=['accessible'=>false];$stages=['quotes'=>$restricted,'awards'=>$restricted,'orders'=>$restricted,'receipts'=>$restricted,'invoices'=>$restricted,'obligations'=>$restricted];$patterns=['requisition.%','sgf.request.command'];
|
||||
if($allowed['sourcing']){$stages['quotes']=$this->page(Quotation::where('requisition_id',$id),['id','reference','supplier_id','total_minor','created_by','created_at'],'quotes_page',$d);$stages['awards']=$this->page(PurchaseAward::where('requisition_id',$id),['id','quotation_id','status','total_minor','created_by','created_at'],'awards_page',$d);array_push($patterns,'quotation.%','award.%','sgf.sourcing.command');}
|
||||
if($allowed['orders']){$stages['orders']=$this->page(PurchaseOrder::visibleTo($a)->where('requisition_id',$id),['id','number','status','total_minor','issued_by','issued_at'],'orders_page',$d);array_push($patterns,'order.%','sgf.order.command');}
|
||||
if($allowed['receipts']){$stages['receipts']=$this->page(GoodsReceipt::whereIn('purchase_order_id',$orders()),['id','number','purchase_order_id','status','received_on','received_by','confirmed_at'],'receipts_page',$d);array_push($patterns,'receipt.%','sgf.receipt.command','sgf.receipt.document','sgf.receipt.document_downloaded');}
|
||||
if($allowed['invoices']){$stages['invoices']=$this->page($invoices(),['id','number','purchase_order_id','status','total_minor','created_by','validated_by','validated_at'],'invoices_page',$d);array_push($patterns,'invoice.%','sgf.invoice.command');}
|
||||
if($allowed['obligations']){
|
||||
$service=app(SupplierPayables::class);$service->readContext($a);
|
||||
// The window is on source invoices, not the complete financial universe.
|
||||
$window=$invoices()->orderBy('id')->paginate(25,['id'],'obligations_page',(int)($d['obligations_page']??1));
|
||||
$positions=$service->engine($a)->positionsPage($window->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all());
|
||||
$stages['obligations']=['accessible'=>true,'data'=>$positions['obligations'],'pagination'=>['page'=>$window->currentPage(),'last_page'=>$window->lastPage(),'total_sources'=>$window->total(),'per_page'=>25],'source_window'=>true];$patterns[]='sgf.obligation.command';
|
||||
}
|
||||
$events=AuditLog::where('requisition_id',$id)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});$history=$this->page($events,['id','user_id','action','created_at'],'events_page',$d);
|
||||
$view=['request'=>$request->only(['id','number','status','process_version','requester_id','total_minor']),'stages'=>$stages,'events'=>$history,'read_only'=>true,'revision_scope'=>'visible_pages_only'];
|
||||
$view=app(ProcessNavigation::class)->decorate($view,$allowed,$id);
|
||||
$view['view_revision']=hash('sha256',Payables::canonical([$fingerprint,$view]));
|
||||
return response()->json($view)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
|
||||
/** Mounts the existing shared requisition adapter in a fresh, pinned QA composition. */
|
||||
final class InternalRequisitionQa
|
||||
{
|
||||
public const FILES = [
|
||||
'packages/purchasing/src/RequisitionPort.php',
|
||||
'packages/purchasing/src/RequisitionFailure.php',
|
||||
'packages/purchasing/src/InternalRequisitions.php',
|
||||
'integrations/sgf/internal-requisitions/bootstrap.php',
|
||||
'integrations/sgf/internal-requisitions/Schema.php',
|
||||
'integrations/sgf/internal-requisitions/Port.php',
|
||||
'integrations/sgf/internal-requisitions/Controller.php',
|
||||
'integrations/sgf/internal-requisitions/routes.php',
|
||||
'integrations/sgf/internal-requisitions/screen.php',
|
||||
'integrations/sgf/internal-requisitions/screen.mjs',
|
||||
];
|
||||
|
||||
public static function verifiedBundle(string $bundle, string $pin): string
|
||||
{
|
||||
$root = realpath($bundle);
|
||||
if ($root === false || is_link($bundle) || ! preg_match('/^[a-f0-9]{64}$/D', $pin)
|
||||
|| ! is_file($root.'/manifest.json') || is_link($root.'/manifest.json')
|
||||
|| ! hash_equals($pin, hash_file('sha256', $root.'/manifest.json'))) {
|
||||
throw new LogicException('REQUISITION_QA_BUNDLE_REFUSED');
|
||||
}
|
||||
$manifest = json_decode(file_get_contents($root.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
$files = $manifest['files'] ?? [];
|
||||
$expected = self::FILES; $actual = array_keys($files); sort($expected); sort($actual);
|
||||
if (($manifest['format'] ?? null) !== 'sgf.requisitions.qa-bundle.v1' || $actual !== $expected) {
|
||||
throw new LogicException('REQUISITION_QA_MANIFEST_REFUSED');
|
||||
}
|
||||
foreach ($files as $relative => $hash) {
|
||||
$file = realpath($root.'/'.$relative);
|
||||
if ($file === false || ! str_starts_with(strtolower($file), strtolower($root.DIRECTORY_SEPARATOR))
|
||||
|| ! is_file($file) || ! is_string($hash) || ! preg_match('/^[a-f0-9]{64}$/D', $hash)
|
||||
|| ! hash_equals($hash, hash_file('sha256', $file))) {
|
||||
throw new LogicException('REQUISITION_QA_SOURCE_REFUSED');
|
||||
}
|
||||
$part = $root;
|
||||
foreach (explode('/', $relative) as $segment) {
|
||||
$part .= '/'.$segment;
|
||||
if (is_link($part)) throw new LogicException('REQUISITION_QA_LINK_REFUSED');
|
||||
}
|
||||
}
|
||||
return $root;
|
||||
}
|
||||
|
||||
public static function mount(): ?string
|
||||
{
|
||||
if (config('internal_requisitions.qa_enabled') !== true) return null;
|
||||
if (! app()->environment('budget-testing')) throw new LogicException('REQUISITION_QA_ENVIRONMENT_REFUSED');
|
||||
$c = DB::connection(); $config = $c->getConfig();
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
if (! preg_match('/^[a-f0-9]{12}$/D', $run) || ($config['driver'] ?? null) !== 'pgsql'
|
||||
|| ($config['host'] ?? null) !== '127.0.0.1' || (string) ($config['port'] ?? '') !== '55439'
|
||||
|| ! preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D', $config['database'] ?? '', $m)
|
||||
|| ($config['username'] ?? null) !== 'ir_composition_'.$m[1]
|
||||
|| ! empty($config['url']) || ! empty($config['read']) || ! empty($config['write'])
|
||||
|| ($config['search_path'] ?? 'public') !== 'public') throw new LogicException('REQUISITION_QA_DATABASE_REFUSED');
|
||||
$identity = $c->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,
|
||||
rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
if ($identity->db !== $config['database'] || $identity->role !== $config['username'] || $identity->elevated
|
||||
|| $identity->cluster !== 'sgf_modular_regression_'.$run) throw new LogicException('REQUISITION_QA_IDENTITY_REFUSED');
|
||||
$bundle = self::verifiedBundle((string) config('internal_requisitions.qa_bundle'), (string) config('internal_requisitions.qa_manifest_sha256'));
|
||||
$expected = 'C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/requisition-composition-'.$m[1].'/bundle';
|
||||
if (strtolower(str_replace('\\', '/', $bundle)) !== strtolower($expected)) throw new LogicException('REQUISITION_QA_LOCATION_REFUSED');
|
||||
require_once $bundle.'/integrations/sgf/internal-requisitions/bootstrap.php';
|
||||
return $bundle.'/integrations/sgf/internal-requisitions/routes.php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{PurchaseOrder,Invoice,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{InvoiceWorkflow,ReceiptWorkflow,RequisitionWorkflow,InventoryDispositions,InventoryReversals,CurrentActor,OrganizationAccess};
|
||||
|
||||
/** Transport around the original supplier-document matching and budget-consumption workflow. */
|
||||
final class InvoiceController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.invoices_enabled')===true,404);}
|
||||
private function viewer(object $a):void{abort_unless($a->hasFunctions(['admin','finance']),403);}
|
||||
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
|
||||
private function generation():string{return $this->hash(request()->session()->get('company_context'));}
|
||||
private function order(int $id,array $b,object $a):PurchaseOrder{$this->viewer($a);$r=PurchaseOrder::visibleTo($a)->where('id',$id)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->first();abort_unless($r,403,'Ordem fora da origem autorizada.');return $r;}
|
||||
private function state(PurchaseOrder $order):array{
|
||||
$lines=$order->items()->orderBy('id')->get();$effects=[];foreach($lines as$line)$effects[]=['line'=>$line->id,'returned'=>app(InventoryDispositions::class)->returnedForOrderLine($line->id),'reversed'=>app(InventoryReversals::class)->voidedReceiptQuantity($line->id,'accepted_milli')];
|
||||
return ['supplier'=>\Tempo\Purchasing\Embedded\Models\Supplier::findOrFail($order->supplier_id)->toArray(),'request'=>\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id)->load('steps')->toArray(),'conflicts'=>\Tempo\Purchasing\Embedded\Models\ConflictDeclaration::where(fn($q)=>$q->where('requisition_id',$order->requisition_id)->orWhere('supplier_id',$order->supplier_id))->orderBy('id')->get()->toArray(),'budgets'=>DB::table('budgets')->whereIn('id',DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->select('budget_id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'order'=>$order->load('items')->toArray(),'receipts'=>DB::table('goods_receipt_items')->whereIn('purchase_order_item_id',$lines->pluck('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'physical_corrections'=>$effects,'invoices'=>Invoice::where('purchase_order_id',$order->id)->with('items')->orderBy('id')->get()->toArray(),'budget_entries'=>DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'budget_consumptions'=>DB::table('budget_consumptions')->whereIn('invoice_id',Invoice::where('purchase_order_id',$order->id)->select('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
|
||||
}
|
||||
private function input(Request $http,array $data,object $actor):Request{$r=Request::create('/embedded/supplier-invoice','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;}
|
||||
private function dispatch(string $action,array $data,PurchaseOrder $order,object $actor,Request $http):array{
|
||||
$workflow=app(InvoiceWorkflow::class);
|
||||
if($action==='register'){
|
||||
abort_if(array_diff(array_keys($data),['number','issued_on','subtotal','tax','total','items']),422);Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:purchase_order_item_id,quantity,unit,unit_price,tax_rate'])->validate();
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\InvoiceController)->store($this->input($http,$data,$actor),$order,$workflow);
|
||||
$invoice=Invoice::where('purchase_order_id',$order->id)->where('created_by',$actor->id)->orderByDesc('id')->firstOrFail();
|
||||
}else{
|
||||
abort_if(array_diff(array_keys($data),['invoice_id']),422);$d=Validator::make($data,['invoice_id'=>'required|integer|min:1'])->validate();$invoice=Invoice::where('purchase_order_id',$order->id)->findOrFail($d['invoice_id']);$workflow->validate($actor,$invoice);$invoice->refresh();
|
||||
}
|
||||
return ['invoice'=>$invoice,'result'=>['invoice_id'=>$invoice->id,'order_id'=>$order->id,'status'=>$invoice->status],'impact'=>['operation'=>$action==='register'?'Registar factura recebida pendente':'Validar conferência da factura','invoice'=>$invoice->load('items')->toArray(),'budget_consumption_minor'=>$action==='validate'?(int)$invoice->total_minor:0,'stock_changed'=>false,'payable_created'=>false,'payment_created'=>false,'ledger_posted'=>false]];
|
||||
}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','validate']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_invoices.'.$r,$c->unitId());return $out;}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_invoices.view',fn($a)=>$this->viewer($a));$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$link=config('receipt_qa.obligations_enabled')===true&&app(\App\Modules\Identity\Services\Permissions::class)->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.view',$c->unitId());return response()->view('supplier-invoice-workspace',['obligations'=>$link,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($d){$this->viewer($a);return response()->json(PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->orderBy('id')->paginate(25,['id','number','status','currency'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$rights){$order=$this->order($id,$b,$a);$state=$this->state($order);$revision=$this->hash($state);$matching=config('receipt_qa.invoice_progress_enabled')===true?app(InvoiceMatchingProgress::class)->forOrder($order,$state):null;$workflow=app(InvoiceWorkflow::class);foreach($state['invoices']as&$i)$i['can_validate']=$rights['validate']&&$workflow->reviewerEligible($a,Invoice::findOrFail($i['id']));unset($i);return response()->json(['order'=>$state['order'],'invoices'=>$state['invoices'],'revision'=>$revision,'matching_progress'=>$matching,'can_prepare'=>$rights['prepare']&&$a->hasFunctions(['finance'])&&!app(OrganizationAccess::class)->maintenanceOnly($a),'receipt_progress'=>app(ReceiptWorkflow::class)->progress($order),'physical_corrections'=>$state['physical_corrections']]);});}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$d){$order=$this->order($id,$b,$a);return response()->json(AuditLog::where('requisition_id',$order->requisition_id)->whereIn('action',['invoice.created','invoice.validated','sgf.invoice.command'])->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:register,validate','reason'=>'required|string|min:10|max:2000','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536,422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($d['action']==='register'?'prepare':'validate'),function($a,$b,$f)use($id,$d,$r,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(hash_equals($this->hash($this->state($order)),$d['revision']),409,'A conferência mudou. Actualize a ordem.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$order,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if($d['action']==='register'){unset($out['impact']['invoice']['id']);foreach($out['impact']['invoice']['items']as&$line)unset($line['id'],$line['invoice_id']);unset($line);}$cut=$d+['order_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['reason'=>$d['reason']]]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['register','validate'],true),409);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($cut['action']==='register'?'prepare':'validate'),function($a,$b,$f,$origin)use($id,$r,$cut,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(($cut['order_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'O contexto ou a prévia mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data'],$cut['reason']]);$old=AuditLog::where('requisition_id',$order->requisition_id)->where('user_id',$a->id)->where('action','sgf.invoice.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outro conteúdo.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($order)),$cut['revision']),409,'A base mudou desde a conferência.');$before=$this->state($order);$out=$this->dispatch($cut['action'],$cut['data'],$order,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.invoice.command',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),['invoices'=>$before['invoices']],$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'reason'=>$cut['reason'],'command'=>$cut['action'],'result'=>$out['result'],'invoice'=>$out['invoice']->load('items')->toArray()]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
|
||||
|
||||
/** Read projection of the native matching quantities; never a reservation or writer. */
|
||||
final class InvoiceMatchingProgress
|
||||
{
|
||||
public function quantities(int $ordered,int $accepted,int $returned,int $reversed,int $validated,int $pending):array
|
||||
{
|
||||
$net=$accepted-$returned-$reversed;$ceiling=min($ordered,$net);
|
||||
return ['ordered_milli'=>$ordered,'accepted_milli'=>$accepted,'returned_milli'=>$returned,'reversed_milli'=>$reversed,'accepted_net_milli'=>$net,'validated_milli'=>$validated,'pending_milli'=>$pending,'remaining_milli'=>max(0,$ceiling-$validated),'deficit_milli'=>max(0,$validated-$ceiling)];
|
||||
}
|
||||
public function forOrder(object $order,array $state):array
|
||||
{
|
||||
$ids=array_column($state['order']['items'],'id');if($ids===[])return [];
|
||||
$accepted=DB::table('goods_receipt_items')->where('tenant_id',$order->tenant_id)->whereIn('purchase_order_item_id',$ids)->groupBy('purchase_order_item_id')->selectRaw('purchase_order_item_id,SUM(accepted_milli) AS accepted')->get()->keyBy('purchase_order_item_id');
|
||||
$invoiced=DB::table('invoice_items as l')->join('invoices as i','i.id','=','l.invoice_id')->where(['l.tenant_id'=>$order->tenant_id,'i.tenant_id'=>$order->tenant_id,'i.purchase_order_id'=>$order->id])->whereIn('l.purchase_order_item_id',$ids)->groupBy('l.purchase_order_item_id')->selectRaw("l.purchase_order_item_id,SUM(CASE WHEN i.status='validated' THEN l.quantity_milli ELSE 0 END) AS validated,SUM(CASE WHEN i.status='pending' THEN l.quantity_milli ELSE 0 END) AS pending")->get()->keyBy('purchase_order_item_id');
|
||||
$effects=array_column($state['physical_corrections'],null,'line');$rows=[];
|
||||
foreach($state['order']['items']as$line){$id=$line['id'];
|
||||
$rows[]=['line_id'=>$id,'description'=>$line['description'],'unit'=>$line['unit']]+$this->quantities(ReceiptWorkflow::milli($line['quantity']),(int)($accepted[$id]->accepted??0),(int)($effects[$id]['returned']??0),(int)($effects[$id]['reversed']??0),(int)($invoiced[$id]->validated??0),(int)($invoiced[$id]->pending??0));
|
||||
}return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Invoice,PurchaseOrder,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{SupplierPayables,RequisitionWorkflow};
|
||||
|
||||
/** Explicit recognition in the original native book; no imported mirror or second writer. */
|
||||
final class ObligationController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.obligations_enabled')===true,404); }
|
||||
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function orders(object $a,array $b) {
|
||||
return PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)));
|
||||
}
|
||||
private function invoice(int $id,object $a,array $b):Invoice {
|
||||
return Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'))->find($id)??abort(403,'Documento fora da origem autorizada.');
|
||||
}
|
||||
private function adapter(object $a):SupplierPayables {
|
||||
$service=app(SupplierPayables::class);$service->readContext($a);return $service;
|
||||
}
|
||||
private function state(object $a,Invoice $invoice,SupplierPayables $service):array {
|
||||
$scope=['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0];
|
||||
$sources=DB::table('pa_sources')->where($scope)->where('local_source_id',$invoice->id)->select('id');
|
||||
return ['document'=>$service->document($a,$invoice->id),
|
||||
'proposals'=>DB::table('pa_proposals')->where($scope)->whereIn('source_id',$sources)->orderByDesc('id')->limit(1)->get()->all(),
|
||||
'obligations'=>DB::table('pa_obligations')->where($scope)->whereIn('source_id',$sources)->orderBy('id')->get()->all(),
|
||||
'identity'=>DB::table('pa_access')->where($scope)->where('user_id',$a->id)->orderBy('action')->get(['action','person'])->all()];
|
||||
}
|
||||
private function rights():array {
|
||||
$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$rights=[];
|
||||
foreach(['prepare','validate']as$r)$rights[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.'.$r,$c->unitId());return $rights;
|
||||
}
|
||||
private function commandId(string $action,int $proposal,Invoice $invoice,object $a):int {
|
||||
if($action==='prepare')return $invoice->id;
|
||||
abort_unless(DB::table('pa_proposals as p')->join('pa_sources as s','s.id','=','p.source_id')->where(['p.id'=>$proposal,'p.tenant_id'=>$a->tenant_id,'p.company_id'=>0,'p.unit_id'=>0,'s.tenant_id'=>$a->tenant_id,'s.company_id'=>0,'s.unit_id'=>0,'s.local_source_id'=>$invoice->id])->exists(),403,'Proposta fora do documento autorizado.');return $proposal;
|
||||
}
|
||||
private function execute(Payables $engine,array $cut,Invoice $invoice,object $a):array {
|
||||
try{return $engine->command($cut['action'],$this->commandId($cut['action'],$cut['proposal_id'],$invoice,$a),$cut['reason'],$cut['operation_key']);}
|
||||
catch(\DomainException|\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$rt->run('purchasing_obligations.view',fn($a)=>$this->adapter($a));return response()->view('supplier-obligation-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);
|
||||
}
|
||||
public function index(Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'sometimes|in:pending,validated','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $rt->run('purchasing_obligations.view',function($a,$b)use($d){$this->adapter($a);$q=Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(? in number)>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','total_minor','purchase_order_id'],'page',(int)($d['page']??1)));});
|
||||
}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['proposal_page'=>'sometimes|integer|min:1|max:10000','event_page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$rights=$this->rights();
|
||||
return $rt->run('purchasing_obligations.view',function($a,$b)use($id,$d,$rights){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$out=$service->engine($a)->listingPage([$id],$d['proposal_page']??1,$d['event_page']??1,25);foreach($rights as$right=>$allowed)$out['permissions'][$right]=$allowed&&$service->can($a,$right);return response()->json($out+['revision'=>$this->hash($this->state($a,$invoice,$service))]);});
|
||||
}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['action'=>'required|in:prepare,validate','proposal_id'=>'required|integer|min:0','operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();
|
||||
return $rt->run('purchasing_obligations.'.$d['action'],function($a,$b,$f)use($id,$d,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);abort_unless(hash_equals($this->hash($this->state($a,$invoice,$service)),$d['revision']),409,'A conferência mudou.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$result=$this->execute($service->engine($a),$d,$invoice,$a);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
unset($result['proposal_id'],$result['obligation_id']);$cut=$d+['person_revision'=>$this->hash($this->state($a,$invoice,$service)['identity']),'invoice_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['result'=>$result,'principal_minor'=>$invoice->total_minor,'due_on'=>null,'due_status'=>'unknown','budget_changed'=>false,'stock_changed'=>false,'payment_created'=>false,'ledger_posted'=>false,'reason'=>$d['reason']]]);});
|
||||
}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $rt) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['prepare','validate'],true),409);$generation=$this->generation();
|
||||
return $rt->run('purchasing_obligations.'.$cut['action'],function($a,$b,$f,$origin)use($id,$cut,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$engine=$service->engine($a);abort_unless(($cut['invoice_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'O contexto ou a prévia mudou.');
|
||||
// A committed retry still calls the kernel: current source/payment eligibility is rechecked.
|
||||
$receipt=DB::table('pa_commands')->where(['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0,'actor_id'=>$a->id,'command_key'=>$cut['operation_key']])->first();if($receipt)abort_unless((json_decode($receipt->result,true)['local_source_id']??null)===$id,409);
|
||||
$before=$this->state($a,$invoice,$service);abort_unless(($cut['person_revision']??null)===$this->hash($before['identity']),409,'Identidade alterada.');if(!$receipt)abort_unless(hash_equals($this->hash($before),$cut['revision']),409,'A base mudou desde a conferência.');
|
||||
$result=$this->execute($engine,$cut,$invoice,$a);
|
||||
if(!($result['replayed']??false))app(RequisitionWorkflow::class)->audit($a,'sgf.obligation.command',Requisition::findOrFail(PurchaseOrder::findOrFail($invoice->purchase_order_id)->requisition_id),$before,$origin+['operation_key'=>$cut['operation_key'],'reason'=>$cut['reason'],'result'=>$result,'after'=>$this->state($a,$invoice,$service)]);
|
||||
return response()->json($result,($result['state']??'')==='conflict'?409:(($result['replayed']??false)?200:201));});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Tempo\Purchasing\Embedded\Models\{PurchaseAward,PurchaseOrder,Requisition,Quotation};
|
||||
use Tempo\Purchasing\Embedded\Services\{PurchaseWorkflow,ProposalComparison,RequisitionWorkflow};
|
||||
|
||||
/** Issuance from an already approved award; no supplier selection or approval by this controller. */
|
||||
final class OrderController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.orders_enabled')===true,404);}
|
||||
private function viewActor(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function award(int $id,array $binding,object $actor):array{
|
||||
$this->viewActor($actor);
|
||||
$award=PurchaseAward::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($award,403);
|
||||
$request=Requisition::visibleTo($actor)->findOrFail($award->requisition_id);
|
||||
return [$award,$request];
|
||||
}
|
||||
private function basis(PurchaseAward $award,Requisition $request):array{
|
||||
$quote=Quotation::findOrFail($award->quotation_id)->load(['items','supplier']);
|
||||
$basis=['award'=>$award->load('approvals')->toArray(),'requisition'=>$request->load(['items','steps'])->toArray(),'quote'=>$quote->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
|
||||
return ['revision'=>hash('sha256',json_encode($basis,JSON_THROW_ON_ERROR)),'basis'=>$basis];
|
||||
}
|
||||
private function output(PurchaseOrder $order,bool $replayed):array{return ['order'=>$order->load('items')->toArray(),'replayed'=>$replayed,'receipt_url'=>'/purchasing/receipts','stock_created'=>false,'invoice_created'=>false,'payment_created'=>false];}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_orders.view',fn($actor)=>$this->viewActor($actor));return response()->view('order-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_orders.view',function($actor,$binding)use($d){$this->viewActor($actor);return response()->json(PurchaseAward::where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->whereIn('requisition_id',Requisition::visibleTo($actor)->select('id'))->orderBy('id')->paginate(25,['id','requisition_id','status','total_minor'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$ctx=app(\App\Shared\Context\CompanyContext::class);$scope=$ctx->identifiers();$can=app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$scope['tenant_id'],$scope['company_id'],'purchasing_orders.issue',$ctx->unitId());return $runtime->run('purchasing_orders.view',function($actor,$binding)use($id,$can){[$award,$request]=$this->award($id,$binding,$actor);$basis=$this->basis($award,$request);$order=PurchaseOrder::where('requisition_id',$request->id)->first();return response()->json($basis+['can_issue'=>$can&&app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),'existing'=>$order?$this->output($order,true):null]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['revision'=>'required|string|size:64']);abort_if(array_diff(array_keys($r->all()),['revision']),422);return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint)use($id,$d){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),403);$basis=$this->basis($award,$request);abort_unless(hash_equals($basis['revision'],$d['revision']),409,'A base aprovada mudou. Confira novamente.');$cut=['award'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'revision'=>$basis['revision'],'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['supplier'=>$basis['basis']['quote']['supplier']['name'],'total_minor'=>$award->total_minor,'currency'=>$basis['basis']['quote']['currency'],'delivery_date'=>$basis['basis']['award']['delivery_date'],'delivery_location'=>$award->delivery_location,'items'=>$basis['basis']['quote']['items'],'budget'=>'O workflow original revalida e regista o compromisso, se configurado.','stock'=>false,'invoice'=>false,'payment'=>false]]);});}
|
||||
public function issue(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['preview'=>'required|string|max:32768']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint,$origin)use($id,$cut){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(($cut['award']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O contexto ou a prévia mudou.');$existing=PurchaseOrder::where('requisition_id',$request->id)->first();abort_unless($existing||hash_equals($this->basis($award,$request)['revision'],$cut['revision']),409,'A base mudou desde a conferência.');$order=app(PurchaseWorkflow::class)->issue($actor,$award);if(!$existing)app(RequisitionWorkflow::class)->audit($actor,'sgf.order.issued',$request,null,$origin+['order_id'=>$order->id,'award_id'=>$award->id,'binding_revision'=>$fingerprint]);return response()->json($this->output($order,(bool)$existing),$existing?200:201);});}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
/** Classification only; balances/terms remain in their existing authoritative readers. */
|
||||
final class PayableAge
|
||||
{
|
||||
public static function classify(array $row,?string $reference):array
|
||||
{
|
||||
$days=null;$bucket=null;
|
||||
if($reference!==null){
|
||||
if(!$row['obligation_id'])$bucket='unrecognised';
|
||||
elseif($row['source_conflict']||$row['balance_minor']===null)$bucket='indeterminate';
|
||||
elseif($row['balance_minor']==='0')$bucket='settled';
|
||||
elseif(($due=$row['reviewed_terms']['due_on']??null)===null)$bucket='unknown';
|
||||
else{$zone=new \DateTimeZone('UTC');$days=max(0,(int)(new \DateTimeImmutable($due,$zone))->diff(new \DateTimeImmutable($reference,$zone))->format('%r%a'));$bucket=$days===0?'not_due':($days<=30?'days_1_30':($days<=60?'days_31_60':($days<=90?'days_61_90':'days_over_90')));}
|
||||
}
|
||||
return ['reviewed_age_days'=>$days,'reviewed_age_bucket'=>$bucket];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Tempo\Purchasing\Embedded\Services\RequisitionWorkflow;
|
||||
|
||||
final class PayableDueController
|
||||
{
|
||||
public const POLICY='SGF-PAYABLE-DUE-QA-v1';
|
||||
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
|
||||
private function exact(array $v,array $rules):array{abort_if(array_diff(array_keys($v),array_keys($rules)),422);return Validator::make($v,$rules)->validate();}
|
||||
private function run(Request $r,string $action,\Closure $fn):mixed
|
||||
{
|
||||
abort_unless(config('payable_due.enabled')===true,404);$physical=CompositionRuntime::physical();$host=DB::connection();$user=(int)$r->user()->id;$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$generation=$this->hash($r->session()->get('company_context',[]));
|
||||
return app(ComposedFinance::class)->run('view',function($engine,$settlements,$actor,$origin,$orders)use($action,$fn,$host,$user,$scope,$generation,$physical){
|
||||
$meta=DB::table('pd_due_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$physical['composition']&&hash_equals($meta->source_hash,(string)config('payable_due.schema_sha256')),503,'Schema de vencimentos não conferido.');
|
||||
if($action!=='view'){$expected=$action==='prepare'?13:14;$profile=$action==='prepare'?'finance_prepare':'finance_review';abort_unless($user===$expected&&$origin['profile']===$profile&&$host->table('permission_grants')->where($scope+['user_id'=>$user,'permission'=>'supplier_payables.due.'.$action])->lockForUpdate()->first(),403);}
|
||||
$person=DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$actor->id,'action'=>'view'])->lockForUpdate()->value('person');abort_unless(is_string($person)&&$person!=='',403);
|
||||
return $fn($engine,$actor,$origin,$orders,$scope,$user,$generation,$this->hash([$physical,$origin['sgf_context_key'],$scope]));
|
||||
});
|
||||
}
|
||||
private function input(array $v):array
|
||||
{
|
||||
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','obligation_id'=>'required|integer|min:1','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:2000']);$d['obligation_id']=(int)$d['obligation_id'];$d['version']=(int)$d['version'];
|
||||
if($d['action']==='prepare'){$data=$d['data'];if(isset($data['evidence'])&&is_string($data['evidence']))$data['evidence']=trim($data['evidence']);$d['data']=$this->exact($data,['due_on'=>'present|nullable|date_format:Y-m-d|after_or_equal:2000-01-01|before_or_equal:2100-12-31','evidence'=>'required|string|min:10|max:1000']);}
|
||||
else $d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;
|
||||
}
|
||||
private function basis($engine,$actor,$orders,array $scope,int $id):array
|
||||
{
|
||||
$source=DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('invoices as i','i.id','=','s.local_source_id')->where(['o.id'=>$id,'o.tenant_id'=>$actor->tenant_id,'o.company_id'=>0,'o.unit_id'=>0,'s.tenant_id'=>$actor->tenant_id,'s.company_id'=>0,'s.unit_id'=>0,'i.tenant_id'=>$actor->tenant_id])->whereIn('i.purchase_order_id',$orders->select('id'))->first(['i.id']);abort_unless($source,403,'Obrigação fora da origem autorizada.');
|
||||
$identities=DB::table('pa_sources')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'local_source_id'=>$source->id])->count();abort_unless($identities===1,409,'Identidade financeira ambígua.');
|
||||
$book=$engine->positionsPage([(int)$source->id]);$position=collect($book['obligations'])->firstWhere('id',$id);$document=collect($book['documents'])->firstWhere('local_id',(int)$source->id);abort_unless($position&&$document&&!$position['source_conflict']&&$position['balance_minor']!==null,409,'Origem ou saldo exige nova conferência.');
|
||||
$q=DB::table('pd_due_versions')->where($scope+['obligation_id'=>$id]);$last=(clone$q)->orderByDesc('version')->first();$approved=(clone$q)->where('state','approved')->orderByDesc('version')->first();
|
||||
return ['current_person'=>DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$actor->id,'action'=>'view'])->value('person'),'version'=>(int)($last?->version??0),'last'=>$last?(array)$last:null,'approved'=>$approved?(array)$approved:null,'position'=>$position,'document'=>$document,'source_basis'=>$this->hash([$position,$document])];
|
||||
}
|
||||
private function execute($actor,array $scope,int $host,array $d,array $before):array
|
||||
{
|
||||
abort_unless($before['version']===$d['version'],409,'A versão mudou; confira novamente.');$last=$before['last'];$person=DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$actor->id,'action'=>'view'])->value('person');
|
||||
if($d['action']==='prepare'){$state='prepared';$data=$d['data']+['_prepared_person'=>$person];$prepared=$host;$native=$actor->id;}
|
||||
else{abort_unless($last&&$last['state']==='prepared'&&(int)$last['prepared_host_id']!==$host&&(int)$last['prepared_native_id']!==$actor->id,403,'Revisão exige preparação alheia actual.');abort_unless(hash_equals($last['source_basis'],$before['source_basis']),409,'Origem ou saldo alterado desde a preparação; prepare novamente.');$state=$d['data']['decision'];$data=json_decode($last['data'],true);$preparedPerson=DB::table('pa_access')->where(['tenant_id'=>$actor->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$last['prepared_native_id'],'action'=>'view'])->lockForUpdate()->value('person');abort_unless(isset($data['_prepared_person'])&&$preparedPerson===$data['_prepared_person'],409,'Identidade do preparador alterada; prepare novamente.');abort_unless($person!==$preparedPerson,403,'A mesma pessoa não pode rever a preparação.');$prepared=$last['prepared_host_id'];$native=$last['prepared_native_id'];}
|
||||
$version=$before['version']+1;$id=DB::table('pd_due_versions')->insertGetId($scope+['obligation_id'=>$d['obligation_id'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'source_basis'=>$before['source_basis'],'prepared_host_id'=>$prepared,'prepared_native_id'=>$native,'host_actor_id'=>$host,'actor_id'=>$actor->id,'data'=>json_encode($data,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],'created_at'=>now()->utc()]);
|
||||
$prior=$before['approved']?json_decode($before['approved']['data'],true):null;$impact=['before'=>['version'=>$before['version'],'state'=>$last['state']??null,'reviewed_terms'=>$prior],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'reviewed_terms'=>$state==='approved'?$data:$prior],'source_basis'=>$before['source_basis'],'principal_minor'=>(string)$before['position']['principal_minor'],'balance_minor'=>(string)$before['position']['balance_minor'],'currency'=>$before['position']['currency'],'original_due_on'=>$before['position']['due_on'],'payment_created'=>false,'journal_entries'=>0,'original_obligation_changed'=>false];
|
||||
return ['id'=>$id,'obligation_id'=>$d['obligation_id'],'version'=>$version,'state'=>$state,'impact'=>$impact];
|
||||
}
|
||||
public function preview(Request $r)
|
||||
{
|
||||
$d=$this->input($r->all());$key=strtolower((string)$r->header('Idempotency-Key'));$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,$d['action'],function($engine,$a,$origin,$orders,$scope,$host,$generation,$binding)use($d,$key){$before=$this->basis($engine,$a,$orders,$scope,$d['obligation_id']);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($a,$scope,$host,$d,$before);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$host,'generation'=>$generation,'binding'=>$binding,'input'=>$d,'key'=>$key,'observed'=>time(),'basis'=>$this->hash($before),'impact'=>$out['impact']];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']])->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function confirm(Request $r)
|
||||
{
|
||||
$v=$this->exact($r->all(),['preview'=>'required|string|max:65536']);try{$cut=json_decode(Crypt::decryptString($v['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($cut)&&is_array($cut['input']??null),409);$d=$this->input($cut['input']);return$this->run($r,$d['action'],function($engine,$a,$origin,$orders,$scope,$host,$generation,$binding)use($cut,$d){abort_unless(($cut['actor']??null)===$host&&($cut['generation']??null)===$generation&&($cut['binding']??null)===$binding&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'Prévia/contexto alterados.');$before=$this->basis($engine,$a,$orders,$scope,$d['obligation_id']);$command=$scope+['host_actor_id'=>$host,'command_key'=>$cut['key']];$old=DB::table('pd_due_commands')->where($command)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d))&&hash_equals($old->binding_hash,$binding),409,'Chave utilizada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true])->header('Cache-Control','no-store');}abort_unless(hash_equals($cut['basis'],$this->hash($before)),409,'Estado alterado desde a prévia. Confira novamente.');$out=$this->execute($a,$scope,$host,$d,$before);abort_unless($out['impact']===$cut['impact'],409,'Impacto alterado.');app(RequisitionWorkflow::class)->audit($a,'sgf.finance.due.command',null,$before,['host_actor_id'=>$host,'operation_key'=>$cut['key'],'reason'=>$d['reason'],'after'=>$out]);DB::table('pd_due_commands')->insert($command+['actor_id'=>$a->id,'request_hash'=>$this->hash($d),'binding_hash'=>$binding,'input'=>json_encode($d,JSON_THROW_ON_ERROR),'result'=>json_encode($out,JSON_THROW_ON_ERROR),'created_at'=>now()->utc()]);return response()->json($out+['replayed'=>false],201)->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function receipt(Request $r,string $key)
|
||||
{
|
||||
abort_if($r->query()!==[],422);$this->exact(['key'=>$key],['key'=>'required|uuid']);return$this->run($r,'view',function($engine,$a,$origin,$orders,$scope,$host,$generation,$binding)use($key){$old=DB::table('pd_due_commands')->where($scope+['host_actor_id'=>$host,'command_key'=>$key])->first();abort_unless($old,404);abort_unless(hash_equals($old->binding_hash,$binding),409);$d=json_decode($old->input,true);$this->basis($engine,$a,$orders,$scope,$d['obligation_id']);return response()->json(['result'=>json_decode($old->result,true),'reason'=>$d['reason'],'created_at'=>$old->created_at,'historical_receipt_only'=>true,'operation_executed'=>false])->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function index(Request $r)
|
||||
{
|
||||
$d=$this->exact($r->query(),['obligation_id'=>'required|integer|min:1','reference_on'=>'required|date_format:Y-m-d','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$hostDb=DB::connection();return$this->run($r,'view',function($engine,$a,$origin,$orders,$scope,$host)use($d,$hostDb){$before=$this->basis($engine,$a,$orders,$scope,(int)$d['obligation_id']);$approved=$before['approved'];$terms=$approved?json_decode($approved['data'],true):null;$date=$terms['due_on']??null;$days=$date===null?null:max(0,(int)(new \DateTimeImmutable($date,new \DateTimeZone('UTC')))->diff(new \DateTimeImmutable($d['reference_on'],new \DateTimeZone('UTC')))->format('%r%a'));$q=DB::table('pd_due_versions')->where($scope+['obligation_id'=>(int)$d['obligation_id']]);$history=$q->orderByDesc('version')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));foreach($history->items()as$row)$row->data=json_decode($row->data,true);$cap=fn($action,$expected)=>$host===$expected&&$hostDb->table('permission_grants')->where($scope+['user_id'=>$host,'permission'=>'supplier_payables.due.'.$action])->exists();return response()->json(['basis'=>$before,'reviewed_terms'=>$terms,'reference_on'=>$d['reference_on'],'days_overdue'=>$days,'due_status'=>$date===null?'unknown':($date<$d['reference_on']?'overdue':'not_overdue'),'history'=>$history,'capabilities'=>['prepare'=>$cap('prepare',13),'review'=>$cap('review',14)],'notice'=>'Condição administrativa QA revista, separada do vencimento original. Saldo actual; referência apenas classifica atraso, sem juros ou pagamento.'])->header('Cache-Control','no-store');});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Embedded\Models\Invoice;
|
||||
|
||||
/** Paged source window; positions come exclusively from the original payable engine. */
|
||||
final class PayablePositionReadController
|
||||
{
|
||||
private function reviewedTerms(array $scope,array $ids):array
|
||||
{
|
||||
if(config('payable_due.enabled')!==true||$ids===[])return [];
|
||||
$meta=DB::table('pd_due_meta')->where('policy',PayableDueController::POLICY)->first();abort_unless($meta&&DB::connection()->getDatabaseName()==='compras_receipts_test_'.$meta->composition&&hash_equals($meta->source_hash,(string)config('payable_due.schema_sha256')),503,'Condições administrativas não conferidas.');
|
||||
$q=DB::table('pd_due_versions')->where($scope)->whereIn('obligation_id',$ids)->where('state','approved')->whereNotExists(function($next){$next->selectRaw('1')->from('pd_due_versions as newer')->where('newer.state','approved');foreach(['tenant_id','company_id','unit_id','obligation_id']as$field)$next->whereColumn('newer.'.$field,'pd_due_versions.'.$field);$next->whereColumn('newer.version','>','pd_due_versions.version');});
|
||||
$out=[];foreach($q->get(['obligation_id','version','data','created_at'])as$row){$d=json_decode($row->data,true);$out[$row->obligation_id]=['version'=>(int)$row->version,'due_on'=>$d['due_on'],'evidence'=>$d['evidence'],'reviewed_at'=>$row->created_at];}return$out;
|
||||
}
|
||||
public function index(Request $r,ComposedFinance $finance)
|
||||
{
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100','document_state'=>'sometimes|in:pending,validated','reference_on'=>'sometimes|date_format:Y-m-d']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
try{return $finance->run('view',function($engine,$settlements,$actor,$origin,$orders)use($d){
|
||||
$q=Invoice::query()->where('invoices.tenant_id',$actor->tenant_id)->whereIn('invoices.purchase_order_id',$orders->select('id'))
|
||||
->join('purchase_orders as po',fn($j)=>$j->on('po.id','=','invoices.purchase_order_id')->on('po.tenant_id','=','invoices.tenant_id'))
|
||||
->leftJoin('suppliers as supplier',fn($j)=>$j->on('supplier.id','=','po.supplier_id')->on('supplier.tenant_id','=','po.tenant_id'));
|
||||
if(isset($d['document_state']))$q->where('invoices.status',$d['document_state']);
|
||||
if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($x)=>$x->whereRaw("invoices.number ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("supplier.name ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
$p=$q->orderByDesc('invoices.id')->paginate((int)($d['per_page']??25),['invoices.id'],'page',(int)($d['page']??1));$ids=$p->pluck('id')->map(fn($id)=>(int)$id)->all();$book=$engine->positionsPage($ids);$positions=collect($book['obligations'])->keyBy('source_id');$sources=DB::table('pa_sources')->where('tenant_id',$actor->tenant_id)->whereIn('local_source_id',$ids)->where(['company_id'=>0,'unit_id'=>0])->get(['id','local_source_id'])->groupBy('local_source_id');$documents=collect($book['documents'])->keyBy('local_id');$rows=[];
|
||||
foreach($ids as$id){$source=$documents->get($id);abort_unless($source,409,'Documento de origem alterado durante a consulta.');$identities=$sources->get($id,collect());abort_if($identities->count()>1,409,'Há mais de uma identidade financeira para este documento; exige conferência.');$position=$positions->get($identities->first()?->id);$money=[];foreach(['principal_minor','balance_minor','confirmed_payments_minor','allocated_payments_minor']as$field)$money[$field]=$position&&$position[$field]!==null?(string)$position[$field]:null;$rows[]=['document_id'=>$id,'reference'=>$source['number'],'supplier'=>$source['supplier_name'],'supplier_id'=>$source['supplier_id'],'document_state'=>$source['status'],'currency'=>$position['currency']??$source['currency'],'document_currency'=>$source['currency'],'obligation_id'=>$position['id']??null,'recognition_state'=>$position?'recognised':'not_recognised','payment_state'=>$position['payment_state']??'not_recognised','source_conflict'=>$position['source_conflict']??false,'source_revision'=>$source['revision'],'due_on'=>$position['due_on']??null,'due_status'=>$position['due_status']??'not_recognised','days_overdue'=>$position['days_overdue']??null,'pending'=>$position['documentary_pending']??$source['missing']]+$money;}
|
||||
$terms=$this->reviewedTerms($origin['sgf_host_scope'],array_values(array_filter(array_column($rows,'obligation_id'))));foreach($rows as&$row){$row['reviewed_terms']=$terms[$row['obligation_id']]??null;$row+=PayableAge::classify($row,$d['reference_on']??null);}unset($row);
|
||||
return response()->json(['positions'=>$rows,'aging_reference_on'=>$d['reference_on']??null,'aging_basis'=>'reviewed_administrative_terms_current_balance','pagination'=>['page'=>$p->currentPage(),'last_page'=>$p->lastPage(),'per_page'=>$p->perPage(),'total'=>$p->total()],'observed_at'=>now()->utc()->toIso8601String(),'balance_basis'=>'original_current_payable_engine','totals_basis'=>'visible_page_only','synthetic_only'=>true,'notice'=>'Documentos sem obrigação reconhecida e saldos indeterminados não são apresentados como dívida zero. Cada página é uma leitura actual; não é um relatório histórico.'])->header('Cache-Control','no-store, private');
|
||||
});}catch(\InvalidArgumentException$e){abort(422,$e->getMessage());}catch(\DomainException$e){abort(403,$e->getMessage());}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Tempo\Purchasing\Embedded\Models\AuditLog;
|
||||
|
||||
/** A stage's history must not become a back door into downstream financial payloads. */
|
||||
final class ProcessHistory
|
||||
{
|
||||
public function query(int $request,string $stage)
|
||||
{
|
||||
$patterns=match($stage){
|
||||
'requests'=>['requisition.%','sgf.request.command'],
|
||||
'sourcing'=>['requisition.%','sgf.request.command','quotation.%','award.%','sgf.sourcing.command'],
|
||||
default=>throw new \InvalidArgumentException('Unknown history stage'),
|
||||
};
|
||||
return AuditLog::where('requisition_id',$request)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
/** Links to already visible records; every destination retains its own authority checks. */
|
||||
final class ProcessNavigation
|
||||
{
|
||||
public function decorate(array $view,array $allowed,int $request):array
|
||||
{
|
||||
if(config('receipt_qa.focus_enabled')!==true)return $view;
|
||||
$link=fn(string $stage,array $ids,string $label)=>['href'=>'/purchasing/'.$stage.'?'.http_build_query($ids),'label'=>$label];
|
||||
$view['_links']=[$link('requests',['request'=>$request],'Consultar esta requisição')];
|
||||
if($allowed['sourcing'])$view['_links'][]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
|
||||
foreach($view['stages']as$key=>&$stage){
|
||||
if(!($stage['accessible']??false))continue;
|
||||
$stage['data']=array_map(function($record)use($key,$allowed,$request,$link){
|
||||
$row=is_array($record)?$record:(method_exists($record,'toArray')?$record->toArray():(array)$record);$links=[];
|
||||
if($key==='quotes'&&$allowed['sourcing'])$links[]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
|
||||
if($key==='awards'&&$allowed['orders'])$links[]=$link('orders',['award'=>$row['id']],'Consultar esta adjudicação e ordem');
|
||||
if($key==='orders'){
|
||||
if($allowed['receipts'])$links[]=$link('receipts',['order'=>$row['id']],'Consultar recepções desta ordem');
|
||||
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['id']],'Consultar facturas desta ordem');
|
||||
}
|
||||
if($key==='receipts'&&$allowed['receipts'])$links[]=$link('receipts',['order'=>$row['purchase_order_id'],'receipt'=>$row['id']],'Consultar esta recepção e os seus anexos');
|
||||
if($key==='invoices'){
|
||||
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['purchase_order_id'],'invoice'=>$row['id']],'Consultar esta factura');
|
||||
if($allowed['obligations'])$links[]=$link('obligations',['invoice'=>$row['id']],'Consultar obrigação desta factura');
|
||||
}
|
||||
return $row+['_links'=>$links];
|
||||
},$stage['data']);
|
||||
}unset($stage);return $view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Explicit access to existing synthetic identities, only in the selected local installation. */
|
||||
final class PurchasingEntry
|
||||
{
|
||||
public static function profiles(array $fixture):array
|
||||
{
|
||||
$definitions=['requester'=>['Solicitante','Prepara e submete a necessidade.'],'approver'=>['Chefia','Decide requisições e adjudicações por identidade distinta.'],'actor'=>['Compras','Prepara fornecedores, propostas, comparação e ordens.'],'issuer'=>['Finanças','Revê fornecedores, avalia propostas, regista facturas e prepara obrigações.'],'validator'=>['Armazém','Confere e confirma a recepção física.'],'reviewer'=>['Revisor financeiro','Valida facturas e obrigações por identidade distinta.'],'auditor'=>['Consulta financeira','Consulta autorizada dos documentos financeiros.']];
|
||||
$stages=['requests'=>'Requisições','suppliers'=>'Fornecedores','sourcing'=>'Propostas e adjudicação','orders'=>'Ordens','receipts'=>'Recepções','invoices'=>'Facturas','obligations'=>'Obrigações'];$profiles=[];
|
||||
$grants=DB::table('permission_grants')->where($fixture['scope'])->whereIn('user_id',array_intersect_key($fixture,$definitions))->get(['user_id','permission'])->groupBy('user_id');
|
||||
foreach($definitions as$key=>[$label,$description]){
|
||||
if(!isset($fixture[$key]))continue;$available=[];
|
||||
$rights=($grants[$fixture[$key]]??collect())->pluck('permission')->all();foreach($stages as$stage=>$name)if(in_array('purchasing_'.$stage.'.view',$rights,true))$available[$stage]=$name;
|
||||
$profiles[$key]=['user_id'=>$fixture[$key],'label'=>$label,'description'=>$description,'stages'=>$available];
|
||||
}return $profiles;
|
||||
}
|
||||
private static function guard():void
|
||||
{
|
||||
abort_unless(config('purchasing_workspace.single_installation')===true&&app()->environment('budget-testing')&&in_array(request()->ip(),['127.0.0.1','::1'],true),404);
|
||||
BudgetAdapter::guard();
|
||||
$id=(string)config('purchasing_workspace.id');$db=DB::connection();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$id)&&preg_match('/^[a-f0-9]{12}$/D',$run)
|
||||
&&$db->getDatabaseName()==='budget_core_sgf_'.$id&&$db->getConfig('username')==='ir_composition_'.$id&&(int)$db->getConfig('port')===55439,403);
|
||||
$identity=$db->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->role==='ir_composition_'.$id&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
}
|
||||
public function screen(Request $r)
|
||||
{
|
||||
self::guard();$scope=config('purchasing_workspace.scope');
|
||||
return response()->view('purchasing-entry',['profiles'=>config('purchasing_workspace.profiles'),'company'=>DB::table('companies')->find($scope['company_id']),'unit'=>DB::table('units')->find($scope['unit_id']),'returnTo'=>(string)$r->query('return_to','')])->header('Cache-Control','no-store')->header('X-SGF-Installation',config('purchasing_workspace.id'));
|
||||
}
|
||||
public function enter(Request $r)
|
||||
{
|
||||
self::guard();$d=$r->validate(['role'=>'required|string','stage'=>'required|string','return_to'=>'nullable|string|max:300']);abort_if(array_diff(array_keys($r->all()),['_token','role','stage','return_to']),422);
|
||||
$profile=config('purchasing_workspace.profiles.'.$d['role']);abort_unless(is_array($profile)&&isset($profile['stages'][$d['stage']]),403);
|
||||
$target='/purchasing/'.$d['stage'];
|
||||
if(!empty($d['return_to'])){
|
||||
$parts=parse_url($d['return_to']);abort_unless(is_array($parts)&&!isset($parts['scheme'])&&!isset($parts['host'])&&!isset($parts['fragment'])&&($parts['path']??null)===$target,422);
|
||||
parse_str($parts['query']??'',$query);$allowed=['requests'=>['request'],'suppliers'=>['supplier'],'sourcing'=>['request'],'orders'=>['award'],'receipts'=>['order','receipt','movement'],'invoices'=>['order','invoice'],'obligations'=>['invoice']][$d['stage']];
|
||||
abort_if(array_diff(array_keys($query),$allowed),422);foreach($query as$v)abort_unless(is_string($v)&&preg_match('/^[1-9][0-9]{0,14}$/D',$v),422);
|
||||
$pairs=isset($parts['query'])?explode('&',$parts['query']):[];abort_unless(count($pairs)===count($query),422);
|
||||
if($query){$primary=$d['stage']==='receipts'&&isset($query['movement'])?'movement':$allowed[0];abort_unless(isset($query[$primary]),422);if($primary==='movement')abort_unless(count($query)===1,422);}
|
||||
$target.=isset($parts['query'])?'?'.http_build_query($query):'';
|
||||
}
|
||||
$user=User::findOrFail($profile['user_id']);auth()->login($user);$scope=config('purchasing_workspace.scope');$r->session()->put('auth_version',$user->auth_version);
|
||||
$r->session()->put('company_context',$scope+['module_id'=>'purchasing','token'=>bin2hex(random_bytes(24))]);
|
||||
return redirect($target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{StockItem,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{SupplyPlanning,EquipmentWorkflow};
|
||||
|
||||
/** Bounded catalogue discovery; the existing receipt workflow remains the physical authority. */
|
||||
final class ReceiptCatalogue
|
||||
{
|
||||
public function query(object $order,object $line) {
|
||||
$q=StockItem::query()->whereRaw('lower(btrim(unit))=lower(btrim(?))',[$line->unit]);
|
||||
if($line->purchase_type==='service')return $q->whereRaw('1=0');
|
||||
$fixed=$order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id']??null;
|
||||
if($fixed!==null)$q->whereKey($fixed);
|
||||
if(Schema::hasTable('supply_records')){
|
||||
$mapping=app(SupplyPlanning::class)->latest('delivery')->where('purchase_order_id',$order->id)->where('data->purchase_order_item_id',$line->id)->first();
|
||||
if($mapping)$q->whereKey($mapping->stock_item_id);
|
||||
}
|
||||
if($line->purchase_type==='spare_part'){
|
||||
abort_unless($line->spare_part_id&&$line->equipment_id,422,'A ordem não contém associação autorizada da peça.');
|
||||
$part=app(EquipmentWorkflow::class)->linkedPart((int)$line->spare_part_id,(int)$line->equipment_id,Requisition::findOrFail($order->requisition_id)->department_id);$q->whereKey($part->stock_item_id);
|
||||
}
|
||||
return $q;
|
||||
}
|
||||
public function revision(StockItem $item):string { return hash('sha256',Payables::canonical($item->toArray())); }
|
||||
public function selected(array $data):array {
|
||||
$ids=array_values(array_unique(array_filter(array_column($data['items'],'stock_item_id'))));sort($ids,SORT_NUMERIC);$revisions=[];
|
||||
foreach($ids as$id){$item=StockItem::whereKey($id)->lockForUpdate()->first();abort_unless($item,422,'Artigo fora do catálogo autorizado.');$revisions[(string)$id]=$this->revision($item);}
|
||||
ksort($revisions);return $revisions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate,Validator};
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,StockItem,Warehouse};
|
||||
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
|
||||
|
||||
final class ReceiptController
|
||||
{
|
||||
private function canConfirm():bool{$ctx=app(\App\Shared\Context\CompanyContext::class);$s=$ctx->identifiers();return app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$s['tenant_id'],$s['company_id'],'purchasing_receipts.confirm',$ctx->unitId());}
|
||||
private function order(int $id,array $binding):PurchaseOrder{$order=PurchaseOrder::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);return $order;}
|
||||
private function revision(PurchaseOrder $order,array $binding):string{return hash('sha256',json_encode([$order->getAttributes(),app(ReceiptWorkflow::class)->progress($order),$binding],JSON_THROW_ON_ERROR));}
|
||||
private function result(GoodsReceipt $receipt,bool $replayed):array{return ['receipt'=>$receipt->load('items')->toArray(),'replayed'=>$replayed,'movements'=>DB::table('stock_movements')->where('tenant_id',$receipt->tenant_id)->whereIn('goods_receipt_item_id',$receipt->items->pluck('id'))->orderBy('id')->get(['id','goods_receipt_item_id','quantity_milli','warehouse_id','stock_item_id','movement_type'])->all()];}
|
||||
public function catalogue(int $id,int $line,Request $r,ReceiptRuntime $runtime) {
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_receipts.confirm',function($actor,$binding)use($id,$line,$d){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$source=$order->items()->find($line);abort_unless($source,403,'Linha fora da ordem autorizada.');$catalogue=app(ReceiptCatalogue::class);$q=$catalogue->query($order,$source);if(isset($d['search'])&&$d['search']!=='')$q->where(fn($w)=>$w->whereRaw('position(lower(?) in lower(code))>0',[$d['search']])->orWhereRaw('position(lower(?) in lower(name))>0',[$d['search']]));$page=$q->orderBy('code')->orderBy('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$page->through(fn($item)=>$item->only(['id','code','name','unit'])+['selection_revision'=>$catalogue->revision($item)]);return response()->json($page)->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d){$order=$this->order($id,$binding);return response()->json(GoodsReceipt::where('purchase_order_id',$order->id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$runtime->run('purchasing_receipts.view',fn()=>true);return response()->view('receipt-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($d){Gate::authorize('viewAny',GoodsReceipt::class);$orders=PurchaseOrder::where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->orderBy('id')->paginate((int)($d['per_page']??25),['id','number','status'], 'page',(int)($d['page']??1));return response()->json($orders);});}
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d,$can){$order=$this->order($id,$binding);return response()->json(['order'=>$order->only(['id','number','status']),'progress'=>app(ReceiptWorkflow::class)->progress($order),'revision'=>$this->revision($order,$binding),'can_confirm'=>$can&&Gate::allows('create',[GoodsReceipt::class,$order]),'warehouses'=>Warehouse::whereIn('id',$binding['warehouse_ids'])->orderBy('id')->get(['id','code','name']),'items'=>StockItem::orderBy('id')->limit(25)->get(['id','code','name','unit']),'history'=>GoodsReceipt::where('purchase_order_id',$id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1))]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint)use($id,$r){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$input=$r->all();$revision=$input['revision']??null;$selected=$input['item_revisions']??null;unset($input['revision'],$input['item_revisions']);abort_if(array_diff(array_keys($input),['idempotency_key','delivery_note','received_on','warehouse_id','items']),422);$rules=(new \Tempo\Purchasing\Embedded\Http\Requests\ConfirmReceiptRequest)->setUserResolver(fn()=>$actor)->rules();$data=Validator::make($input,$rules)->validate();abort_unless(in_array((int)($data['warehouse_id']??0),$binding['warehouse_ids'],true),403);abort_unless(is_string($revision)&&hash_equals($this->revision($order,$binding),$revision),409,'A origem mudou. Actualize e confira novamente.');$items=app(ReceiptCatalogue::class)->selected($data);if($selected!==null){Validator::make(['selected'=>$selected],['selected'=>'array|max:50','selected.*'=>'required|string|size:64'])->validate();foreach($selected as$key=>$value)abort_unless(isset($items[(string)$key])&&hash_equals($items[(string)$key],$value),409,'O artigo mudou desde a escolha. Pesquise novamente.');} $cut=['item_revisions'=>$items,'actor'=>$actor->id,'fingerprint'=>$fingerprint,'order'=>$id,'revision'=>$revision,'data'=>$data,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['document'=>'Recepção confirmada, com histórico imutável','stock'=>'Apenas quantidades físicas aceites geram entrada, uma única vez.','payments'=>false,'accounting'=>false],'data'=>$data]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['preview'=>'required|string|max:100000']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida. Confira novamente.');}return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint,$origin)use($id,$cut){$order=$this->order($id,$binding);abort_unless(($cut['actor']??null)===$actor->id&&($cut['order']??null)===$id&&($cut['fingerprint']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou. Confira novamente.');$existing=GoodsReceipt::where('purchase_order_id',$id)->where('idempotency_key',$cut['data']['idempotency_key'])->first();abort_unless($existing||hash_equals($this->revision($order,$binding),$cut['revision']),409,'Recepção alterada por outra operação. Confira novamente.');if(!$existing){$current=app(ReceiptCatalogue::class)->selected($cut['data']);abort_unless(($cut['item_revisions']??null)===$current,409,'O artigo mudou desde a previa. Confira novamente.');}$receipt=app(ReceiptWorkflow::class)->confirm($actor,$order,$cut['data']);if(!$existing)app(\Tempo\Purchasing\Embedded\Services\RequisitionWorkflow::class)->audit($actor,'sgf.receipt.confirmed',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),null,$origin+['receipt_id'=>$receipt->id,'binding_revision'=>$fingerprint]);return response()->json($this->result($receipt,(bool)$existing),$existing?200:201);});}
|
||||
public function receipt(int $id,ReceiptRuntime $runtime){$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$can){$receipt=GoodsReceipt::findOrFail($id);$this->order($receipt->purchase_order_id,$binding);Gate::authorize('view',$receipt);return response()->json($this->result($receipt,true)+['documents_enabled'=>config('receipt_qa.receipt_documents_enabled')===true,'document_revision'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical([$receipt->getAttributes()])),'can_attach'=>$can&&config('receipt_qa.receipt_documents_enabled')===true&&app(\Tempo\Purchasing\Embedded\Services\ReceiptDocuments::class)->canAdd($actor,$receipt)]);});}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\{Request,UploadedFile};
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,ReceiptDocument,AuditLog,Requisition};
|
||||
use Tempo\Purchasing\Embedded\Services\{ReceiptDocuments,RequisitionWorkflow};
|
||||
|
||||
/** Private transport; all attachment decisions and writes belong to the pinned native service. */
|
||||
final class ReceiptDocumentsController
|
||||
{
|
||||
private const FIELDS=['id','goods_receipt_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'];
|
||||
private function guard():void { abort_unless(config('receipt_qa.receipt_documents_enabled')===true,404); }
|
||||
private function hash(array $v):string { return hash('sha256',Payables::canonical($v)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function source(int $id,array $binding):GoodsReceipt {
|
||||
$receipt=GoodsReceipt::findOrFail($id);
|
||||
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
|
||||
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);return $receipt;
|
||||
}
|
||||
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
|
||||
private function integrity(ReceiptDocument $doc,string $root):void {
|
||||
$file=app(SupplierDocumentFiles::class)->path($root,$doc->path,(int)$doc->tenant_id,(int)$doc->goods_receipt_id);
|
||||
abort_unless(filesize($file)===(int)$doc->size&&hash_equals($doc->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
|
||||
}
|
||||
private function same(GoodsReceipt $receipt,array $cut):?ReceiptDocument { return ReceiptDocument::where('goods_receipt_id',$receipt->id)->where('document_type',$cut['document_type'])->where('sha256',$cut['file']['sha256'])->first(); }
|
||||
public function index(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_receipts.view',function($a,$b)use($id,$d){$receipt=$this->source($id,$b);abort_unless(app(ReceiptDocuments::class)->canRead($a,$receipt),403);return response()->json(ReceiptDocument::where('goods_receipt_id',$id)->orderByDesc('id')->paginate(15,self::FIELDS,'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
|
||||
return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f)use($id,$r,$d,$generation,$meta){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$d['revision']),409,'A recepção mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true,'receipt');$db=DB::connection();$level=$db->transactionLevel();$document=null;$existing=$this->same($receipt,$d+['file'=>$meta]);$db->beginTransaction();
|
||||
try{$document=$files->using($stage,fn()=>$service->add($a,$receipt,$d,$r->file('file')),'receipt_documents');$impact=['document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at']);}
|
||||
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document&&!$existing)$files->discard($stage,$document->path,(int)$receipt->tenant_id,$id,'receipt_documents');}
|
||||
// A reused document belongs to permanent storage; never delete or stage it.
|
||||
if($existing)$this->integrity($existing,$files->root(false,'receipt'));
|
||||
$cut=$d+['receipt_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
|
||||
}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$meta=$this->meta($r->file('file'));$generation=$this->generation();$created=null;
|
||||
try{return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f,$o)use($id,$r,$cut,$meta,$generation,&$created){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);
|
||||
abort_unless(($cut['receipt_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
|
||||
$hash=$this->hash([$id,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$old=AuditLog::where('action','sgf.receipt.document')->where('after->operation_key',$cut['operation_key'])->first();
|
||||
if($old){abort_unless($old->user_id===$a->id&&($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($old->after['result']['document_id']);$this->integrity($doc,$root);return response()->json($old->after['result']+['replayed'=>true]);}
|
||||
abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$cut['revision']),409,'A recepção mudou desde a conferência.');$existing=$this->same($receipt,$cut);$document=$files->using($root,fn()=>$service->add($a,$receipt,$cut,$r->file('file')),'receipt_documents');if(!$existing)$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$receipt->tenant_id,'receipt'=>$id];$this->integrity($document,$root);
|
||||
$result=['receipt_id'=>$id,'document_id'=>$document->id,'document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true];$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document',Requisition::findOrFail($order->requisition_id),null,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
|
||||
catch(\Throwable $e){if($created){try{$exists=DB::connection('receipt_native')->table('receipt_documents')->where(['tenant_id'=>$created['tenant'],'goods_receipt_id'=>$id,'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$id,'receipt_documents');}catch(\Throwable){/* An uncertain commit retains its evidence. */}}throw $e;}
|
||||
}
|
||||
public function download(int $id,int $document,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_receipts.view',function($a,$b,$f,$o)use($id,$document,$r){$receipt=$this->source($id,$b);$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($document);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$files->path($root,$doc->path,(int)$doc->tenant_id,$id);$input=Request::create('/embedded/receipt-document','GET');$input->setUserResolver(fn($guard=null)=>$a);
|
||||
$response=$files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\ReceiptDocumentController)->download($input,$receipt,$document,app(ReceiptDocuments::class)),'receipt_documents');$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document_downloaded',Requisition::findOrFail($order->requisition_id),null,$o+['receipt_id'=>$id,'document_id'=>$document,'sha256'=>$doc->sha256]);return $response;});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\{Auth,DB,Gate,Schema};
|
||||
|
||||
/** Local QA composition of the original receipt workflow, never an HTTP proxy or second writer. */
|
||||
final class ReceiptRuntime
|
||||
{
|
||||
private static array $loaded=[];
|
||||
private static function loadClass(string $class,string $bundle,array $files):void
|
||||
{
|
||||
foreach(['Tempo\\Purchasing\\Embedded\\'=>'app/','Laravel\\Fortify\\'=>'fortify/']as$prefix=>$directory){
|
||||
if(!str_starts_with($class,$prefix))continue;
|
||||
$tail=substr($class,strlen($prefix));abort_unless(preg_match('/^[A-Za-z0-9_\\\\]+$/D',$tail),503);
|
||||
$relative=$directory.str_replace('\\','/',$tail).'.php';$pin=$files[$relative]??null;$file=realpath($bundle.'/'.$relative);$base=realpath($bundle);
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&$base&&$file&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR))&&!is_link($bundle.'/'.$relative)&&hash_equals($pin,hash_file('sha256',$file)),503,'Dependência do pacote incompatível.');
|
||||
require_once $file;return;
|
||||
}
|
||||
}
|
||||
public function run(string $right,\Closure $work):mixed
|
||||
{
|
||||
abort_unless(config('receipt_qa.enabled')===true,404);
|
||||
if(str_starts_with($right,'general_ledger.'))abort_unless(config('sgf_single_installation.enabled')===true&&config('sgf_single_installation.ledger_enabled')===true,404);
|
||||
if(str_starts_with($right,'supplier_payables.'))abort_unless(config('sgf_single_installation.enabled')===true&&config('sgf_single_installation.finance_enabled')===true,404);
|
||||
if(str_starts_with($right,'stock.')){
|
||||
abort_unless(config('stock_operations.enabled')===true,404);
|
||||
$allowed=['stock.view','stock.operations.prepare','stock.operations.confirm'];
|
||||
if(config('stock_operations.final_enabled')===true)$allowed=[...$allowed,'stock.transfers.receive','stock.lots.review'];
|
||||
if(config('stock_catalogue.enabled')===true)$allowed=[...$allowed,'stock.items.prepare','stock.items.review'];
|
||||
if(config('stock_completion.enabled')===true)$allowed=[...$allowed,'stock.planning.view','stock.planning.prepare','stock.planning.review','stock.disposition.view','stock.disposition.prepare','stock.disposition.review'];
|
||||
abort_unless(in_array($right,$allowed,true),403);
|
||||
}
|
||||
if(str_starts_with($right,'purchasing_obligations.'))abort_unless(config('receipt_qa.obligations_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_invoices.'))abort_unless(config('receipt_qa.invoices_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_requests.'))abort_unless(config('receipt_qa.requests_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_orders.'))abort_unless(config('receipt_qa.orders_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_suppliers.'))abort_unless(config('receipt_qa.suppliers_enabled')===true,404);
|
||||
if(str_starts_with($right,'purchasing_sourcing.'))abort_unless(config('receipt_qa.sourcing_enabled')===true,404);
|
||||
abort_unless(app()->environment('budget-testing'),403);
|
||||
BudgetAdapter::guard();
|
||||
$host=DB::connection();$db=$host->getDatabaseName();$role=$host->getConfig('username');
|
||||
abort_unless(preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$db,$m)&&$role==='ir_composition_'.$m[1],403);
|
||||
$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$hostConfig=$host->getConfig();abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&($hostConfig['host']??null)==='127.0.0.1'&&(int)($hostConfig['port']??0)===55439&&empty($hostConfig['read'])&&empty($hostConfig['write'])&&($hostConfig['search_path']??'public')==='public',403);
|
||||
$identity=$host->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$identity->elevated&&$identity->role===$role&&$identity->cluster==='sgf_modular_regression_'.$run,403);
|
||||
$expected='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
|
||||
$profile=realpath((string)config('receipt_qa.profile'));
|
||||
abort_unless($profile&&strtolower(str_replace('\\','/',$profile))===strtolower($expected.'/binding.json'),403);
|
||||
$p=json_decode(file_get_contents($profile),true,64,JSON_THROW_ON_ERROR);
|
||||
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();
|
||||
abort_unless($scope['unit_id']&&request()->user()->id===$user,403);
|
||||
$key=implode(':',array_values($scope)).':'.$user;$binding=$p['bindings'][$key]??null;
|
||||
if(config('sgf_single_installation.enabled')===true&&config('sgf_single_installation.bindings_sha256')!==null){
|
||||
$file=$expected.'/single-bindings.json';$pin=config('sgf_single_installation.bindings_sha256');
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);
|
||||
$additional=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($additional['composition']??null)===$m[1],403);
|
||||
if(isset($additional['bindings'][$key]))$binding=$additional['bindings'][$key];
|
||||
}
|
||||
abort_unless($binding,403,'Origem de recepção não autorizada neste contexto.');
|
||||
// A Stock-specific map is explicit and pinned; it cannot change purchasing bindings.
|
||||
if(str_starts_with($right,'stock.')&&config('stock_operations.profile_sha256')!==null){
|
||||
$mapFile=$expected.'/stock-operations-binding.json';$pin=config('stock_operations.profile_sha256');
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&is_file($mapFile)&&!is_link($mapFile)&&hash_equals($pin,hash_file('sha256',$mapFile)),503);
|
||||
$map=json_decode(file_get_contents($mapFile),true,32,JSON_THROW_ON_ERROR);$warehouses=$map['warehouse_bindings'][$key]??null;
|
||||
abort_unless(($map['composition']??null)===$m[1]&&is_array($warehouses)&&count($warehouses)>0&&count($warehouses)<=1000&&!array_filter($warehouses,fn($id)=>!is_int($id)||$id<1),403);
|
||||
$binding['warehouse_ids']=$warehouses;$binding['stock_profile_sha256']=$pin;
|
||||
}
|
||||
if(config('receipt_qa.obligations_enabled')===true)abort_unless(($binding['payable_instance']??null)==='qa-stock-ledger-'.$m[1]&&config('common-payables.source_instance')===$binding['payable_instance']&&config('common-payables.history_enabled')===false&&config('common-payables.history_decisions_enabled')===false,403);
|
||||
$p['context_key']=$key;
|
||||
$bundle=realpath($expected.'/bundle');$manifest=$bundle.'/manifest.json';
|
||||
abort_unless($bundle&&hash_equals($p['manifest_sha256'],hash_file('sha256',$manifest)),503);
|
||||
$files=json_decode(file_get_contents($manifest),true,64,JSON_THROW_ON_ERROR)['files'];
|
||||
foreach($files as$file=>$pin)abort_unless(preg_match('~^(app|fortify)/[A-Za-z0-9_/]+\.php$~D',$file)&&!str_contains($file,'..')&&is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin),503);
|
||||
if(config('receipt_qa.orders_enabled')===true||config('receipt_qa.sourcing_enabled')===true){
|
||||
$economic=$expected.'/economic';abort_unless(isset($p['economic_manifest_sha256'])&&is_file($economic.'/manifest.json')&&hash_equals($p['economic_manifest_sha256'],hash_file('sha256',$economic.'/manifest.json')),503);
|
||||
$economicFiles=json_decode(file_get_contents($economic.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($economicFiles);sort($names);abort_unless($names===['ProposalScoring.php','QuotationPricing.php'],503);
|
||||
foreach($economicFiles as$file=>$pin){abort_unless(is_file($economic.'/'.$file)&&!is_link($economic.'/'.$file)&&hash_equals($pin,hash_file('sha256',$economic.'/'.$file)),503);$class='Tempo\\Purchasing\\Economic\\'.basename($file,'.php');if(class_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $economic.'/'.$file;}
|
||||
}
|
||||
if(config('receipt_qa.requests_enabled')===true){
|
||||
$internal=$expected.'/internal';abort_unless(isset($p['internal_manifest_sha256'])&&hash_equals($p['internal_manifest_sha256'],hash_file('sha256',$internal.'/manifest.json')),503);$pins=json_decode(file_get_contents($internal.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($pins);sort($names);abort_unless($names===['InternalRequisitions.php','RequisitionFailure.php','RequisitionPort.php'],503);foreach(['RequisitionPort.php','RequisitionFailure.php','InternalRequisitions.php']as$file){$pin=$pins[$file];abort_unless(preg_match('/^[a-f0-9]{64}$/D',$pin)&&!is_link($internal.'/'.$file)&&hash_equals($pin,hash_file('sha256',$internal.'/'.$file)),503);$class='Tempo\\Purchasing\\Internal\\'.basename($file,'.php');if(class_exists($class,false)||interface_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $internal.'/'.$file;}
|
||||
}
|
||||
if(!isset(self::$loaded[$bundle])){
|
||||
abort_unless(self::$loaded===[],503,'Uma só versão do workflow por processo.');
|
||||
spl_autoload_register(fn($class)=>self::loadClass($class,$bundle,$files));
|
||||
Gate::policy(\Tempo\Purchasing\Embedded\Models\GoodsReceipt::class,\Tempo\Purchasing\Embedded\Policies\GoodsReceiptPolicy::class);Gate::policy(\Tempo\Purchasing\Embedded\Models\Supplier::class,\Tempo\Purchasing\Embedded\Policies\SupplierPolicy::class);self::$loaded[$bundle]=true;
|
||||
app()->singleton(\Tempo\Purchasing\Embedded\Services\ReplenishmentRun::class);
|
||||
}
|
||||
$connection=$p['connection'];abort_unless(($connection['driver']??null)==='pgsql'&&($connection['host']??null)==='127.0.0.1'&&(int)($connection['port']??0)===55439
|
||||
&&($connection['database']??null)==='compras_receipts_test_'.$m[1]&&($connection['username']??null)==='payable_history_native_'.$m[1]
|
||||
&&empty($connection['url'])&&empty($connection['read'])&&empty($connection['write'])&&($connection['search_path']??null)==='public',403);
|
||||
config(['database.connections.receipt_native'=>$connection]);$native=DB::connection('receipt_native');
|
||||
$i=$native->selectOne("SELECT current_user role,current_database() db,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless(!$i->elevated&&$i->role===$connection['username']&&$i->db===$connection['database']&&$i->cluster==='sgf_modular_regression_'.$run,403);
|
||||
return $host->transaction(function()use($host,$scope,$user,$right,$binding,$work,$p,$native){
|
||||
if(str_starts_with($right,'stock.')){$host->statement("SET LOCAL lock_timeout='5s'");$host->statement("SET LOCAL statement_timeout='15s'");}
|
||||
// Hold existing authority until the native transaction ends; no host business writes.
|
||||
abort_unless($host->table('users')->where('id',$user)->lockForUpdate()->first(),403);
|
||||
abort_unless($host->table('company_memberships')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']])->lockForUpdate()->first(),403);
|
||||
$view=str_starts_with($right,'purchasing_invoices.')?'purchasing_invoices.view':(str_starts_with($right,'purchasing_requests.')?'purchasing_requests.view':(str_starts_with($right,'purchasing_suppliers.')?'purchasing_suppliers.view':(str_starts_with($right,'purchasing_sourcing.')?'purchasing_sourcing.view':(str_starts_with($right,'purchasing_orders.')?'purchasing_orders.view':'purchasing_receipts.view'))));
|
||||
if(str_starts_with($right,'purchasing_obligations.'))$view='purchasing_obligations.view';
|
||||
if(str_starts_with($right,'stock.'))$view='stock.view';
|
||||
if(str_starts_with($right,'general_ledger.'))$view='general_ledger.view';
|
||||
if(str_starts_with($right,'supplier_payables.'))$view='supplier_payables.view';
|
||||
foreach(['company.view'=>null,'unit.view'=>$scope['unit_id'],$view=>$scope['unit_id'],$right=>$scope['unit_id']]as$permission=>$unit)
|
||||
abort_unless($host->table('permission_grants')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'unit_id'=>$unit,'permission'=>$permission])->lockForUpdate()->first(),403);
|
||||
$default=DB::getDefaultConnection();$guard=Auth::getDefaultDriver();$environment=app()->environment();
|
||||
config(['auth.guards.receipt_native'=>['driver'=>'session','provider'=>'receipt_native'],'auth.providers.receipt_native'=>['driver'=>'eloquent','model'=>\Tempo\Purchasing\Embedded\Models\User::class]]);
|
||||
DB::setDefaultConnection('receipt_native');Schema::clearResolvedInstance('db.schema');Auth::shouldUse('receipt_native');app()->instance('env','testing');
|
||||
try{
|
||||
$actor=\Tempo\Purchasing\Embedded\Models\User::where('tenant_id',$binding['tenant_id'])->findOrFail($binding['actor_id']);Auth::guard('receipt_native')->setUser($actor);
|
||||
return $native->transaction(function()use($native,$actor,$binding,$work,$p,$right){
|
||||
if(str_starts_with($right,'stock.')){$native->statement("SET LOCAL lock_timeout='5s'");$native->statement("SET LOCAL statement_timeout='15s'");}
|
||||
$native->table('tenants')->where('id',$actor->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$version=[$p['context_key'],$binding,$p['manifest_sha256']];$origin=['sgf_context_key'=>$p['context_key'],'workflow_manifest'=>$p['manifest_sha256']];
|
||||
if(isset($p['internal_manifest_sha256'])){$version[]=$p['internal_manifest_sha256'];$origin['internal_manifest']=$p['internal_manifest_sha256'];}
|
||||
if(isset($p['economic_manifest_sha256'])){$version[]=$p['economic_manifest_sha256'];$origin['economic_manifest']=$p['economic_manifest_sha256'];}
|
||||
return $work($actor,$binding,hash('sha256',json_encode($version,JSON_THROW_ON_ERROR)),$origin);
|
||||
});
|
||||
}finally{Auth::forgetGuards();Auth::shouldUse($guard);DB::setDefaultConnection($default);Schema::clearResolvedInstance('db.schema');app()->instance('env',$environment);}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Requisition,AuditLog,CostCenter};
|
||||
use Tempo\Purchasing\Embedded\Services\{RequisitionWorkflow,OrganizationAccess};
|
||||
|
||||
/** Native requisition lifecycle in SGF, sharing the same reservation and order book. */
|
||||
final class RequestController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.requests_enabled')===true,404);}
|
||||
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
|
||||
private function state(?Requisition $r):?array{return $r?->load(['items','steps'])->toArray();}
|
||||
private function source(int $id,array $b,object $a):?Requisition{abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);if(!$id)return null;abort_unless(RequisitionAccess::allowed($id,$b),403);return Requisition::visibleTo($a)->findOrFail($id);}
|
||||
private function right(string $a):string{return ['save'=>'prepare','submit'=>'submit','decide'=>'approve'][$a];}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$v=[];foreach(['prepare','submit','approve']as$r)$v[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_requests.'.$r,$c->unitId());return $v;}
|
||||
private function historyPage(int $id,object $actor,string $binding,int $size=25,?string $token=null):array {
|
||||
$generation=$this->hash(request()->session()->get('company_context'));$cursor=null;
|
||||
if($token!==null){try{$cut=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Cursor inválido. Actualize o histórico.');}
|
||||
abort_unless(($cut['request']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$binding&&($cut['generation']??null)===$generation&&($cut['size']??null)===$size&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time()&&is_string($cut['cursor']??null),409,'A consulta ou o contexto mudou. Actualize o histórico.');
|
||||
$cursor=\Illuminate\Pagination\Cursor::fromEncoded($cut['cursor']);abort_unless($cursor,409,'Cursor inválido. Actualize o histórico.');
|
||||
}
|
||||
$page=app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->cursorPaginate($size,['id','user_id','action','created_at','before','after'],'cursor',$cursor);
|
||||
$encode=fn($value)=>$value?Crypt::encryptString(json_encode(['request'=>$id,'actor'=>$actor->id,'binding'=>$binding,'generation'=>$generation,'size'=>$size,'observed'=>time(),'cursor'=>$value->encode()],JSON_THROW_ON_ERROR)):null;
|
||||
return ['data'=>$page->items(),'navigation'=>['next'=>$encode($page->nextCursor()),'previous'=>$encode($page->previousCursor()),'per_page'=>$size,'order'=>'sequence_desc']];
|
||||
}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $rt){
|
||||
$this->guard();abort_unless(config('receipt_qa.request_history_enabled')===true,404);abort_unless($id>0,404);$d=$r->validate(['per_page'=>'sometimes|integer|in:25,100','cursor'=>'sometimes|required|string|max:16384']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d){$this->source($id,$b,$a);return response()->json($this->historyPage($id,$a,$fingerprint,(int)($d['per_page']??25),$d['cursor']??null))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
private function dispatch(string $action,array $data,?Requisition $record,object $actor,array $binding,string $key):array{
|
||||
$workflow=app(RequisitionWorkflow::class);$before=$this->state($record);
|
||||
if($action==='save'){
|
||||
abort_if(array_diff(array_keys($data),['cost_center_id','priority','needed_by','justification','items']),422);
|
||||
Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate','items.*.purchase_type'=>'required|in:consumable,service,other'])->validate();
|
||||
$input=(new \Tempo\Purchasing\Embedded\Http\Requests\StoreRequisitionRequest)->setUserResolver(fn()=>$actor);
|
||||
$data=Validator::make($data,$input->rules())->validate();abort_unless(in_array((int)$data['cost_center_id'],$binding['centre_ids']??[],true),403);
|
||||
$record=$workflow->save($actor,$data+['operation_key'=>$key]+($record?['process_version'=>(int)$record->process_version]:[]),$record);
|
||||
}elseif($action==='submit'){
|
||||
abort_unless($record&&$data===[],422);$workflow->submit($actor,$record,$key,(int)$record->process_version);
|
||||
}else{
|
||||
abort_unless($record,422);abort_if(array_diff(array_keys($data),['decision','comment']),422);$data=Validator::make($data,['decision'=>'required|in:approved,rejected','comment'=>'required|string|min:10|max:2000'])->validate();$workflow->decide($actor,$record,$data['decision'],$data['comment'],$key,(int)$record->process_version);
|
||||
}
|
||||
$record->refresh();return ['result'=>['request_id'=>$record->id,'status'=>$record->status,'version'=>(int)$record->process_version],'impact'=>['operation'=>$action,'before'=>$before,'after'=>$this->state($record),'budget_authority'=>'Compras: mesmo livro da ordem; sem reserva no host SGF','reservation_on_submit'=>$action==='submit','release_on_rejection'=>$action==='decide'&&$data['decision']==='rejected']];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_requests.view',fn($a)=>$this->source(0,[],$a));return response()->view('request-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'nullable|in:draft,pending,approved,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_requests.view',function($a,$b)use($d,$rights){$this->source(0,$b,$a);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if($d['status']??'')$q->where('status',$d['status']);return response()->json(['requests'=>$q->orderByDesc('id')->paginate(15,['id','number','status','process_version'],'page',(int)($d['page']??1)),'centres'=>CostCenter::whereIn('id',$b['centre_ids']??[])->where('department_id',$a->department_id)->get(['id','code','name']),'new_revision'=>$this->hash(null),'can_prepare'=>$rights['prepare']&&app(OrganizationAccess::class)->canRequest($a)]);});}
|
||||
public function show(int $id,ReceiptRuntime $rt){$this->guard();abort_unless($id>0,404);$rights=$this->rights();return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$rights){$r=$this->source($id,$b,$a);$state=$this->state($r);$own=$r->requester_id===$a->id&&$r->status==='draft';$paged=config('receipt_qa.request_history_enabled')===true?$this->historyPage($id,$a,$fingerprint):null;return response()->json(['request'=>$state,'revision'=>$this->hash($state),'capabilities'=>['save'=>$rights['prepare']&&$own,'submit'=>$rights['submit']&&$own,'decide'=>$rights['approve']&&$r->canDecide($a)],'history'=>$paged?$paged['data']:app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at','before','after']),'history_navigation'=>$paged['navigation']??null]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:save,submit,decide','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536||($id===0&&$d['action']!=='save'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_requests.'.$this->right($d['action']),function($a,$b,$f)use($r,$id,$d){$record=$this->source($id,$b,$a);abort_unless(hash_equals($this->hash($this->state($record)),$d['revision']),409,'A requisição mudou. Confira novamente.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$record,$a,$b,$d['operation_key']);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if(!$id){unset($out['impact']['after']['id'],$out['impact']['after']['number']);foreach($out['impact']['after']['items']as&$line)unset($line['id'],$line['requisition_id']);unset($line);}$cut=$d+['id'=>$id,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'',['save','submit','decide'],true),409);return $rt->run('purchasing_requests.'.$this->right($cut['action']),function($a,$b,$f,$o)use($id,$cut){$record=$this->source($id,$b,$a);abort_unless(($cut['id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'A prévia ou o contexto mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.request.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($record)),$cut['revision']),409,'A requisição mudou desde a conferência.');$before=$this->state($record);$out=$this->dispatch($cut['action'],$cut['data'],$record,$a,$b,$cut['operation_key']);$record=Requisition::findOrFail($out['result']['request_id']);app(RequisitionWorkflow::class)->audit($a,'sgf.request.command',$record,$before,$o+['request_scope'=>RequisitionAccess::scope(),'operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
use Tempo\Purchasing\Embedded\Models\Requisition;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
|
||||
/** One native record/book; admission is an explicit binding or an audited host origin. */
|
||||
final class RequisitionAccess
|
||||
{
|
||||
public static function scope():string{$c=app(CompanyContext::class);return hash('sha256',json_encode($c->identifiers()+['unit_id'=>$c->unitId()],JSON_THROW_ON_ERROR));}
|
||||
public static function ids(array $binding){return Requisition::query()->select('requisitions.id')->where(function($q)use($binding){$q->whereIn('requisitions.id',$binding['requisition_ids']??[]);if(config('receipt_qa.requests_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as ra')->whereColumn('ra.tenant_id','requisitions.tenant_id')->whereColumn('ra.requisition_id','requisitions.id')->where('ra.action','sgf.request.command')->where('ra.after->request_scope',self::scope()));});}
|
||||
public static function allowed(int $id,array $binding):bool{return self::ids($binding)->where('requisitions.id',$id)->exists();}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Requests\Sgc\StoreInvoiceRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt, DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Stateless transport to the existing pending-invoice workflow; no second business writer. */
|
||||
final class SgcInvoiceIngress
|
||||
{
|
||||
public function receive(StoreInvoiceRequest $request)
|
||||
{
|
||||
$body = $request->validated(); $id = (int)$body['order_id'];
|
||||
$key = strtolower($request->header('X-Idempotency-Key'));
|
||||
$controller = app(InvoiceController::class); $runtime = app(ReceiptRuntime::class);
|
||||
$host = DB::connection();
|
||||
abort_unless($host->getDriverName() === 'pgsql', 503);
|
||||
return $host->transaction(function () use ($request, $body, $id, $key, $controller, $runtime, $host) {
|
||||
// Serialize the complete retry check + native commit, including different orders using one key.
|
||||
$host->statement("SET LOCAL lock_timeout = '5s'");
|
||||
$host->select('SELECT pg_advisory_xact_lock(hashtextextended(?, 0))', ['sgc.invoice:'.$key]);
|
||||
$controller->show($id, $runtime); // exact origin, current visibility and native policies
|
||||
if ($body['phase'] === 'confirm') {
|
||||
try { $cut = json_decode(Crypt::decryptString($body['preview']), true, 128, JSON_THROW_ON_ERROR); }
|
||||
catch (\Throwable) { abort(409, 'Prévia inválida.'); }
|
||||
abort_unless(($cut['action'] ?? null) === 'register' && ($cut['order_id'] ?? null) === $id
|
||||
&& ($cut['operation_key'] ?? null) === $key, 409, 'Prévia de outra operação.');
|
||||
$data = $cut['data']; $reason = $cut['reason'];
|
||||
} else { $cut = null; $data = $body['data']; $reason = $body['reason']; }
|
||||
$hash = hash('sha256', Payables::canonical([[$id, 'register', $data, $reason]]));
|
||||
$generation = hash('sha256', Payables::canonical([$request->session()->get('company_context')]));
|
||||
$old = $runtime->run('purchasing_invoices.prepare', function ($actor, $binding, $fingerprint) use ($key, $id, $hash, $cut, $generation) {
|
||||
$actor = app(\Tempo\Purchasing\Embedded\Services\CurrentActor::class)->lock($actor, ['finance']);
|
||||
abort_if(app(\Tempo\Purchasing\Embedded\Services\OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
if ($cut) abort_unless(($cut['actor'] ?? null) === $actor->id && ($cut['binding'] ?? null) === $fingerprint
|
||||
&& ($cut['generation'] ?? null) === $generation, 409, 'Contexto da prévia alterado.');
|
||||
$receipt = \Tempo\Purchasing\Embedded\Models\AuditLog::where('user_id', $actor->id)
|
||||
->where('action', 'sgf.invoice.command')->where('after->operation_key', $key)->first();
|
||||
if (!$receipt) return null;
|
||||
abort_unless(($receipt->after['result']['order_id'] ?? null) === $id && ($receipt->after['request_hash'] ?? null) === $hash
|
||||
&& ($receipt->after['binding_revision'] ?? null) === $fingerprint, 409, 'Chave usada com outro conteúdo ou origem.');
|
||||
return $receipt->after['result'] + ['replayed'=>true];
|
||||
});
|
||||
if ($old) return response()->json($old);
|
||||
$input = $body['phase'] === 'confirm' ? ['preview'=>$body['preview']] :
|
||||
['operation_key'=>$key, 'revision'=>$body['revision'], 'action'=>'register', 'reason'=>$reason, 'data'=>$data];
|
||||
$native = Request::create('/api/purchasing-invoices/'.$id, 'POST', $input, [], [], ['HTTP_ACCEPT'=>'application/json']);
|
||||
$native->setLaravelSession($request->session()); $native->setUserResolver($request->getUserResolver());
|
||||
return $body['phase'] === 'confirm' ? $controller->confirm($id, $native, $runtime) : $controller->preview($id, $native, $runtime);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Requisition,Quotation,PurchaseAward,Supplier,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,QuotationParticipation,ProposalComparison,PurchaseWorkflow,RequisitionWorkflow};
|
||||
|
||||
/** Transport and receipts around the original quotation/award commands; no pricing or scoring engine. */
|
||||
final class SourcingController
|
||||
{
|
||||
private const ROLES=['quote'=>['procurement'],'evaluate'=>['procurement','finance','department_head'],'select'=>['procurement'],'decide'=>['procurement','finance','department_head']];
|
||||
private const RIGHTS=['quote'=>'prepare','evaluate'=>'evaluate','select'=>'select','decide'=>'approve'];
|
||||
private function guard():void{abort_unless(config('receipt_qa.sourcing_enabled')===true,404);}
|
||||
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function source(int $id,array $binding,object $actor):Requisition{$this->viewer($actor);abort_unless(RequisitionAccess::allowed($id,$binding),403);return Requisition::visibleTo($actor)->findOrFail($id);}
|
||||
private function hash(mixed $value):string{return hash('sha256',Payables::canonical($value));}
|
||||
private function state(Requisition $request):array{
|
||||
return ['request'=>$request->load(['items','steps'])->toArray(),'quotes'=>Quotation::where('requisition_id',$request->id)->with(['items','supplier','evaluations'])->orderBy('id')->get()->toArray(),'award'=>PurchaseAward::where('requisition_id',$request->id)->with('approvals')->first()?->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
|
||||
}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','evaluate','select','approve']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$r,$c->unitId());return $out;}
|
||||
private function input(Request $r,array $data,object $actor):Request{$input=Request::create('/embedded/sourcing','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$input->setUserResolver(fn($guard=null)=>$actor);if($r->hasSession())$input->setLaravelSession($r->session());return $input;}
|
||||
private function supplierAccess(string $action,array $data,array $binding,string $context):?string {
|
||||
if($action!=='quote')return null;
|
||||
$d=Validator::make($data,['supplier_id'=>'required|integer|min:1','supplier_revision'=>'sometimes|required|string|size:64'])->validate();
|
||||
$supplier=SupplierAccess::query($binding,$context)->find($d['supplier_id']);abort_unless($supplier,404);
|
||||
$revision=$this->hash($supplier->toArray());
|
||||
if(isset($d['supplier_revision']))abort_unless(hash_equals($revision,$d['supplier_revision']),409,'O fornecedor mudou desde a selecção. Pesquise e confira novamente.');
|
||||
return $revision;
|
||||
}
|
||||
private function eligibleSuppliers(array $binding,string $context) {
|
||||
return SupplierAccess::query($binding,$context)->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today());
|
||||
}
|
||||
public function suppliers(int $id,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_sourcing.prepare',function($actor,$binding,$fingerprint,$origin)use($id,$d){
|
||||
$request=$this->source($id,$binding,$actor);abort_unless(app(QuotationParticipation::class)->canPrepare($actor,$request),403);
|
||||
$q=$this->eligibleSuppliers($binding,$origin['sgf_context_key']);
|
||||
if(isset($d['search'])&&$d['search']!=='')$q->where(fn($query)=>$query->whereRaw('position(lower(?) in lower(suppliers.name))>0',[$d['search']])->orWhereRaw('position(? in suppliers.nuit)>0',[$d['search']]));
|
||||
$page=$q->orderBy('suppliers.name')->orderBy('suppliers.id')->paginate((int)($d['per_page']??25),['suppliers.*'],'page',(int)($d['page']??1));
|
||||
$page->through(fn($s)=>['id'=>$s->id,'name'=>$s->name,'nuit'=>$s->nuit,'version'=>$s->version,'selection_revision'=>$this->hash($s->toArray())]);
|
||||
return response()->json($page)->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
private function dispatch(string $action,array $data,Requisition $request,object $actor,Request $http):array{
|
||||
$keys=match($action){'quote'=>['supplier_id','supplier_revision','reference','delivery_days','valid_until','payment_terms','no_conflict','items'],'evaluate'=>['quotation_id','quality','history','payment','comment','no_conflict'],'select'=>['quotation_id','justification','delivery_date','delivery_location'],'decide'=>['award_id','decision','comment']};abort_if(array_diff(array_keys($data),$keys),422);
|
||||
$audit=app(RequisitionWorkflow::class);
|
||||
if($action==='quote'){
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->store($this->input($http,array_diff_key($data,['supplier_revision'=>true]),$actor),$request,$audit);
|
||||
$quote=Quotation::where('requisition_id',$request->id)->where('supplier_id',$data['supplier_id'])->with(['supplier','items'])->firstOrFail();
|
||||
return ['result'=>['quotation_id'=>$quote->id],'impact'=>['operation'=>'Registar proposta','supplier'=>$quote->supplier->name,'total_minor'=>$quote->total_minor,'currency'=>$quote->currency,'lines'=>$quote->items->map(fn($i)=>$i->only(['description','quantity','unit_price_minor','tax_minor','total_minor']))->all()]];
|
||||
}
|
||||
if($action==='evaluate'){
|
||||
$quote=Quotation::where('requisition_id',$request->id)->findOrFail($data['quotation_id']??0);$payload=$data;unset($payload['quotation_id']);
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->evaluate($this->input($http,$payload,$actor),$quote,$audit);
|
||||
return ['result'=>['quotation_id'=>$quote->id,'evaluator_id'=>$actor->id],'impact'=>['operation'=>'Registar avaliação individual','supplier'=>$quote->supplier->name,'quality'=>$data['quality'],'history'=>$data['history'],'payment'=>$data['payment'],'comment'=>$data['comment']]];
|
||||
}
|
||||
if($action==='select'){
|
||||
$validated=Validator::make($data,['quotation_id'=>'required|integer','justification'=>'required|string|min:10|max:5000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'])->validate();
|
||||
$award=app(PurchaseWorkflow::class)->select($actor,$request,$validated);
|
||||
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Submeter adjudicação para aprovação distinta','supplier'=>Quotation::findOrFail($award->quotation_id)->supplier->name,'total_minor'=>$award->total_minor,'delivery_date'=>$validated['delivery_date'],'delivery_location'=>$award->delivery_location,'required_approvals'=>$award->approvals->pluck('role')->all()]];
|
||||
}
|
||||
$validated=Validator::make($data,['award_id'=>'required|integer','decision'=>'required|in:approved,rejected','comment'=>'nullable|required_if:decision,rejected|string|max:2000'])->validate();$award=PurchaseAward::where('requisition_id',$request->id)->findOrFail($validated['award_id']);
|
||||
app(PurchaseWorkflow::class)->decide($actor,$award,$validated['decision'],$validated['comment']??null);$award->refresh();
|
||||
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Decidir adjudicação','decision'=>$validated['decision'],'resulting_status'=>$award->status,'total_minor'=>$award->total_minor,'comment'=>$validated['comment']??null]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_sourcing.view',fn($actor)=>$this->viewer($actor));return response()->view('sourcing-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($d){$this->viewer($actor);return response()->json(Requisition::visibleTo($actor)->whereIn('id',RequisitionAccess::ids($binding))->where('status','approved')->orderBy('id')->paginate(25,['id','number','status'],'page',(int)($d['page']??1)));});}
|
||||
public function history(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($id,$d){$this->source($id,$binding,$actor);return response()->json(app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->paginate(25,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$rights=$this->rights();return $runtime->run('purchasing_sourcing.view',function($actor,$binding,$fingerprint,$origin)use($id,$rights){$request=$this->source($id,$binding,$actor);$state=$this->state($request);$part=app(QuotationParticipation::class);$quotes=Quotation::where('requisition_id',$id)->get()->keyBy('id');foreach($state['comparison']['rows']as&$row)$row['can_evaluate']=$rights['evaluate']&&$part->canEvaluate($actor,$request,$quotes[$row['id']]);unset($row);$award=PurchaseAward::where('requisition_id',$id)->first();return response()->json($state+['revision'=>$this->hash($this->state($request)),'capabilities'=>['quote'=>$rights['prepare']&&$part->canPrepare($actor,$request),'select'=>$rights['select']&&$actor->hasFunctions(['procurement'])&&$actor->id!==$request->requester_id&&!$award&&$state['comparison']['ready'],'decide'=>$rights['approve']&&$award&&app(PurchaseWorkflow::class)->canDecide($actor,$award,$request)],'suppliers'=>SupplierAccess::query($binding,$origin['sgf_context_key'])->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today())->orderBy('id')->limit(25)->get(['id','name']),'history'=>app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at'])]);});}
|
||||
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:quote,evaluate,select,decide','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data'],JSON_THROW_ON_ERROR))>65536,422);$d['operation_key']=strtolower($d['operation_key']);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$d['action']],function($actor,$binding,$fingerprint,$origin)use($id,$d,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$d['action']]);abort_unless(hash_equals($this->hash($this->state($request)),$d['revision']),409,'O processo mudou. Actualize e confira novamente.');
|
||||
// Exact native validation/pricing in a rollback-only savepoint; no preview record is committed.
|
||||
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$supplierRevision=$this->supplierAccess($d['action'],$d['data'],$binding,$origin['sgf_context_key']);$out=$this->dispatch($d['action'],$d['data'],$request,$actor,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
|
||||
$cut=$d+['supplier_revision'=>$supplierRevision,'request_id'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['stock'=>false,'order'=>false,'payment'=>false],'operation_key'=>$d['operation_key']]);});}
|
||||
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(isset(self::RIGHTS[$cut['action']??'']),409);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$cut['action']],function($actor,$binding,$fingerprint,$origin)use($id,$cut,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$cut['action']]);abort_unless(($cut['request_id']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou.');$hash=$this->hash([$cut['action'],$cut['data']]);$old=AuditLog::where('requisition_id',$id)->where('user_id',$actor->id)->where('action','sgf.sourcing.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){$receipt=$old->after;abort_unless(($receipt['request_hash']??null)===$hash&&($receipt['binding_revision']??null)===$fingerprint,409,'Chave já usada com outros dados.');return response()->json($receipt['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($request)),$cut['revision']),409,'O processo mudou desde a conferência.');$supplierRevision=$this->supplierAccess($cut['action'],$cut['data'],$binding,$origin['sgf_context_key']);if($cut['action']==='quote')abort_unless(is_string($cut['supplier_revision']??null)&&hash_equals($cut['supplier_revision'],$supplierRevision),409,'Fornecedor alterado; exige nova conferencia.');$out=$this->dispatch($cut['action'],$cut['data'],$request,$actor,$r);app(RequisitionWorkflow::class)->audit($actor,'sgf.sourcing.command',$request,null,$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$fingerprint,'command'=>$cut['action'],'supplier_revision'=>$supplierRevision,'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{DB,Gate};
|
||||
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder};
|
||||
|
||||
/** Resolve an actual movement to its authorised receipt; URL parent ids are never trusted. */
|
||||
final class StockReceiptTraceController
|
||||
{
|
||||
public function show(int $id,Request $r,ReceiptRuntime $runtime,CompanyContext $ctx)
|
||||
{
|
||||
abort_unless(config('receipt_qa.stock_trace_enabled')===true&&config('receipt_qa.stock_read_enabled')===true,404);
|
||||
abort_if($r->query()!==[],422);
|
||||
$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();$host=DB::connection();
|
||||
return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$scope,$user,$host){
|
||||
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'permission'=>'stock.view'])->sharedLock()->first(),403);
|
||||
$tag=substr($host->getDatabaseName(),strlen('budget_core_sgf_'));
|
||||
$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$tag.'/stock-binding.json';
|
||||
abort_unless(is_file($file)&&!is_link($file),503,'Origem de stock indisponível.');
|
||||
try{$profile=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(503,'Origem de stock incompatível.');}
|
||||
$stock=$profile['bindings'][implode(':',array_values($scope)).':'.$user]??null;
|
||||
abort_unless($stock&&($stock['source_tenant_id']??null)===$binding['tenant_id']
|
||||
&&($profile['connection']['database']??null)===DB::connection()->getDatabaseName()
|
||||
&&($stock['schema']??null)==='public'&&($stock['append_only']??false)===true,403);
|
||||
$movement=DB::table('stock_movements')->where('tenant_id',$binding['tenant_id'])->where('id',$id)
|
||||
->whereIn('warehouse_id',array_values(array_intersect($stock['warehouse_ids']??[],$binding['warehouse_ids'])))
|
||||
->where('movement_type','receipt_in')->first(['id','goods_receipt_item_id','stock_item_id','warehouse_id','quantity_milli','snapshot']);
|
||||
abort_unless($movement,403,'Movimento fora da origem autorizada.');
|
||||
$line=DB::table('goods_receipt_items')->where('tenant_id',$binding['tenant_id'])->where('id',$movement->goods_receipt_item_id)->first();
|
||||
abort_unless($line,409,'A evidência de origem exige nova conferência.');
|
||||
$receipt=GoodsReceipt::findOrFail($line->goods_receipt_id);
|
||||
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
|
||||
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);
|
||||
try{$snapshot=json_decode($movement->snapshot,true,64,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(409,'A evidência de origem exige nova conferência.');}
|
||||
abort_unless($receipt->tenant_id===$binding['tenant_id']&&(int)$receipt->warehouse_id===(int)$movement->warehouse_id
|
||||
&&(int)$line->stock_item_id===(int)$movement->stock_item_id&&(string)$line->accepted_milli===(string)$movement->quantity_milli
|
||||
&&(int)$movement->quantity_milli>0&&($snapshot['receipt_id']??null)===$receipt->id,409,'O movimento e a recepção exigem nova conferência.');
|
||||
return response()->json(['movement_id'=>(int)$movement->id,'order_id'=>$order->id,'receipt_id'=>$receipt->id,
|
||||
'href'=>'/purchasing/receipts?order='.$order->id.'&receipt='.$receipt->id,'read_only'=>true])->header('Cache-Control','no-store');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Tempo\Purchasing\Embedded\Models\Supplier;
|
||||
|
||||
/** Explicit fixture bindings and suppliers actually prepared in this company/unit. */
|
||||
final class SupplierAccess
|
||||
{
|
||||
public static function scope(string $context):string{return hash('sha256',substr($context,0,strrpos($context,':')));}
|
||||
public static function query(array $binding,string $context){
|
||||
return Supplier::query()->where(function($q)use($binding,$context){
|
||||
$q->whereIn('suppliers.id',$binding['supplier_ids']??[]);
|
||||
if(config('receipt_qa.suppliers_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as sa')->whereColumn('sa.tenant_id','suppliers.tenant_id')->where('sa.action','sgf.supplier.command')->where('sa.after->supplier_scope',self::scope($context))->whereRaw("sa.after->'result'->>'supplier_id'=suppliers.id::text"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\{Crypt,DB};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Supplier,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,SupplierParticipation,RequisitionWorkflow};
|
||||
|
||||
/** QA transport around the original supplier controller and FormRequest. */
|
||||
final class SupplierController
|
||||
{
|
||||
private function guard():void{abort_unless(config('receipt_qa.suppliers_enabled')===true,404);}
|
||||
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
|
||||
private function hash(mixed $data):string{return hash('sha256',Payables::canonical([$data]));}
|
||||
private function source(int $id,array $b,object $a,string $context):?Supplier{$this->viewer($a);return $id===0?null:SupplierAccess::query($b,$context)->findOrFail($id);}
|
||||
private function revision(?Supplier $s):string{return $this->hash($s?->toArray());}
|
||||
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','review']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_suppliers.'.$r,$c->unitId());return $out;}
|
||||
private function right(string $action):string{return $action==='review'?'review':'prepare';}
|
||||
private function dispatch(string $action,array $data,?Supplier $supplier,object $actor,Request $http):array{
|
||||
$controller=new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierController;$workflow=app(RequisitionWorkflow::class);
|
||||
$class=$action==='review'?Request::class:\Tempo\Purchasing\Embedded\Http\Requests\SaveSupplierRequest::class;
|
||||
$input=$class::create('/embedded/suppliers','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);
|
||||
$input->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$input->setLaravelSession($http->session());
|
||||
if($action!=='review'){
|
||||
$input->setContainer(app())->setRedirector(app('redirect'));
|
||||
$route=new \Illuminate\Routing\Route(['POST'],'embedded/suppliers/{supplier}',fn()=>null);$route->bind($input);if($supplier)$route->setParameter('supplier',$supplier);else $route->forgetParameter('supplier');$input->setRouteResolver(fn()=>$route);
|
||||
abort_if(array_diff(array_keys($data),array_keys($input->rules())),422);$input->validateResolved();
|
||||
}else abort_if(array_diff(array_keys($data),['status','comment','version']),422);
|
||||
if($action==='create'){$controller->store($input,$workflow);$supplier=Supplier::where('nuit',$data['nuit'])->firstOrFail();}
|
||||
elseif($action==='update')$controller->update($input,$supplier,$workflow);
|
||||
else $controller->review($input,$supplier,$workflow);
|
||||
$supplier->refresh();return ['result'=>['supplier_id'=>$supplier->id,'status'=>$supplier->status,'version'=>$supplier->version],'impact'=>['operation'=>$action,'before'=>$action==='create'?null:$supplier->getOriginal(),'after'=>$supplier->toArray()]];
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_suppliers.view',fn($a)=>$this->viewer($a));return response()->view('supplier-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
|
||||
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','search'=>'nullable|string|max:100','status'=>'nullable|in:pending,active,suspended']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($d,$rights){$this->viewer($a);$q=SupplierAccess::query($b,$o['sgf_context_key']);if($d['search']??'')$q->where(fn($w)=>$w->where('name','ilike','%'.$d['search'].'%')->orWhere('nuit','like','%'.$d['search'].'%'));if($d['status']??'')$q->where('status',$d['status']);$part=app(SupplierParticipation::class);return response()->json(['suppliers'=>$q->orderBy('name')->orderBy('id')->paginate(15,['id','name','nuit','status','version'],'page',(int)($d['page']??1)),'new_revision'=>$this->revision(null),'can_prepare'=>$rights['prepare']&&$part->canPrepare($a)]);});}
|
||||
public function show(int $supplier,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$rights=$this->rights();return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$rights){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$part=app(SupplierParticipation::class);return response()->json(['supplier'=>$s,'revision'=>$this->revision($s),'documents_enabled'=>config('receipt_qa.supplier_documents_enabled')===true&&\Illuminate\Support\Facades\Gate::allows('viewDocuments',$s),'geography_enabled'=>config('geography.enabled')===true,'capabilities'=>['prepare'=>$rights['prepare']&&$part->canPrepare($a),'review'=>$rights['review']&&$part->canReview($a,$s)]]);});}
|
||||
public function history(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);return response()->json(AuditLog::where('tenant_id',$s->tenant_id)->where(function($q)use($supplier){$q->where(fn($w)=>$w->whereIn('action',['supplier.created','supplier.updated','supplier.reviewed'])->where('after->id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_uploaded')->where('after->document->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_downloaded')->where('after->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.document')->where('after->result->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.command')->where('after->result->supplier_id',$supplier));})->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
|
||||
public function preview(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:create,update,review','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>16384,422);abort_unless(($supplier===0)===($d['action']==='create'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_suppliers.'.$this->right($d['action']),function($a,$b,$f,$o)use($supplier,$r,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$d['action']==='review'?'finance':'procurement']);abort_unless(hash_equals($this->revision($s),$d['revision']),409,'A ficha mudou. Confira novamente.');$before=$s?->toArray();$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$s,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$out['impact']['before']=$before;if($d['action']==='create')unset($out['impact']['after']['id'],$out['impact']['after']['created_at'],$out['impact']['after']['updated_at']);$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
|
||||
public function confirm(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['create','update','review'],true),409);return $rt->run('purchasing_suppliers.'.$this->right($cut['action']),function($a,$b,$f,$o)use($supplier,$r,$cut){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$cut['action']==='review'?'finance':'procurement']);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou contexto mudou.');$hash=$this->hash([$supplier,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->revision($s),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s?->toArray();$out=$this->dispatch($cut['action'],$cut['data'],$s,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.command',null,$before,$o+['supplier_scope'=>SupplierAccess::scope($o['sgf_context_key']),'operation_key'=>$cut['operation_key'],'binding_revision'=>$f,'request_hash'=>$hash,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/** Storage is confined to the already verified composition, never public or client storage. */
|
||||
final class SupplierDocumentFiles
|
||||
{
|
||||
public function root(bool $preview=false,string $resource='supplier'):string {
|
||||
abort_unless(in_array($resource,['supplier','receipt'],true),403);
|
||||
$profile=realpath((string)config('receipt_qa.profile'));abort_unless($profile,403);
|
||||
$base=str_replace('\\','/',dirname($profile));$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&preg_match('~^'.preg_quote('C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-','~').'[a-f0-9]{12}$~iD',$base),403);
|
||||
$container=$base.'/'.$resource.($preview?'-document-previews':'-documents');abort_if(is_link($container),403);
|
||||
if(!is_dir($container))mkdir($container,0700);
|
||||
abort_unless(realpath($container)&&str_starts_with(strtolower(realpath($container)),strtolower(realpath($base).DIRECTORY_SEPARATOR)),403);
|
||||
$root=$preview?$container.'/'.bin2hex(random_bytes(16)):$container;
|
||||
if(!is_dir($root))mkdir($root,0700);abort_unless(realpath($root)&&!is_link($root),403);return $root;
|
||||
}
|
||||
public function using(string $root,\Closure $work,string $disk='supplier_documents'):mixed {
|
||||
abort_unless(in_array($disk,['supplier_documents','receipt_documents'],true),403);
|
||||
$before=config('filesystems.disks.'.$disk);config(['filesystems.disks.'.$disk=>['driver'=>'local','root'=>$root,'throw'=>true,'serve'=>false]]);Storage::forgetDisk($disk);
|
||||
try{return $work(Storage::disk($disk));}finally{Storage::forgetDisk($disk);config(['filesystems.disks.'.$disk=>$before]);}
|
||||
}
|
||||
public function path(string $root,string $path,int $tenant,int $supplier):string {
|
||||
abort_unless(preg_match('~^'.$tenant.'/'.$supplier.'/[A-Za-z0-9]{40}\.(pdf|png|jpe?g)$~D',$path),409,'Referência de anexo inválida.');
|
||||
$file=realpath($root.'/'.$path);$base=realpath($root);abort_unless($file&&$base&&!is_link($root.'/'.$path)&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR)),409,'Anexo indisponível.');return $file;
|
||||
}
|
||||
public function discard(string $root,string $path,int $tenant,int $supplier,string $disk='supplier_documents'):void {
|
||||
$this->path($root,$path,$tenant,$supplier);$this->using($root,fn($storage)=>$storage->delete($path),$disk);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\{Request,UploadedFile};
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Gate};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tempo\Purchasing\Embedded\Models\{Supplier,SupplierDocument,AuditLog};
|
||||
use Tempo\Purchasing\Embedded\Services\{CurrentActor,RequisitionWorkflow};
|
||||
|
||||
/** Transport around the original upload/review invalidation and private audited download. */
|
||||
final class SupplierDocumentsController
|
||||
{
|
||||
private function guard():void { abort_unless(config('receipt_qa.supplier_documents_enabled')===true,404); }
|
||||
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
|
||||
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
|
||||
private function source(int $id,array $binding,string $context):Supplier { return SupplierAccess::query($binding,$context)->find($id)??abort(404); }
|
||||
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
|
||||
private function input(Request $http,object $actor,string $type):Request {
|
||||
$files=$http->file('file')?['file'=>$http->file('file')]:[];
|
||||
$r=Request::create('/embedded/supplier-documents','POST',['document_type'=>$type],[],$files,['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;
|
||||
}
|
||||
private function store(Request $http,object $actor,Supplier $supplier,string $type):SupplierDocument {
|
||||
(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->store($this->input($http,$actor,$type),$supplier,app(RequisitionWorkflow::class));return $supplier->documents()->orderByDesc('id')->firstOrFail();
|
||||
}
|
||||
private function document(Supplier $supplier,int $id):SupplierDocument { return $supplier->documents()->find($id)??abort(404); }
|
||||
private function integrity(SupplierDocument $document,string $root):void {
|
||||
$file=app(SupplierDocumentFiles::class)->path($root,$document->path,(int)$document->tenant_id,(int)$document->supplier_id);
|
||||
abort_unless(filesize($file)===(int)$document->size&&hash_equals($document->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
|
||||
}
|
||||
public function index(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
return $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);return response()->json($s->documents()->orderByDesc('id')->paginate(15,['id','supplier_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'],'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
|
||||
}
|
||||
public function preview(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
|
||||
return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$d,$generation,$meta){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(hash_equals($this->hash([$s->toArray()]),$d['revision']),409,'A ficha mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true);$db=DB::connection();$level=$db->transactionLevel();$document=null;$db->beginTransaction();
|
||||
try{$files->using($stage,function()use($r,$a,$s,$d,&$document){$document=$this->store($r,$a,$s,$d['document_type']);});$impact=['document'=>$document->toArray(),'supplier_status'=>'pending','supplier_version'=>$s->fresh()->version,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at'],$impact['document']['updated_at']);}
|
||||
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document)$files->discard($stage,$document->path,(int)$s->tenant_id,$s->id);}
|
||||
$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
|
||||
}
|
||||
public function confirm(int $supplier,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$generation=$this->generation();$meta=$this->meta($r->file('file'));$created=null;
|
||||
try{return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$cut,$generation,$meta,&$created){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
|
||||
$hash=$this->hash([$supplier,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root();$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.document')->where('after->operation_key',$cut['operation_key'])->first();
|
||||
if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$this->integrity($this->document($s,$old->after['result']['document_id']),$root);return response()->json($old->after['result']+['replayed'=>true]);}
|
||||
abort_unless(hash_equals($this->hash([$s->toArray()]),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s->toArray();$document=$files->using($root,fn()=>$this->store($r,$a,$s,$cut['document_type']));$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$s->tenant_id,'supplier'=>$s->id];$this->integrity($document,$root);$result=['supplier_id'=>$s->id,'document_id'=>$document->id,'document'=>$document->toArray(),'supplier_version'=>$s->fresh()->version,'supplier_status'=>'pending'];
|
||||
app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.document',null,$before,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
|
||||
catch(\Throwable $e){
|
||||
// Delete only a newly created file after observing that its DB row did not commit.
|
||||
// If rollback/commit is uncertain or verification fails, retain it for recovery.
|
||||
if($created){try{$exists=DB::connection('receipt_native')->table('supplier_documents')->where(['tenant_id'=>$created['tenant'],'supplier_id'=>$created['supplier'],'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$created['supplier']);}catch(\Throwable){/* Preserve evidence if outcome cannot be verified. */}}throw $e;
|
||||
}
|
||||
}
|
||||
public function download(int $supplier,int $document,Request $r,ReceiptRuntime $runtime) {
|
||||
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$document,$r){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);$doc=$this->document($s,$document);$files=app(SupplierDocumentFiles::class);$root=$files->root();$this->integrity($doc,$root);return $files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->download($this->input($r,$a,$doc->document_type),$s,$doc,app(RequisitionWorkflow::class)));});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Modules\Purchasing;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\HistoricalPayments;
|
||||
use Tempo\Finance\Payables\HistoricalCapture;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Reads explicitly reconciled immutable receipts; never imports or decides payments. */
|
||||
@@ -34,7 +35,7 @@ final class SupplierHistoryReceipts
|
||||
}
|
||||
|
||||
/** Local read snapshots only; no command obtains its source through this batch. */
|
||||
public function page(User $user, array $documents): array
|
||||
public function page(User $user, array $documents, bool $validatedCapture = false): array
|
||||
{
|
||||
abort_unless($this->available(), 503);
|
||||
$adapter = app(SupplierPayables::class); $scope = $adapter->scope($user);
|
||||
@@ -43,14 +44,14 @@ final class SupplierHistoryReceipts
|
||||
->distinct('local_source_id')->orderBy('local_source_id')->orderByDesc('version')->get()->keyBy('local_source_id');
|
||||
$result = [];
|
||||
foreach ($documents as $id => $source) {
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id)); }
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id), $validatedCapture ? $scope : null); }
|
||||
catch (\DomainException $e) { $result[$id] = $e; }
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function decode(array $source, ?object $receipt): array
|
||||
private function decode(array $source, ?object $receipt, ?array $captureScope = null): array|HistoricalCapture
|
||||
{
|
||||
if (! $receipt) {
|
||||
throw new \DomainException('Histórico não reconciliado para esta origem e contexto.');
|
||||
@@ -66,11 +67,12 @@ final class SupplierHistoryReceipts
|
||||
&& is_array($payload['payments'] ?? null) && array_is_list($payload['payments']))) {
|
||||
throw new \DomainException('Recibo histórico antigo ou incompatível; exige nova conferência da origem.');
|
||||
}
|
||||
$capture = HistoricalPayments::capture($source, $payload['payments']);
|
||||
$value = $captureScope === null ? null : HistoricalCapture::fromPayments($captureScope, $source, $payload['payments']);
|
||||
$capture = $value ? $value->forSource($captureScope, $source) : HistoricalPayments::capture($source, $payload['payments']);
|
||||
if (($payload['history_hash'] ?? null) !== $capture['history_hash']) {
|
||||
throw new \DomainException('Fotografia histórica incompatível com os pagamentos originais.');
|
||||
}
|
||||
|
||||
return $capture['payments'];
|
||||
return $value ?? $capture['payments'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SupplierPayableReportController extends Controller
|
||||
{
|
||||
private function criteria(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['q' => 'sometimes|nullable|string|max:100', 'source' => 'sometimes|required|integer|min:1']);
|
||||
abort_if(array_diff(array_keys($request->query()), array_keys($data)) !== [], 422);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function index(Request $request, SupplierPayables $service)
|
||||
{
|
||||
return response()->json($service->report($request->user(), $this->criteria($request)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $request, SupplierPayables $service)
|
||||
{
|
||||
return response()->view('purchasing.payable-report', ['report' => $service->report($request->user(), $this->criteria($request))])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ final class SupplierPayables
|
||||
public static function guard(): void
|
||||
{
|
||||
$c = DB::connection();
|
||||
abort_unless(app()->environment('payable-testing'), 403, 'Obrigações apenas no ensaio isolado.');
|
||||
$composedQa=app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true);
|
||||
abort_unless(app()->environment('payable-testing')||$composedQa, 403, 'Obrigações apenas no ensaio isolado.');
|
||||
if($composedQa)\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) {
|
||||
throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED');
|
||||
}
|
||||
@@ -81,7 +83,32 @@ final class SupplierPayables
|
||||
};
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, fn (string $a) => $authorize($a), $source,
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false));
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false), fn () => $authorize('payments_view', false));
|
||||
}
|
||||
|
||||
/** One predicate for list windows and complete position reports. */
|
||||
private function sourceQuery(array $scope, array $options): array
|
||||
{
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless(mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
$sourceId = array_key_exists('source', $options) ? filter_var($options['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) : null;
|
||||
abort_if($sourceId === false || (isset($options['source']) && is_bool($options['source'])), 422, 'Origem inválida.');
|
||||
if ($sourceId !== null) {
|
||||
abort_unless((clone $query)->where('r.id', $sourceId)->exists(), 403, 'Origem não mapeada neste contexto.');
|
||||
$query->where('r.id', $sourceId);
|
||||
}
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
return [$query, $sourceId];
|
||||
}
|
||||
|
||||
public function listing(User $u, array $options = []): array
|
||||
@@ -93,17 +120,7 @@ final class SupplierPayables
|
||||
$page = (int) ($options['page'] ?? 1); $perPage = (int) ($options['per_page'] ?? 25);
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless($page >= 1 && $page <= 100000 && in_array($perPage, [25, 100], true) && mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
[$query, $sourceId] = $this->sourceQuery($scope, $options);
|
||||
$pager = $query->orderByDesc('r.id')->paginate($perPage, ['r.*'], 'page', $page); $documents = [];
|
||||
foreach ($pager->items() as $row) { $documents[(int) $row->id] = $this->receiptDocument($row); }
|
||||
$provider = app(SupplierHistoryReceipts::class);
|
||||
@@ -111,6 +128,7 @@ final class SupplierPayables
|
||||
$d = $this->buildEngine($u, $documents, $histories)->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $perPage);
|
||||
$d['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()];
|
||||
$d['listing_query'] = ['page' => $page, 'per_page' => $perPage, 'q' => $q, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)];
|
||||
if ($sourceId !== null) { $d['listing_query']['source'] = $sourceId; }
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
$d['history_import_available'] = app(SupplierHistoryReceipts::class)->available();
|
||||
$d['history_decisions_available'] = false;
|
||||
@@ -122,4 +140,79 @@ final class SupplierPayables
|
||||
return $d;
|
||||
});
|
||||
}
|
||||
|
||||
/** Current recognised positions, aggregated through the existing engine in bounded windows. */
|
||||
public function report(User $u, array $criteria = []): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($criteria), ['q', 'source']) !== [], 422);
|
||||
abort_if(isset($criteria['q']) && !is_string($criteria['q']), 422);
|
||||
if (array_key_exists('source', $criteria)) {
|
||||
$id = filter_var($criteria['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
abort_if($id === false || is_bool($criteria['source']), 422);
|
||||
$criteria['source'] = $id;
|
||||
}
|
||||
$own = DB::transactionLevel() === 0;
|
||||
return DB::transaction(function () use ($u, $criteria, $own): array {
|
||||
if ($own) DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
else abort_unless(in_array(DB::selectOne('SHOW transaction_isolation')->transaction_isolation, ['repeatable read', 'serializable'], true), 409, 'O relatório exige uma fotografia coerente própria.');
|
||||
$scope = $this->scope($u);
|
||||
abort_unless($this->can($u, 'view'), 403);
|
||||
$criteria['q'] = trim((string) ($criteria['q'] ?? ''));
|
||||
$hash = hash_init('sha256');
|
||||
hash_update($hash, Payables::canonical(['scope' => $scope, 'criteria' => $criteria, 'basis' => 'current_recognised_positions', 'window' => 100])."\n");
|
||||
$total = ['count' => 0, 'principal_minor' => 0, 'unknown_balance_count' => 0, 'unknown_due_count' => 0];
|
||||
$known = ['count' => 0, 'principal_minor' => 0, 'historical_confirmed_minor' => 0, 'net_allocated_minor' => 0, 'balance_minor' => 0];
|
||||
[$query] = $this->sourceQuery($scope, $criteria + ['recognition' => 'recognised']);
|
||||
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
|
||||
abort_unless(is_string($person) && $person !== '', 403);
|
||||
$provider = app(SupplierHistoryReceipts::class); $historical = $provider->available();
|
||||
$read = function () use ($u, $scope, $person): void {
|
||||
$context = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $u->id && $context->userId() === $u->id, 403);
|
||||
abort_unless(($context->identifiers() + ['unit_id' => $context->unitId()]) === $scope, 409);
|
||||
abort_unless($this->can($u, 'view'), 403);
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409);
|
||||
};
|
||||
$last = null; $windows = 0;
|
||||
do {
|
||||
$window = clone $query;
|
||||
if ($last !== null) $window->where('r.id', '<', $last);
|
||||
$rows = $window->orderByDesc('r.id')->limit(100)->get(['r.*']);
|
||||
if ($rows->isEmpty()) break;
|
||||
$documents = [];
|
||||
foreach ($rows as $row) $documents[(int) $row->id] = $this->receiptDocument($row);
|
||||
$histories = $historical ? $provider->page($u, $documents, true) : [];
|
||||
$engine = new Payables(DB::connection(), $u->id, $person, $scope,
|
||||
static fn () => throw new \LogicException('Relatórios nunca executam comandos.'),
|
||||
fn (int $id) => $documents[$id] ?? abort(403),
|
||||
$historical ? static function (int $id) use ($histories) {
|
||||
$result = $histories[$id] ?? new \DomainException('Histórico fora da janela.');
|
||||
if ($result instanceof \DomainException) throw $result;
|
||||
return $result;
|
||||
} : null, $read);
|
||||
$data = $engine->positionsPage(array_keys($documents));
|
||||
foreach ($data['documents'] as $doc) hash_update($hash, Payables::canonical(['local_source_id' => $doc['local_id'], 'current_revision' => $doc['revision']])."\n");
|
||||
foreach ($data['obligations'] as $o) {
|
||||
abort_unless($o['currency'] === 'MZN', 409, 'Moeda incompatível no relatório; nenhuma soma entre moedas.');
|
||||
$total['count']++; $total['principal_minor'] += $o['principal_minor'];
|
||||
if ($o['due_on'] === null) $total['unknown_due_count']++;
|
||||
if ($o['source_conflict'] || $o['balance_minor'] === null) $total['unknown_balance_count']++;
|
||||
else {
|
||||
$known['count']++; $known['principal_minor'] += $o['principal_minor'];
|
||||
$known['historical_confirmed_minor'] += $o['confirmed_payments_minor'];
|
||||
$known['net_allocated_minor'] += $o['allocated_payments_minor'];
|
||||
$known['balance_minor'] += $o['balance_minor'];
|
||||
}
|
||||
hash_update($hash, Payables::canonical($o)."\n");
|
||||
}
|
||||
$windows++; $last = (int) $rows->last()->id;
|
||||
} while ($rows->count() === 100);
|
||||
abort_unless($known['principal_minor'] - $known['historical_confirmed_minor'] - $known['net_allocated_minor'] === $known['balance_minor'], 409, 'Componentes financeiros não reconciliados.');
|
||||
$total['balance_minor'] = $total['unknown_balance_count'] === 0 ? $known['balance_minor'] : null;
|
||||
return ['version' => 'SGF-PAYABLE-POSITION-REPORT-SIM-v1', 'synthetic_only' => true,
|
||||
'basis' => 'current_recognised_positions', 'captured_at_utc' => DB::selectOne("SELECT transaction_timestamp() AT TIME ZONE 'UTC' AS captured")->captured,
|
||||
'scope' => $scope, 'criteria' => $criteria, 'currency' => 'MZN', 'totals' => $total, 'known_positions' => $known,
|
||||
'positions_sha256' => hash_final($hash), 'window_size' => 100, 'windows' => $windows];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
private function options(Request $r): array
|
||||
public function listingOptions(Request $r): array
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
$d = $r->validate(['source' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'event_page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100',
|
||||
'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
@@ -21,17 +21,21 @@ final class SupplierPayablesController extends Controller
|
||||
|
||||
public function index(Request $r, SupplierPayables $s)
|
||||
{
|
||||
return response()->json($s->listing($r->user(), $this->options($r)))->header('Cache-Control', 'no-store');
|
||||
return response()->json($s->listing($r->user(), $this->listingOptions($r)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s)
|
||||
{
|
||||
$data = $s->listing($r->user(), $this->options($r));
|
||||
$data = $s->listing($r->user(), $this->listingOptions($r));
|
||||
$scope = $s->scope($r->user());
|
||||
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
|
||||
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
|
||||
$flow=[];
|
||||
if(config('general_ledger.synthetic_enabled')===true&&isset($data['listing_query']['source'])&&app(\App\Modules\Identity\Services\Permissions::class)->has($r->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id'])){
|
||||
$flow=['ledger_flow_url'=>'/general-ledger/flow?source='.$data['listing_query']['source'],'ledger_flow_scope'=>$scope];
|
||||
}
|
||||
|
||||
return response(Workspace::render($data, ['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
return response(Workspace::render($data, $flow+['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'document_filter' => 'source', 'report_url' => '/supplier-payables/report', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action, SupplierPayables $s)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Tempo\Finance\Payables\SettlementHttp;
|
||||
|
||||
final class SupplierPaymentsController extends SettlementHttp
|
||||
@@ -9,4 +10,27 @@ final class SupplierPaymentsController extends SettlementHttp
|
||||
protected const MODE = 'sgf';
|
||||
|
||||
protected const SERVICE = SupplierPayables::class;
|
||||
|
||||
private const PAYMENT_FILTERS = ['payments_page', 'allocation_proposals_page', 'allocations_page', 'payment_events_page', 'payment_per_page', 'payment_q', 'payment_state', 'payment'];
|
||||
protected function ledgerSettings(Request $request):array
|
||||
{
|
||||
if(config('general_ledger.cycle_enabled')!==true)return [];
|
||||
$scope=app(SupplierPayables::class)->scope($request->user());
|
||||
if(!app(\App\Modules\Identity\Services\Permissions::class)->has($request->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']))return [];
|
||||
return ['ledger_cycle_url'=>'/general-ledger/cycle','ledger_scope'=>$scope];
|
||||
}
|
||||
|
||||
protected function obligationOptions(Request $request): array
|
||||
{
|
||||
return app(SupplierPayablesController::class)->listingOptions($request->duplicate(array_diff_key($request->query(), array_flip(self::PAYMENT_FILTERS))));
|
||||
}
|
||||
|
||||
protected function settlementOptions(Request $request): ?array
|
||||
{
|
||||
return $request->validate(['payments_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'allocation_proposals_page' => 'sometimes|integer|min:1|max:100000', 'allocations_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'payment_events_page' => 'sometimes|integer|min:1|max:100000', 'payment_per_page' => 'sometimes|integer|in:25,100',
|
||||
'payment_q' => 'sometimes|nullable|string|max:100', 'payment_state' => 'sometimes|in:all,proposed,confirmed,rejected',
|
||||
'payment' => 'sometimes|required|integer|min:1']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/** Navigation only: every destination retains its own current native authority checks. */
|
||||
final class WorkspaceNavigation
|
||||
{
|
||||
public function links():array
|
||||
{
|
||||
if(config('receipt_qa.enabled')!==true||!app()->environment('budget-testing'))return [];
|
||||
$c=app(CompanyContext::class);$scope=$c->identifiers();if(!$c->unitId())return [];
|
||||
$definitions=[
|
||||
['requests','requests','Requisições','Preparar, submeter e obter aprovação distinta.'],
|
||||
['suppliers','suppliers','Fornecedores','Conferir cadastro e revisão antes das propostas.'],
|
||||
['sourcing','sourcing','Propostas e adjudicação','Comparar propostas e fundamentar a escolha; aprovação distinta.'],
|
||||
['orders','orders','Ordens','Emitir a partir da adjudicação aprovada.'],
|
||||
['enabled','receipts','Recepções','Conferir quantidades e qualidade da ordem emitida.'],
|
||||
['invoices','invoices','Facturas','Registar e validar o documento recebido.'],
|
||||
['obligations','obligations','Obrigações','Preparar fotografia e reconhecer por pessoa distinta.'],
|
||||
];$links=[];$permissions=app(Permissions::class);
|
||||
foreach($definitions as[$flag,$path,$label,$description]){
|
||||
$flag=$flag==='enabled'?$flag:$flag.'_enabled';$right='purchasing_'.$path.'.view';
|
||||
if(config('receipt_qa.'.$flag)===true&&$permissions->has($c->userId(),$scope['tenant_id'],$scope['company_id'],$right,$c->unitId()))$links[]=['url'=>'/purchasing/'.$path,'label'=>$label,'description'=>$description,'right'=>$right];
|
||||
}
|
||||
if(config('receipt_qa.dossiers_enabled')===true&&array_filter($links,fn($link)=>$link['right']==='purchasing_requests.view'))$links[]=['url'=>'/purchasing/dossiers','label'=>'Dossiês e evidência','description'=>'Consultar as etapas e o histórico autorizado da mesma requisição.','right'=>'purchasing_requests.view'];
|
||||
return $links;
|
||||
}
|
||||
public function screen(Request $r,ReceiptRuntime $runtime)
|
||||
{
|
||||
abort_unless(config('receipt_qa.enabled')===true,404);$links=$this->links();abort_unless($links,403);
|
||||
$runtime->run($links[0]['right'],fn()=>null);
|
||||
return response()->view('purchasing-workspace',['links'=>$links]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
use App\Modules\Identity\Services\Permissions;use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use Illuminate\Support\Facades\{DB,Validator};
|
||||
|
||||
/** Read-only register. SQL filters/pages precede bounded financial enrichment. */
|
||||
final class BillingRegister
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
abort_unless(config('service_documents.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='commercial',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
$rules=['kind'=>'sometimes|in:invoice,invoice_receipt,receipt,credit_note,quote,draft','search'=>'sometimes|nullable|string|max:100','from'=>'sometimes|date_format:Y-m-d','to'=>'sometimes|date_format:Y-m-d','currency'=>'sometimes|regex:/^[A-Z]{3}$/','series'=>'sometimes|string|max:100','author'=>'sometimes|integer|min:1','state'=>'sometimes|in:issued,cancelled,draft,confirmed_internal,superseded,approved,rejected,refused,customer_accepted,customer_declined,converted','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();abort_if(isset($d['from'],$d['to'])&&$d['from']>$d['to'],422,'Intervalo de emissão inválido.');
|
||||
$has=fn($right)=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],$right,$s['unit_id']);$rights=['invoice'=>$has('service_invoices.view'),'additional'=>$has('service_documents.view'),'quote'=>$has('commercial_quotes.view'),'draft'=>$has('billing_drafts.view'),'finance'=>$has('service_receivables.view')];abort_unless($rights['invoice']||$rights['additional']||$rights['quote']||$rights['draft'],403);
|
||||
return DB::transaction(function()use($actor,$s,$d,$rights){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$families=[];
|
||||
$invoiceQuery=fn()=>ModuleRecords::query('si_invoices')->where($s);
|
||||
if($rights['invoice']){$q=$invoiceQuery();if($rights['additional'])$q->whereNotExists(fn($v)=>$v->selectRaw('1')->from('sd_outputs as fr')->whereColumn('fr.invoice_id','si_invoices.id')->where(['fr.tenant_id'=>$s['tenant_id'],'fr.company_id'=>$s['company_id'],'fr.unit_id'=>$s['unit_id'],'fr.kind'=>'invoice_receipt'])->whereIn('fr.invoice_id',app(ServiceDocuments::class)->sourceInvoices($actor)->select('id')));
|
||||
$families[]=$q->selectRaw("'invoice'::text kind,id source_id,series_code||'/'||number::text reference,snapshot->'document'->'client'->>'name' client,snapshot->'document'->'client'->'data'->>'nuit' nuit,(issued_at AT TIME ZONE 'Africa/Maputo')::date document_on,snapshot->'calculation'->>'total_document_proposed' amount,NULL::text base,'MZN'::text currency,'issued'::text document_state,actor_id,id invoice_id,NULL::text source_key,1::integer version,issued_at sort_at");}
|
||||
if($rights['additional']){
|
||||
$q=DB::table('sd_outputs as o')->join('si_invoices as i','i.id','=','o.invoice_id')->where(['o.tenant_id'=>$s['tenant_id'],'o.company_id'=>$s['company_id'],'o.unit_id'=>$s['unit_id']])->whereIn('i.id',app(ServiceDocuments::class)->sourceInvoices($actor)->select('id'));
|
||||
$families[]=$q->selectRaw("o.kind,o.id source_id,'TEST-SD-'||upper(o.kind)||'/'||o.number::text reference,i.snapshot->'document'->'client'->>'name' client,i.snapshot->'document'->'client'->'data'->>'nuit' nuit,(o.created_at AT TIME ZONE 'Africa/Maputo')::date document_on,o.snapshot->>'amount' amount,NULL::text base,'MZN'::text currency,CASE WHEN EXISTS(SELECT 1 FROM sd_cancellations c WHERE c.output_id=o.id AND c.state='cancelled') THEN 'cancelled' ELSE 'issued' END document_state,o.actor_id,o.invoice_id,o.document_key::text source_key,3::integer version,o.created_at sort_at");
|
||||
}
|
||||
if($rights['draft']){
|
||||
$draftState="CASE WHEN EXISTS(SELECT 1 FROM b_internal_confirmations b WHERE b.billing_draft_id=a_billing_drafts.id AND b.tenant_id=a_billing_drafts.tenant_id AND b.company_id=a_billing_drafts.company_id AND b.unit_id=a_billing_drafts.unit_id) THEN 'confirmed_internal' ELSE 'draft' END";
|
||||
if(DB::getSchemaBuilder()->hasTable('wc_applications'))$draftState="CASE WHEN EXISTS(SELECT 1 FROM wc_applications w WHERE w.old_billing_id=a_billing_drafts.id AND w.kind='replace') THEN 'superseded' WHEN EXISTS(SELECT 1 FROM wc_applications w WHERE w.old_billing_id=a_billing_drafts.id) THEN 'cancelled' ELSE ".$draftState.' END';
|
||||
$families[]=ModuleRecords::query('a_billing_drafts')->where($s)->selectRaw("'draft'::text kind,id source_id,'Interno #'||id::text reference,data->>'client_name' client,NULL::text nuit,NULL::date document_on,NULL::text amount,NULL::text base,data->>'currency' currency,".$draftState." document_state,(SELECT h.actor_id FROM a_history h WHERE h.area='billing_drafts' AND h.record_id=a_billing_drafts.id AND h.version=a_billing_drafts.version AND h.tenant_id=a_billing_drafts.tenant_id AND h.company_id=a_billing_drafts.company_id AND h.unit_id=a_billing_drafts.unit_id) actor_id,NULL::bigint invoice_id,NULL::text source_key,version,updated_at sort_at");
|
||||
}
|
||||
if($rights['quote']){
|
||||
$q=DB::table('cq_versions as q')->where(['q.tenant_id'=>$s['tenant_id'],'q.company_id'=>$s['company_id'],'q.unit_id'=>$s['unit_id']])->whereIn('q.client_id',ModuleRecords::query('a_clients')->select('id'))->whereNotExists(fn($v)=>$v->selectRaw('1')->from('cq_versions as n')->whereColumn('n.quote_key','q.quote_key')->whereColumn('n.tenant_id','q.tenant_id')->whereColumn('n.company_id','q.company_id')->whereColumn('n.unit_id','q.unit_id')->whereColumn('n.version','>','q.version'));
|
||||
$families[]=$q->selectRaw("'quote'::text kind,q.id source_id,'ORC-QA-'||upper(substr(q.quote_key::text,1,8)) reference,q.dependencies->'client'->>'name' client,NULL::text nuit,NULL::date document_on,NULL::text amount,q.data->>'base' base,q.data->>'currency' currency,q.state document_state,q.actor_id,NULL::bigint invoice_id,q.quote_key::text source_key,q.version,q.created_at sort_at");
|
||||
}
|
||||
$union=array_shift($families);foreach($families as $q)$union->unionAll($q);$q=DB::query()->fromSub($union,'document_register');if(isset($d['kind']))$q->where('kind',$d['kind']);if(isset($d['state']))$q->where('document_state',$d['state']);if(isset($d['from']))$q->where('document_on','>=',$d['from']);if(isset($d['to']))$q->where('document_on','<=',$d['to']);
|
||||
if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($v)=>$v->whereRaw("reference ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("client ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("nuit ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
if(isset($d['currency']))$q->where('currency',$d['currency']);if(isset($d['author']))$q->where('actor_id',$d['author']);if(isset($d['series']))$q->whereRaw("CASE WHEN kind IN('invoice','invoice_receipt','receipt','credit_note') THEN split_part(reference,'/',1) ELSE NULL END = ?",[$d['series']]);
|
||||
$p=$q->orderByDesc('sort_at')->orderBy('kind')->orderByDesc('source_id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$ids=$p->pluck('invoice_id')->filter()->unique()->values()->all();$positions=collect();$draftIds=collect($p->items())->where('kind','draft')->pluck('source_id')->all();$bases=[];
|
||||
if($draftIds!==[])foreach(ModuleRecords::query('a_billing_drafts')->where($s)->whereIn('id',$draftIds)->get(['id','data']) as $draft)$bases[$draft->id]=DraftValues::base(json_decode($draft->data,true,32,JSON_THROW_ON_ERROR));
|
||||
if($rights['finance']&&$ids!==[]){$positions=DB::table('sr_records as register_ob')->leftJoin('sr_effects as e','e.obligation_id','=','register_ob.id')->where(['register_ob.tenant_id'=>$s['tenant_id'],'register_ob.company_id'=>$s['company_id'],'register_ob.unit_id'=>$s['unit_id'],'register_ob.kind'=>'obligation'])->whereIn('register_ob.invoice_id',$ids)->groupBy('register_ob.id','register_ob.invoice_id','register_ob.amount')->selectRaw("register_ob.invoice_id,CASE WHEN count(*) FILTER(WHERE e.kind='open')>0 THEN (register_ob.amount-coalesce(sum(CASE WHEN e.kind IN('allocate','reduction') THEN e.amount WHEN e.kind IN('reverse_allocation','reverse_reduction') THEN -e.amount ELSE 0 END),0))::text ELSE NULL END balance")->get()->keyBy('invoice_id');}
|
||||
foreach($p->items() as $row){if($row->kind==='draft')$row->base=$bases[$row->source_id]??null;$row->series=in_array($row->kind,['invoice','invoice_receipt','receipt','credit_note'],true)?explode('/',$row->reference,2)[0]:null;$row->origin_invoice_balance=$rights['finance']?$positions->get($row->invoice_id)?->balance:null;$row->balance_available=$rights['finance']&&$row->origin_invoice_balance!==null;$row->pdf_url=match($row->kind){'invoice'=>'/api/service-invoices/'.$row->source_id.'/pdf','invoice_receipt','receipt','credit_note'=>'/api/service-documents/'.$row->source_id.'/pdf',default=>null};$row->print_url=$row->kind==='quote'?'/commercial-quotes/'.$row->source_key.'/print?version='.$row->version:null;}
|
||||
return ['documents'=>$p,'financial_access'=>$rights['finance'],'notice'=>ServiceInvoicing::NOTICE,'has_mixed_document_types'=>true,'totals_not_summed'=>true];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
use App\Modules\Identity\Services\{Permissions,Audit};use App\Shared\Context\{CompanyContext,ModuleRecords};use App\Shared\Installation\CompositionRuntime;use Illuminate\Support\Facades\{DB,Crypt,Validator};use Carbon\CarbonImmutable;
|
||||
|
||||
final class CommercialQuotes
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function scope(int $actor,string $action='view'):array{abort_unless(config('commercial_quotes.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='commercial',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(['view',$action] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'commercial_quotes.'.$right,$s['unit_id']);return $s;}
|
||||
private function canonical(mixed $v):mixed{if(is_object($v))$v=(array)$v;if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
|
||||
private function json(mixed $v):string{return json_encode($this->canonical($v),JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);}
|
||||
private function hash(mixed $v):string{return hash('sha256',$this->json($v));}
|
||||
private function current(array $s){return DB::table('cq_versions as q')->where(['q.tenant_id'=>$s['tenant_id'],'q.company_id'=>$s['company_id'],'q.unit_id'=>$s['unit_id']])->whereNotExists(fn($x)=>$x->selectRaw('1')->from('cq_versions as n')->whereColumn('n.previous_id','q.id'));}
|
||||
private function right(string $action):string{return match($action){'prepare','customer'=>'prepare','review'=>'review','convert'=>'convert',default=>abort(422)};}
|
||||
private function data(array $input):array
|
||||
{
|
||||
foreach(['reason','representative','evidence'] as $k)if(isset($input[$k])&&is_string($input[$k]))$input[$k]=trim($input[$k]);$rules=['action'=>'required|in:prepare,review,customer,convert','quote_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();$d['version']=(int)$d['version'];$d['quote_key']=strtolower($d['quote_key']);return $d;
|
||||
}
|
||||
private function basis(int $actor,array $s,array $d):array
|
||||
{
|
||||
$old=$this->current($s)->where('quote_key',$d['quote_key'])->first();abort_unless(($old?(int)$old->version:0)===$d['version'],409,'Orçamento alterado. Actualize a conferência.');abort_if($old&&$old->state==='converted',409,'Orçamento já convertido; o original permanece preservado.');$fields=$d['data'];
|
||||
if($d['action']==='prepare'){
|
||||
$rules=['client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','valid_until'=>'required|date_format:Y-m-d','source_order_id'=>'nullable|integer|min:1','description'=>'required|string|min:3|max:1000','lines'=>'required|array|min:1|max:100','lines.*.service_id'=>'required|integer|min:1','lines.*.quantity'=>'required|string','lines.*.unit_price'=>'required|string','lines.*.description'=>'nullable|string|max:500'];abort_if(array_diff(array_keys($fields),['client_id','currency','valid_until','source_order_id','description','lines']),422);$v=Validator::make($fields,$rules)->validate();$v['client_id']=(int)$v['client_id'];$v['source_order_id']=isset($v['source_order_id'])?(int)$v['source_order_id']:null;abort_if($v['valid_until']<now('Africa/Maputo')->toDateString(),422,'Validade anterior à data actual.');$v['lines']=DraftValues::lines($v['lines'],$s);foreach($v['lines'] as $line)abort_if($line['quantity']===null||$line['unit_price']===null,422,'Quantidade e preço explícitos obrigatórios.');$v['base']=ServiceReceivables::money(DraftValues::base($v));$v['tax_status']='pending_separate_review';$state='draft';
|
||||
}else{abort_unless($old,403,'Orçamento fora do âmbito.');$v=json_decode($old->data,true);$state=$old->state;}
|
||||
$client=ModuleRecords::query('a_clients')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'id'=>$v['client_id']])->first();abort_unless($client,403,'Cliente sem associação explícita ao Comercial.');
|
||||
$services=ModuleRecords::query('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->whereIn('id',array_column($v['lines'],'service_id'))->orderBy('id')->get(['id','version','name'])->all();abort_unless(count($services)===count(array_unique(array_column($v['lines'],'service_id'))),403,'Serviço fora do módulo autorizado.');
|
||||
$origin=null;if($v['source_order_id']!==null){$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop_billing.view',$s['unit_id']);$o=DB::table('wo_orders')->where($s)->where('id',$v['source_order_id'])->first();abort_unless($o&&$o->client_id===$v['client_id'],403,'Ordem de origem incompatível.');abort_if(DB::table('wb_generations')->where('order_id',$o->id)->exists()||DB::table('cq_conversions')->where('source_order_id',$o->id)->exists(),409,'Ordem já convertida por outro percurso.');$origin=['order'=>['id'=>$o->id,'reference'=>$o->reference,'client_id'=>$o->client_id,'equipment_id'=>$o->equipment_id],'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) v',[$o->id])->v];}
|
||||
$dependencies=['client'=>['id'=>$client->id,'version'=>$client->version,'name'=>$client->name],'services'=>$services,'origin'=>$origin];if($d['action']!=='prepare')abort_unless($this->hash(json_decode($old->dependencies,true))===$this->hash($dependencies),409,'Cliente, serviço ou ordem mudou desde a preparação. Prepare nova versão.');
|
||||
if($d['action']==='review'){abort_unless($state==='draft',409,'Revisão exige a versão preparada actual.');abort_if($old->prepared_by===$actor,403,'Revisão distinta da preparação.');$v['review']=Validator::make($fields,['decision'=>'required|in:approved,rejected'])->validate();abort_if(array_diff(array_keys($fields),['decision']),422);$v['review']+=['actor_id'=>$actor,'reason'=>$d['reason']];$state=$fields['decision']==='approved'?'approved':'rejected';}
|
||||
if($d['action']==='customer'){abort_unless($state==='approved',409,'Conferência comercial aprovada necessária.');abort_if($v['valid_until']<now('Africa/Maputo')->toDateString(),409,'Orçamento caducado.');$rules=['decision'=>'required|in:accepted,declined','representative'=>'required|string|min:3|max:150','capacity'=>'required|string|min:3|max:150','evidence'=>'required|string|min:10|max:2000','decided_on'=>'required|date_format:Y-m-d|before_or_equal:today'];abort_if(array_diff(array_keys($fields),array_keys($rules)),422);$v['customer']=Validator::make($fields,$rules)->validate();$v['customer']['recorded_by']=$actor;$state=$fields['decision']==='accepted'?'customer_accepted':'customer_declined';}
|
||||
if($d['action']==='convert'){abort_unless($fields===[]&&$state==='customer_accepted',409,'Aceitação documentada necessária; conversão não é automática.');abort_if($v['valid_until']<now('Africa/Maputo')->toDateString(),409,'Orçamento caducado.');abort_if(DB::table('cq_conversions')->where($s)->where('quote_key',$d['quote_key'])->exists(),409);}
|
||||
$grants=DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('permission')->orderBy('unit_id')->get(['permission','unit_id'])->all();
|
||||
return ['previous'=>$old?(array)$old:null,'data'=>$v,'state'=>$state,'prepared_by'=>$d['action']==='prepare'?$actor:$old->prepared_by,'dependencies'=>['client'=>['id'=>$client->id,'version'=>$client->version,'name'=>$client->name],'services'=>$services,'origin'=>$origin],'authority'=>$grants];
|
||||
}
|
||||
private function locked(int $actor,string $action,\Closure $fn):mixed{$s=$this->scope($actor,$this->right($action));return DB::transaction(function()use($actor,$action,$s,$fn){DB::statement("SET LOCAL lock_timeout='5s'");DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();$this->scope($actor,$this->right($action));DB::table('permission_grants')->where(['user_id'=>$actor,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->lockForUpdate()->get();return $fn($s);});}
|
||||
private function execute(int $actor,array $s,array $d,array $b):array
|
||||
{
|
||||
$state=$d['action']==='convert'?'converted':$b['state'];$drafts=null;
|
||||
if($d['action']==='convert'){$writer=app(ServiceWorkflow::class);$data=$b['data'];$source=$writer->save('service_drafts',null,$actor,['name'=>'Orçamento '.substr($d['quote_key'],0,8),'client_id'=>$data['client_id'],'currency'=>$data['currency'],'description'=>$data['description'],'lines'=>$data['lines']],(string)\Illuminate\Support\Str::uuid());$billing=$writer->save('billing_drafts',null,$actor,['name'=>'Orçamento '.substr($d['quote_key'],0,8),'source_id'=>$source['id']],(string)\Illuminate\Support\Str::uuid());$drafts=['service_draft_id'=>$source['id'],'billing_draft_id'=>$billing['id']];}
|
||||
$id=DB::table('cq_versions')->insertGetId($s+['quote_key'=>$d['quote_key'],'version'=>$d['version']+1,'previous_id'=>$b['previous']['id']??null,'action'=>$d['action'],'state'=>$state,'client_id'=>$b['data']['client_id'],'source_order_id'=>$b['data']['source_order_id'],'prepared_by'=>$b['prepared_by'],'actor_id'=>$actor,'data'=>$this->json($b['data']),'dependencies'=>$this->json($b['dependencies']),'reason'=>$d['reason'],'created_at'=>now()]);
|
||||
if($drafts)DB::table('cq_conversions')->insert($s+['quote_key'=>$d['quote_key'],'quote_version_id'=>$id,'source_order_id'=>$b['data']['source_order_id'],'actor_id'=>$actor,'created_at'=>now()]+$drafts);
|
||||
$impact=['action'=>$d['action'],'reference'=>'ORC-QA-'.strtoupper(substr($d['quote_key'],0,8)),'version'=>$d['version']+1,'state'=>$state,'client'=>$b['dependencies']['client']['name'],'base'=>$b['data']['base'],'currency'=>'MZN','valid_until'=>$b['data']['valid_until'],'tax_status'=>'pending_separate_review','lines'=>$b['data']['lines'],'source_reference'=>$b['dependencies']['origin']['order']['reference']??null,'creates_draft'=>$drafts!==null,'invoice_issued'=>false,'stock_effect'=>false,'financial_effect'=>false];return ['id'=>$id,'quote_key'=>$d['quote_key'],'version'=>$d['version']+1,'state'=>$state,'drafts'=>$drafts,'impact'=>$impact];
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array{$d=$this->data($input);Validator::make(['key'=>$key],['key'=>'required|uuid'])->validate();return $this->locked($actor,$d['action'],function($s)use($actor,$d,$key){abort_if(DB::table('cq_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->exists(),409,'Consulte o recibo da chave já usada.');$b=$this->basis($actor,$s,$d);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($actor,$s,$d,$b);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'data'=>$d,'key'=>$key,'revision'=>$this->hash($b),'impact'=>$out['impact'],'generation'=>$this->hash(request()->session()->get('company_context',[])),'observed'=>time()];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});}
|
||||
public function confirm(int $actor,string $token):array{try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$this->data($c['data']??[]);return $this->locked($actor,$d['action'],function($s)use($actor,$c,$d){abort_unless(($c['actor']??null)===$actor&&$this->hash($c['scope']??null)===$this->hash($s)&&($c['generation']??null)===$this->hash(request()->session()->get('company_context',[]))&&($c['observed']??0)>=time()-600&&$c['observed']<=time(),409,'Prévia/contexto expirados ou alterados.');$old=DB::table('cq_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409);$result=json_decode($old->result,true);$source=DB::table('cq_versions')->where($s)->where('id',$result['id'])->first();abort_unless($source&&ModuleRecords::query('a_clients')->where(['id'=>$source->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Origem já não autorizada no módulo.');if($source->source_order_id!==null)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop_billing.view',$s['unit_id']);return $result+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($this->hash($b),$c['revision']),409,'Cliente, serviços, origem, autoridade ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('cq_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('commercial.quote.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'reason'=>$d['reason']]);return $out+['replayed'=>false];});}
|
||||
public function listing(int $actor,array $input):array{$s=$this->scope($actor);$rules=['state'=>'sometimes|in:draft,approved,rejected,customer_accepted,customer_declined,converted','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();$q=$this->current($s)->join('a_clients as c','c.id','=','q.client_id')->whereIn('q.client_id',ModuleRecords::query('a_clients')->select('id'));if(isset($d['state']))$q->where('q.state',$d['state']);if(($d['search']??'')!=='')$q->whereRaw("c.name ILIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']);$p=$q->orderByDesc('q.id')->paginate((int)($d['per_page']??25),['q.*','c.name as client_name'],'page',(int)($d['page']??1));foreach($p->items() as $row){$row->data=json_decode($row->data,true);$row->dependencies=json_decode($row->dependencies,true);$row->expired=$row->data['valid_until']<now('Africa/Maputo')->toDateString();$row->conversion=DB::table('cq_conversions')->where($s)->where('quote_key',$row->quote_key)->first();}return ['quotes'=>$p,'notice'=>'Orçamento não é factura. Impostos exigem conferência separada.'];}
|
||||
public function history(int $actor,string $key,int $page=1):array{$s=$this->scope($actor);Validator::make(['key'=>$key,'page'=>$page],['key'=>'required|uuid','page'=>'required|integer|min:1|max:100000'])->validate();$q=DB::table('cq_versions')->where($s)->where('quote_key',$key)->whereIn('client_id',ModuleRecords::query('a_clients')->select('id'));abort_unless((clone$q)->exists(),403);return ['events'=>$q->orderByDesc('version')->paginate(25,['*'],'page',$page)];}
|
||||
public function options(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$rules=['view'=>'required|in:clients,services,orders','search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();
|
||||
if($d['view']==='orders'){$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop_billing.view',$s['unit_id']);$q=DB::table('wo_orders')->where($s)->whereIn('client_id',ModuleRecords::query('a_clients')->select('id'))->whereNotIn('id',DB::table('wb_generations')->select('order_id'))->whereNotIn('id',DB::table('cq_conversions')->whereNotNull('source_order_id')->select('source_order_id'));$fields=['id','reference as name','client_id'];$column='reference';}
|
||||
else{$q=ModuleRecords::query('a_'.$d['view'])->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']]);$fields=['id','name','version'];$column='name';}
|
||||
if(($d['search']??'')!=='')$q->whereRaw($column." ILIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']);return ['records'=>$q->orderBy('id')->paginate((int)($d['per_page']??25),$fields,'page',(int)($d['page']??1))];
|
||||
}
|
||||
public function document(int $actor,string $key,int $version):object{$s=$this->scope($actor);Validator::make(['key'=>$key,'version'=>$version],['key'=>'required|uuid','version'=>'required|integer|min:1'])->validate();$row=DB::table('cq_versions')->where($s)->where(['quote_key'=>$key,'version'=>$version])->whereIn('client_id',ModuleRecords::query('a_clients')->select('id'))->first();abort_unless($row,403);$row->data=json_decode($row->data,true);$row->dependencies=json_decode($row->dependencies,true);return $row;}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
use App\Shared\Installation\CompositionRuntime;use App\Modules\Accounting\SourceBook;use Illuminate\Support\Facades\DB;
|
||||
final class DocumentPeriod
|
||||
{
|
||||
public static function open(array $scope):array
|
||||
{
|
||||
$physical=CompositionRuntime::physical();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$physical['composition'].'/binding.json';
|
||||
abort_unless(realpath((string)config('receipt_qa.profile'))===realpath($file),403);$p=json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);$c=$p['connection'];abort_unless($c['database']==='compras_receipts_test_'.$physical['composition']&&$c['username']==='payable_history_native_'.$physical['composition']&&$c['host']==='127.0.0.1'&&(int)$c['port']===55439,403);
|
||||
config(['database.connections.receipt_native'=>$c]);$period=SourceBook::table('gl_periods')->where($scope)->where('period_key','QA-2026-INTEGRADO')->orderByDesc('version')->first();abort_unless($period&&$period->status==='open',409,'A anulação exige período QA aberto; não há reabertura.');return (array)$period;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\{Audit,Permissions};
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** One explicitly bound QA output book and internal map. No tax settlement or ledger writer. */
|
||||
final class FiscalAssessment
|
||||
{
|
||||
public const CONTRACT='SGF-IVA-ASSESSMENT-FORM-QA-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}
|
||||
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
private function prefix(string $kind):string{return $kind==='assessment'?'fiscal_assessments.':'fiscal_forms.';}
|
||||
public function scope(int $actor,string $kind='assessment',string $action='view'):array
|
||||
{
|
||||
abort_unless(config('fiscal_qa.enabled')===true,404);CompositionRuntime::assert();
|
||||
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='accounting',403);
|
||||
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$this->prefix($kind).$r,$s['unit_id']);abort_unless($this->binding()['scope']===$s,403);return $s;
|
||||
}
|
||||
public function binding():array
|
||||
{
|
||||
$p=CompositionRuntime::physical();$path='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$p['composition'].'/fiscal-binding.json';$pin=config('fiscal_qa.binding_sha256');abort_unless(is_string($pin)&&is_file($path)&&!is_link($path)&&hash_equals($pin,hash_file('sha256',$path)),503,'Origem fiscal QA não conferida.');$b=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR);abort_unless($b['composition']===$p['composition']&&$b['contract']===self::CONTRACT&&$b['scope']===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],403);return $b;
|
||||
}
|
||||
private function rows(array $s,array $b,string $kind){return DB::table('fx_versions')->where($s)->where(['process_key'=>$b['process_key'],'kind'=>$kind]);}
|
||||
private function latest(array $s,array $b,string $kind):?array{$r=$this->rows($s,$b,$kind)->orderByDesc('version')->first();return $r?$this->row($r):null;}
|
||||
private function row(object $r):array{$r=(array)$r;foreach(['data','dependencies']as$k)$r[$k]=json_decode($r[$k],true,64,JSON_THROW_ON_ERROR);return $r;}
|
||||
public function source(array $s,array $b):array
|
||||
{
|
||||
$doc=DB::table('sd_outputs')->where($s)->where(['id'=>$b['source_output_id'],'invoice_id'=>$b['invoice_id'],'kind'=>'invoice_receipt'])->first();
|
||||
$invoice=DB::table('si_invoices')->where($s)->where('id',$b['invoice_id'])->first();abort_unless($doc&&$invoice,409,'Origem QA emitida não encontrada.');
|
||||
$draft=DB::table('a_billing_drafts')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'id'=>$invoice->draft_id]);ModuleRecords::constrain($draft,'a_billing_drafts','billing_drafts','commercial');abort_unless($draft->exists(),403,'Origem sem associação Comercial actual.');
|
||||
$latest=DB::table('sd_versions')->where($s)->where('document_key',$doc->document_key)->orderByDesc('version')->first();$pending=DB::table('sd_cancellations')->where($s)->where('output_id',$doc->id)->exists();
|
||||
$allocation=DB::table('sr_effects')->where(['id'=>$doc->effect_id,'receipt_id'=>$b['receipt_id'],'kind'=>'allocate'])->first();$receipt=DB::table('sr_records')->where($s)->where(['id'=>$b['receipt_id'],'invoice_id'=>$invoice->id,'kind'=>'receipt'])->first();$confirmation=$receipt?DB::table('sr_effects')->where(['record_id'=>$receipt->id,'kind'=>'receipt_confirm'])->first():null;
|
||||
abort_unless($latest&&$latest->state==='issued'&&$latest->id===$doc->version_id&&!$pending&&$allocation&&$receipt&&$confirmation&&$receipt->actor_id!==$confirmation->actor_id&&!DB::table('sr_effects')->where('target_effect_id',$allocation->id)->exists()&&!DB::table('sr_effects')->where(['receipt_id'=>$receipt->id,'kind'=>'reverse_receipt'])->exists(),409,'Documento/recebimento alterado, revertido ou em correcção. Exige nova conferência.');
|
||||
foreach([$doc,$invoice]as$file){$bytes=base64_decode($file->pdf_base64,true);abort_unless(is_string($bytes)&&hash_equals($file->pdf_sha256,hash('sha256',$bytes)),409,'Documento congelado incompatível.');}
|
||||
$snap=json_decode($invoice->snapshot,true,64,JSON_THROW_ON_ERROR);$cal=$snap['calculation']??[];
|
||||
abort_unless(($snap['simulation_only']??false)===true&&($cal['policy']??null)===ServiceCalculation::POLICY&&($cal['currency']??null)==='MZN'&&($snap['assessment_period']??null)===$b['period']&&ServiceReceivables::money($cal['base_exact']??'0')==='862.07'&&($cal['vat_document_proposed']??null)==='137.93'&&($cal['total_document_proposed']??null)==='1000.00'&&ServiceReceivables::money($allocation->amount)==='1000.00',409,'Montantes, política ou período não correspondem ao caso QA autorizado.');
|
||||
$summary=['output_id'=>$doc->id,'kind'=>'invoice_receipt','document_reference'=>'FR-QA-'.$doc->number,'technical_invoice_id'=>$invoice->id,'document_sha256'=>$doc->pdf_sha256,'currency'=>'MZN','base'=>'862.07','vat'=>'137.93','total'=>'1000.00','vat_exact'=>$cal['vat_exact'],'representation_difference'=>$cal['discarded_difference'],'policy'=>$cal['policy'],'profile_identifier'=>$snap['profile_identifier'],'profile_version'=>$snap['profile_version'],'exigibility_date'=>$snap['exigibility_date'],'period'=>$snap['assessment_period'],'synthetic_only'=>true];
|
||||
$state=['invoice_snapshot'=>$snap,'document_snapshot'=>json_decode($doc->snapshot,true),'latest'=>(array)$latest,'allocation'=>(array)$allocation,'receipt'=>(array)$receipt,'confirmation'=>(array)$confirmation];return ['summary'=>$summary,'hash'=>$this->hash($state)];
|
||||
}
|
||||
private function basis(array $s,array $b,string $kind):array
|
||||
{
|
||||
$source=$this->source($s,$b);$period=DocumentPeriod::open($s);$assessment=$this->latest($s,$b,'assessment');$old=$kind==='assessment'?$assessment:$this->latest($s,$b,'form');
|
||||
return ['source'=>$source,'period'=>$period,'binding'=>$this->hash($b),'old'=>$old,'assessment'=>$assessment];
|
||||
}
|
||||
private function dependencies(array $basis,string $kind):array{return ['source'=>$basis['source']['hash'],'period'=>$this->hash($basis['period']),'binding'=>$basis['binding'],'assessment_id'=>$kind==='form'?($basis['assessment']['id']??null):null];}
|
||||
private function lock(int $actor,array $s):void
|
||||
{
|
||||
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->firstOrFail();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
private function exact(array $data,array $rules):array{abort_if(array_diff(array_keys($data),array_keys($rules)),422,'Campo fora do âmbito.');return Validator::make($data,$rules)->validate();}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
abort_if(strlen(json_encode($input))>16384,422);$d=$this->exact($input,['kind'=>'required|in:assessment,form','action'=>'required|in:prepare,review','version'=>'required|integer|min:0','data'=>'present|array','reason'=>'required|string|min:10|max:4000']);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$s=$this->scope($actor,$d['kind'],$d['action']);$b=$this->binding();
|
||||
return DB::transaction(function()use($actor,$s,$d,$b,$key){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);$basis=$this->basis($s,$b,$d['kind']);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$b,$d,$basis);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['input'=>$d,'key'=>$key,'actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'observed'=>time(),'basis'=>$this->hash($basis),'impact'=>$out['impact']];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
|
||||
}
|
||||
public function confirm(int $actor,string $preview):array
|
||||
{
|
||||
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$c['input'];$s=$this->scope($actor,$d['kind'],$d['action']);$b=$this->binding();
|
||||
return DB::transaction(function()use($actor,$s,$d,$c,$b){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);abort_unless($c['actor']===$actor&&$c['scope']==$s&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto ou prévia expirados.');$old=DB::table('fx_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409);return json_decode($old->result,true)+['replayed'=>true,'historical_receipt_only'=>true];}$basis=$this->basis($s,$b,$d['kind']);abort_unless($this->hash($basis)===$c['basis'],409,'Origem, versão ou dependências mudaram. Confira novamente.');$out=$this->execute($actor,$s,$b,$d,$basis);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');DB::table('fx_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('fiscal_qa.'.$d['kind'].'.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version'],'before_revision'=>$d['version'],'reason'=>$d['reason'],'review_hash'=>$basis['source']['hash']]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function validAssessment(array $basis):bool{$a=$basis['assessment'];return $a&&$a['state']==='reviewed'&&$this->hash($a['dependencies'])===$this->hash($this->dependencies($basis,'assessment'));}
|
||||
private function execute(int $actor,array $s,array $b,array $d,array $basis):array
|
||||
{
|
||||
$kind=$d['kind'];$old=$basis['old'];abort_unless(($old['version']??0)===$d['version'],409,'Versão alterada.');$dependencies=$this->dependencies($basis,$kind);$prepared=$actor;
|
||||
if($kind==='form')abort_unless($this->validAssessment($basis),409,'O livro exige revisão actual antes de preparar/rever o mapa.');
|
||||
if($d['action']==='prepare'){
|
||||
$data=$this->exact($d['data'],['source_output_id'=>'required|integer|min:1','period'=>'required|date_format:Y-m','classification'=>'required|in:qa_output_only','evidence'=>'required|string|min:10|max:2000']);abort_unless($data['source_output_id']===$b['source_output_id']&&$data['period']===$b['period'],403,'Escolha individual da origem e período QA autorizados.');$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.view');
|
||||
$data+=['source'=>$basis['source']['summary'],'deductible_vat'=>null,'tax_payable'=>null,'complete_company_assessment'=>false,'pending'=>['Aquisições/deduções e outras origens não classificadas; não são zero.','Mapa interno QA — não é declaração oficial nem apuramento integral da empresa.']];
|
||||
if($kind==='form'){$data['assessment_id']=$basis['assessment']['id'];$data['artifact_csv']=$this->csv($data);$data['artifact_sha256']=hash('sha256',$data['artifact_csv']);}$state='prepared';
|
||||
}else{
|
||||
$review=$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']);abort_unless($old&&$old['state']==='prepared'&&$old['prepared_by']!==$actor,403,'Preparação actual e revisor distinto obrigatórios.');$prepared=$old['prepared_by'];DB::table('users')->where('id',$prepared)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),403);$grant=DB::table('permission_grants')->where($s+['user_id'=>$prepared,'permission'=>$this->prefix($kind).'prepare'])->lockForUpdate()->first();abort_unless($grant,403,'Autor da preparação já não autorizado.');abort_unless($this->hash($old['dependencies'])===$this->hash($dependencies),409,'A origem/revisão mudou desde a preparação.');$data=$old['data'];$state=$review['decision'];
|
||||
}
|
||||
$version=($old['version']??0)+1;$id=DB::table('fx_versions')->insertGetId($s+['process_key'=>$b['process_key'],'kind'=>$kind,'version'=>$version,'previous_id'=>$old['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'data'=>$this->json($data),'dependencies'=>$this->json($dependencies),'reason'=>$d['reason'],'created_at'=>now()]);
|
||||
return ['id'=>$id,'kind'=>$kind,'version'=>$version,'state'=>$state,'impact'=>['before'=>['version'=>$old['version']??0,'state'=>$old['state']??null],'after'=>['version'=>$version,'state'=>$state],'source'=>$basis['source']['summary'],'deductible_vat'=>null,'tax_payable'=>null,'company_assessment_complete'=>false,'new_journals'=>0,'official_submission'=>false,'artifact_sha256'=>$data['artifact_sha256']??null]];
|
||||
}
|
||||
private function csv(array $data):string
|
||||
{
|
||||
$s=$data['source'];$rows=[['MAPA INTERNO IVA QA — SEM VALIDADE FISCAL'],['Ambito','Apenas a origem seleccionada; apuramento da empresa incompleto'],['Periodo','Documento','Base MZN','IVA documental MZN','Total MZN','Regra','Versao perfil','SHA documento'],[$s['period'],$s['document_reference'],$s['base'],$s['vat'],$s['total'],$s['policy'],(string)$s['profile_version'],$s['document_sha256']],['IVA dedutivel','INDETERMINADO'],['Imposto a pagar','INDETERMINADO'],['Origem do livro revisto',(string)$data['assessment_id']]];$fp=fopen('php://temp','w+');foreach($rows as$row)fputcsv($fp,$row,';', '"','',"\r\n");rewind($fp);return stream_get_contents($fp);
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=$this->exact($input,['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','kind'=>'sometimes|in:assessment,form']);if(($d['kind']??null)==='form')$this->scope($actor,'form');$b=$this->binding();
|
||||
return DB::transaction(function()use($actor,$s,$d,$b){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$blocked=null;$basis=null;try{$basis=$this->basis($s,$b,'assessment');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==409)throw $e;$blocked=$e->getMessage();}$a=$this->latest($s,$b,'assessment');$canForm=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.view',$s['unit_id']);$form=$canForm?$this->latest($s,$b,'form'):null;$q=DB::table('fx_versions')->where($s)->where('process_key',$b['process_key']);if(!$canForm)$q->where('kind','assessment');if(isset($d['kind']))$q->where('kind',$d['kind']);$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$rows=$page->getCollection()->map(fn($r)=>$this->row($r))->all();$refs=DB::table('fx_commands')->where($s)->where('actor_id',$actor)->whereIn(DB::raw("(result->>'id')::bigint"),array_column($rows,'id'))->get();foreach($rows as&$row){$row['command_reference']=null;foreach($refs as$ref)if(json_decode($ref->result,true)['id']===$row['id'])$row['command_reference']=$ref->command_key;unset($row['data']['artifact_csv']);}$page->setCollection(collect($rows));
|
||||
$aCurrent=$basis&&$this->validAssessment($basis);$formCurrent=$basis&&$aCurrent&&$form&&$form['state']==='reviewed'&&$this->hash($form['dependencies'])===$this->hash($this->dependencies($basis,'form'));if($form)unset($form['data']['artifact_csv']);return ['synthetic_only'=>true,'contract'=>self::CONTRACT,'blocked'=>$blocked,'source'=>$basis['source']['summary']??null,'period'=>$b['period'],'assessment'=>$a,'form'=>$form,'assessment_current_review'=>$aCurrent,'form_current_review'=>$formCurrent,'events'=>$page->toArray(),'capabilities'=>['assessment_prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_assessments.prepare',$s['unit_id']),'assessment_review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_assessments.review',$s['unit_id']),'form_prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.prepare',$s['unit_id']),'form_review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.review',$s['unit_id'])],'official_submission'=>false,'complete_company_assessment'=>false];});
|
||||
}
|
||||
public function receipt(int $actor,string $key):array
|
||||
{
|
||||
$s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);$r=DB::table('fx_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);$result=json_decode($r->result,true);$this->scope($actor,$result['kind']);return ['result'=>$result,'command_key'=>$key,'created_at'=>$r->created_at,'historical_receipt_only'=>true,'operation_executed'=>false];
|
||||
}
|
||||
public function export(int $actor,int $id):array
|
||||
{
|
||||
$s=$this->scope($actor,'form');$b=$this->binding();return DB::transaction(function()use($actor,$s,$b,$id){$this->lock($actor,$s);$basis=$this->basis($s,$b,'form');$f=$basis['old'];abort_unless($f&&$f['id']===$id&&$f['state']==='reviewed'&&$this->validAssessment($basis)&&$this->hash($f['dependencies'])===$this->hash($this->dependencies($basis,'form')),409,'Mapa ainda não revisto ou desactualizado.');$csv=$f['data']['artifact_csv'];abort_unless(hash_equals($f['data']['artifact_sha256'],hash('sha256',$csv)),409);Audit::record('fiscal_qa.form.downloaded',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$f['version'],'review_hash'=>$f['data']['artifact_sha256']]);return ['bytes'=>$csv,'sha256'=>$f['data']['artifact_sha256'],'version'=>$f['version']];});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
/** Product capability metadata only: not tax rates, legal rules or activation authority. */
|
||||
final class FiscalCoverage
|
||||
{
|
||||
public static function describe(): array
|
||||
{
|
||||
return [
|
||||
'version'=>'SGF-FISCAL-COVERAGE-v4',
|
||||
'automatic_knowledge_import'=>false,
|
||||
'legal_validation'=>false,
|
||||
'notice'=>'Conhecimento em documentos ou conversas não passa automaticamente para a aplicação. Cada regra exige fonte, versão, âmbito, provas e revisão antes de utilização.',
|
||||
'taxes'=>[
|
||||
['id'=>'iva','label'=>'IVA','state'=>'partial','available'=>'Cálculo delimitado de serviço, MZN, uma linha e imposto acrescido; perfis versionados, revisão distinta e decisão temporal. Documentos QA e diário integrado; livro/mapa interno da origem seleccionada revistos distintamente. Núcleo comum com versões/vigência e cálculo interno QA de1–25linhas, preço acrescido/incluído, exactos e diferenças preservados.','pending'=>'Emissão documental por múltiplas linhas/tratamentos, livros gerais de apuramento/dedução, regularizações e modelos oficiais.','legal_use'=>false],
|
||||
['id'=>'irps','label'=>'IRPS','state'=>'partial','available'=>'RH autónomo com cadastro, contratos/tempos/férias e processamento interno QA revisto; retenção artificial explicada e mapa conservado.','pending'=>'Cálculo legal de IRPS por regime/rendimento, retenções/apuramento e modelos oficiais; o ensaio interno não valida o imposto.','legal_use'=>false],
|
||||
['id'=>'irpc','label'=>'IRPC','state'=>'pending','available'=>null,'pending'=>'Ponte do resultado contabilístico para ajustamentos/base/colecta, retenções e modelos no SGF.','legal_use'=>false],
|
||||
['id'=>'ispc','label'=>'ISPC','state'=>'pending','available'=>null,'pending'=>'Enquadramento, cálculo, obrigações e modelos operacionais no SGF.','legal_use'=>false],
|
||||
['id'=>'ice','label'=>'ICE','state'=>'pending','available'=>null,'pending'=>'Classificação dos bens, parâmetros com vigência, liquidação e obrigações operacionais no SGF.','legal_use'=>false],
|
||||
['id'=>'inss','label'=>'INSS','state'=>'partial','available'=>'Cadastro e processamento interno QA em RH, com desconto do trabalhador e encargo patronal artificiais separados e mapa revisto.','pending'=>'Regimes/bases/contribuições legais INSS, reconciliação e modelos oficiais; percentagens do exercício não são parâmetros legais activados.','legal_use'=>false],
|
||||
['id'=>'other','label'=>'Outros impostos e obrigações','state'=>'pending','available'=>null,'pending'=>'Identificação dos tributos aplicáveis, calendário legal, multas/juros e modelos; sem taxas ou prazos presumidos.','legal_use'=>false],
|
||||
],
|
||||
'management'=>[
|
||||
['id'=>'human_resources','label'=>'Recursos Humanos','state'=>'partial','available'=>'Módulo autónomo e perfis próprios: cadastro, contratos internos, plano/assiduidade, condições/pedidos/gozo de férias, conferência de período e remunerações internas QA; histórico e resumos legíveis.','pending'=>'Aplicabilidade laboral real, calendário/turnos gerais, benefícios, cálculo legal IRPS/INSS e efeitos salariais financeiros/contabilísticos.'],
|
||||
['id'=>'finance','label'=>'Gestão financeira','state'=>'partial','available'=>'Percursos QA de orçamento de Compras, obrigações/recebíveis, afectações/reversões e Caixa com fecho; conferência individual de extracto artificial contra duas linhas existentes.','pending'=>'Operação geral real, bancos e reconciliação bancária geral.'],
|
||||
['id'=>'accounting','label'=>'Contabilidade','state'=>'partial','available'=>'Diário, plano, razão, balancete e conferência/fecho QA; demonstrações internas parciais revistas/conservadas, com contas não classificadas explícitas. Cadastro/custódia, plano mensal, quota e diário de activos QA integrados, com correcção e abate no recorte sintético.','pending'=>'Operação geral de activos e tratamento fiscal de depreciações, demonstrações financeiras legais, encerramento anual real e integração dos apuramentos fiscais.'],
|
||||
['id'=>'analysis','label'=>'Análise financeira','state'=>'partial','available'=>'Custos directos/industriais, orçamento versus realizado, diário/saldos e dois critérios/indicadores QA revistos com fontes explícitas e relatórios.','pending'=>'Rácios de liquidez/rentabilidade/endividamento e tendências conciliadas, com plano de contas e critérios aprovados.'],
|
||||
],
|
||||
'rule_lifecycle'=>['Fonte identificada e versão preservada','Aplicabilidade e vigência explícitas','Cálculo exacto com pressupostos/pendências','Provas e revisão por pessoa distinta','Activação específica e histórico sem reescrever operações anteriores'],
|
||||
'official_submission_enabled'=>false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
use Brick\Math\{BigRational,RoundingMode};
|
||||
/** Approved QA representation policy. No classification, issuance or financial effects. */
|
||||
final class FiscalDocumentMath
|
||||
{
|
||||
public const POLICY='SGF-QA-LINE-TRUNCATE2-INCLUDED-RESIDUAL-v1';
|
||||
public static function calculate(array $lines,string $mode,string $rate):array
|
||||
{
|
||||
if(!in_array($mode,['net_added','gross_included'],true)||BigRational::of($rate)->isLessThanOrEqualTo(0)||BigRational::of($rate)->isGreaterThanOrEqualTo(1))throw new \InvalidArgumentException('Modalidade/taxa artificial inválida.');$baseSum=BigRational::of(0);$taxSum=BigRational::of(0);$totalSum=BigRational::of(0);$exactBaseSum=BigRational::of(0);$exactTaxSum=BigRational::of(0);$rows=[];$pending=[];$r=BigRational::of($rate);
|
||||
foreach($lines as$i=>$line){$amount=BigRational::of($line['quantity'])->multipliedBy($line['unit_price']);$base=$mode==='net_added'?$amount:$amount->dividedBy(BigRational::of(1)->plus($r));$tax=$mode==='net_added'?$base->multipliedBy($r):$amount->minus($base);$taxDoc=$tax->toScale(2,RoundingMode::Down);$taxDocR=BigRational::of((string)$taxDoc);$baseDoc=null;$total=null;
|
||||
try{if($mode==='net_added'){$baseDoc=$base->toScale(2,RoundingMode::Unnecessary);$total=$base->plus($taxDocR)->toScale(2,RoundingMode::Unnecessary);}else{$gross=$amount->toScale(2,RoundingMode::Unnecessary);$baseDoc=BigRational::of((string)$gross)->minus($taxDocR)->toScale(2);$total=$gross;}}catch(\Brick\Math\Exception\RoundingNecessaryException){$pending[]='Linha '.($i+1).': '.($mode==='net_added'?'base':'bruto').' não representável em duas casas; não truncado.';}
|
||||
$rows[]=['line'=>($i+1),'reference'=>$line['reference'],'quantity'=>$line['quantity'],'unit_price'=>$line['unit_price'],'amount_exact_fraction'=>(string)$amount->simplified(),'base_exact_fraction'=>(string)$base->simplified(),'tax_exact_fraction'=>(string)$tax->simplified(),'base_document'=>$baseDoc===null?null:(string)$baseDoc,'tax_document'=>(string)$taxDoc,'total_document'=>$total===null?null:(string)$total,'base_difference_fraction'=>$baseDoc===null?null:(string)BigRational::of((string)$baseDoc)->minus($base)->simplified(),'tax_difference_fraction'=>(string)$tax->minus($taxDocR)->simplified()];
|
||||
$exactBaseSum=$exactBaseSum->plus($base);$exactTaxSum=$exactTaxSum->plus($tax);if($baseDoc!==null){$baseSum=$baseSum->plus(BigRational::of((string)$baseDoc));$taxSum=$taxSum->plus($taxDocR);$totalSum=$totalSum->plus(BigRational::of((string)$total));}
|
||||
}
|
||||
return ['state'=>$pending?'pending_representation':'determined_qa','policy'=>self::POLICY,'price_mode'=>$mode,'rate'=>$rate,'lines'=>$rows,'base_exact_fraction'=>(string)$exactBaseSum->simplified(),'tax_exact_fraction'=>(string)$exactTaxSum->simplified(),'base_document'=>$pending?null:(string)$baseSum->toScale(2),'tax_document'=>$pending?null:(string)$taxSum->toScale(2),'total'=>$pending?null:(string)$totalSum->toScale(2),'base_difference_fraction'=>$pending?null:(string)$baseSum->minus($exactBaseSum)->simplified(),'tax_difference_fraction'=>$pending?null:(string)$exactTaxSum->minus($taxSum)->simplified(),'aggregation'=>'sum_document_lines','pending'=>$pending,'synthetic_only'=>true,'legal_use'=>false,'financial_effects'=>0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\{Audit,Permissions};
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\{Crypt,DB,Validator};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Administrative QA deadlines/evidence only. No legal-calendar or financial writer. */
|
||||
final class FiscalObligations
|
||||
{
|
||||
public const POLICY='SGF-FISCAL-OBLIGATIONS-QA-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions,private FiscalAssessment $forms){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}
|
||||
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
|
||||
public function scope(int $actor,string $action='view'):array
|
||||
{
|
||||
abort_unless(config('fiscal_obligations.enabled')===true,404);CompositionRuntime::assert();$p=CompositionRuntime::physical();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='accounting',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach(array_unique(['view',$action])as$right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.'.$right,$s['unit_id']);
|
||||
if($action!=='view')abort_unless($actor===($action==='prepare'?13:14),403);$meta=DB::table('fo_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$p['composition']&&hash_equals($meta->source_hash,(string)config('fiscal_obligations.schema_sha256')),503,'Schema de obrigações não conferido.');return$s;
|
||||
}
|
||||
private function identity(int $actor,array $s):string
|
||||
{
|
||||
$u=DB::table('users')->where('id',$actor)->first(['id','email','name']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);
|
||||
}
|
||||
private function responsible(int $actor,array $s):void
|
||||
{
|
||||
abort_unless(in_array($actor,[13,14,15,16,17],true),403,'Responsável fora dos perfis QA aprovados.');$this->identity($actor,$s);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.view',$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.view',$s['unit_id']);
|
||||
}
|
||||
private function lock(int $actor,array $s):void
|
||||
{
|
||||
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
private function source(int $actor,array $s):array
|
||||
{
|
||||
$state=$this->forms->listing($actor,['kind'=>'form','per_page'=>25]);abort_unless($state['form_current_review']===true&&$state['form'],409,'Mapa interno revisto deixou de ser actual.');$form=$state['form'];$row=DB::table('fx_versions')->where($s+['id'=>$form['id'],'kind'=>'form','state'=>'reviewed'])->first();abort_unless($row,409);$data=json_decode($row->data,true,64,JSON_THROW_ON_ERROR);$bytes=$data['artifact_csv']??null;$sha=$data['artifact_sha256']??null;abort_unless(is_string($bytes)&&is_string($sha)&&hash_equals($sha,hash('sha256',$bytes)),409,'Exportação interna alterada.');
|
||||
return ['form_id'=>$row->id,'form_version'=>$row->version,'period'=>$state['period'],'export_sha256'=>$sha,'document_reference'=>$state['source']['document_reference'],'document_sha256'=>$state['source']['document_sha256'],'dependencies'=>json_decode($row->dependencies,true),'source_basis'=>$this->hash(['row'=>(array)$row,'source'=>$state['source'],'binding'=>$this->forms->binding()]),'synthetic_only'=>true,'tax_payable'=>null,'official_submission'=>false];
|
||||
}
|
||||
private function rows(array $s){return DB::table('fo_versions')->where($s);}
|
||||
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$key)$a[$key]=json_decode($a[$key],true,64,JSON_THROW_ON_ERROR);return$a;}
|
||||
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
|
||||
private function basis(int $actor,array $s,string $key):array{return ['source'=>$this->source($actor,$s),'last'=>$this->latest($s,$key),'effective'=>$this->latest($s,$key,true),'actor_identity'=>$this->identity($actor,$s)];}
|
||||
private function input(array $v):array
|
||||
{
|
||||
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['record_key']=strtolower($d['record_key']);$d['version']=(int)$d['version'];
|
||||
if($d['action']==='prepare'){$d['data']=$this->exact($d['data'],['operation'=>'required|in:establish,amend,cancel,delivery','form_id'=>'required|integer|min:1','period'=>'required|date_format:Y-m','responsible_id'=>'required|integer|min:1','due_on'=>'present|nullable|date_format:Y-m-d|after_or_equal:2000-01-01|before_or_equal:2100-12-31','evidence'=>'required|string|min:10|max:2000','export_sha256'=>'present|nullable|regex:/^[a-f0-9]{64}$/D']);foreach(['form_id','responsible_id']as$field)$d['data'][$field]=(int)$d['data'][$field];}else$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;
|
||||
}
|
||||
private function duplicate(array $s,string $key,int $form):bool
|
||||
{
|
||||
// Effective approved record, or a first unresolved preparation; no universe in PHP.
|
||||
$q=$this->rows($s)->where('record_key','<>',$key)->whereRaw("(data->>'form_id')::bigint=?",[$form]);
|
||||
return$q->where(function($q){$q->where(function($q){$q->where('state','approved')->whereRaw("data->>'administrative_status'<>'cancelled'")->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as newer')->whereColumn('newer.record_key','fo_versions.record_key')->whereColumn('newer.tenant_id','fo_versions.tenant_id')->whereColumn('newer.company_id','fo_versions.company_id')->whereColumn('newer.unit_id','fo_versions.unit_id')->where('newer.state','approved')->whereColumn('newer.version','>','fo_versions.version');});})->orWhere(function($q){$q->where('state','prepared')->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as anyapproved')->whereColumn('anyapproved.record_key','fo_versions.record_key')->whereColumn('anyapproved.tenant_id','fo_versions.tenant_id')->whereColumn('anyapproved.company_id','fo_versions.company_id')->whereColumn('anyapproved.unit_id','fo_versions.unit_id')->where('anyapproved.state','approved');})->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as next')->whereColumn('next.record_key','fo_versions.record_key')->whereColumn('next.tenant_id','fo_versions.tenant_id')->whereColumn('next.company_id','fo_versions.company_id')->whereColumn('next.unit_id','fo_versions.unit_id')->whereColumn('next.version','>','fo_versions.version');});});})->exists();
|
||||
}
|
||||
private function execute(int $actor,array $s,array $d,array $b):array
|
||||
{
|
||||
$last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version'],409,'Versão alterada; confira novamente.');$source=$b['source'];
|
||||
if($d['action']==='prepare'){$data=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}
|
||||
else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'fiscal_obligations.prepare',$s['unit_id']);abort_unless(hash_equals($last['source_basis'],$source['source_basis']),409,'Origem mudou desde a preparação.');$data=$last['data'];}
|
||||
abort_unless($data['form_id']===$source['form_id']&&$data['period']===$source['period'],409,'Escolha o mapa/período revistos actuais.');$this->responsible($data['responsible_id'],$s);$op=$data['operation'];$old=$effective['data']??null;
|
||||
if($op==='establish'){abort_unless(!$effective,409,'Já existe condição aprovada neste registo.');abort_if($this->duplicate($s,$d['record_key'],$data['form_id']),409,'Já existe obrigação ou preparação deste mapa. Não duplicar.');}
|
||||
else{abort_unless($old&&$old['administrative_status']!=='cancelled'&&$old['form_id']===$data['form_id']&&$old['period']===$data['period'],409,'Operação exige obrigação activa da mesma origem.');if(in_array($op,['cancel','delivery'],true))foreach(['due_on','responsible_id']as$field)abort_unless($data[$field]===$old[$field],409,'Esta operação conserva as condições aprovadas.');if($op==='delivery')abort_unless($old['administrative_status']!=='delivery_simulated',409,'Entrega simulada já registada.');}
|
||||
if($op==='delivery')abort_unless(is_string($data['export_sha256'])&&hash_equals($source['export_sha256'],$data['export_sha256']),409,'SHA da exportação interna não corresponde.');else abort_unless($data['export_sha256']===null,422,'SHA de entrega só na declaração simulada.');
|
||||
$data['administrative_status']=$op==='cancel'?'cancelled':($op==='delivery'?'delivery_simulated':($old['administrative_status']??'active'));$data['delivery_evidence']=$op==='delivery'?['export_sha256'=>$data['export_sha256'],'reference'=>$data['evidence'],'simulated_only'=>true]:($old['delivery_evidence']??null);$data['source']=$source;
|
||||
$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=($last['version']??0)+1;$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'tax_payable'=>null,'new_debt'=>false,'payments'=>0,'journals'=>0,'official_submission'=>false];
|
||||
$id=DB::table('fo_versions')->insertGetId($s+['record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'preparer_identity'=>$identity,'data'=>$this->json($data),'source_basis'=>$source['source_basis'],'impact'=>$this->json($impact),'reason'=>$d['reason'],'created_at'=>now()->utc()]);return['id'=>$id,'record_key'=>$d['record_key'],'version'=>$version,'state'=>$state,'impact'=>$impact];
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['action']);$b=$this->basis($actor,$s,$d['record_key']);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($actor,$s,$d,$b);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
|
||||
}
|
||||
public function confirm(int $actor,string $preview):array
|
||||
{
|
||||
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto/prévia alterados.');$b=$this->basis($actor,$s,$d['record_key']);$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('fo_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem, pessoa ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');DB::table('fo_commands')->insert($q+['request_hash'=>$this->hash($d),'result'=>$this->json($out),'input'=>$this->json($d),'created_at'=>now()->utc()]);Audit::record('fiscal_obligations.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'record_key'=>$d['record_key'],'version'=>$out['version'],'reason'=>$d['reason'],'impact'=>$out['impact']]);return$out+['replayed'=>false];});
|
||||
}
|
||||
public function receipt(int $actor,string $key):array
|
||||
{
|
||||
$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$this->forms->scope($actor,'form');$old=DB::table('fo_commands')->where($s+['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$this->forms->scope($actor,'form');$d=$this->exact($input,['reference_on'=>'required|date_format:Y-m-d','period'=>'sometimes|date_format:Y-m','status'=>'sometimes|in:active,delivery_simulated,cancelled,unapproved','record_key'=>'sometimes|uuid','due_from'=>'required_with:due_to|date_format:Y-m-d','due_to'=>'required_with:due_from|date_format:Y-m-d|after_or_equal:due_from','due_state'=>'sometimes|in:unknown,overdue,not_overdue','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
|
||||
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$source=null;$blocked=null;try{$source=$this->source($actor,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){if($e->getStatusCode()!==409)throw$e;$blocked=$e->getMessage();}$q=$this->rows($s);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403,'Histórico fora do âmbito.');}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$key)$n->whereColumn('next.'.$key,'fo_versions.'.$key);$n->whereColumn('next.version','>','fo_versions.version');});
|
||||
$effective="(SELECT e.data->>'administrative_status' FROM fo_versions e WHERE e.tenant_id=fo_versions.tenant_id AND e.company_id=fo_versions.company_id AND e.unit_id=fo_versions.unit_id AND e.record_key=fo_versions.record_key AND e.state='approved' ORDER BY e.version DESC LIMIT 1)";$effectiveDue="(SELECT e.data->>'due_on' FROM fo_versions e WHERE e.tenant_id=fo_versions.tenant_id AND e.company_id=fo_versions.company_id AND e.unit_id=fo_versions.unit_id AND e.record_key=fo_versions.record_key AND e.state='approved' ORDER BY e.version DESC LIMIT 1)";if(isset($d['due_from']))$q->whereRaw("($effectiveDue)>=? AND ($effectiveDue)<=?",[$d['due_from'],$d['due_to']]);if(isset($d['due_state'])){if($d['due_state']==='unknown')$q->whereRaw("($effectiveDue) IS NULL");else$q->whereRaw("($effectiveDue)".($d['due_state']==='overdue'?'<':'>=').'?',[$d['reference_on']]);}if(isset($d['status']))$q->whereRaw("coalesce($effective,'unapproved')=?",[$d['status']]);if(isset($d['period']))$q->whereRaw("data->>'period'=?",[$d['period']]);$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$approved=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as later');foreach(['tenant_id','company_id','unit_id','record_key']as$key)$n->whereColumn('later.'.$key,'fo_versions.'.$key);$n->where('later.state','approved')->whereColumn('later.version','>','fo_versions.version');})->get()->keyBy('record_key');
|
||||
$items=[];foreach($page->items()as$r){$row=$this->row($r);$a=$approved->get($r->record_key);$terms=$a?json_decode($a->data,true):null;$due=$terms['due_on']??null;$days=$due===null?null:max(0,(int)(new \DateTimeImmutable($due))->diff(new \DateTimeImmutable($d['reference_on']))->format('%r%a'));$row['effective']=$terms;$row['days_overdue']=$days;$row['due_status']=$due===null?'unknown':($due<$d['reference_on']?'overdue':'not_overdue');$row['current_source_matches']=$source!==null&&hash_equals($r->source_basis,$source['source_basis']);$items[]=$row;}$page->setCollection(collect($items));$can=fn($right,$expected)=>$actor===$expected&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.'.$right,$s['unit_id']);return['policy'=>self::POLICY,'synthetic_only'=>true,'reference_on'=>$d['reference_on'],'source'=>$source,'blocked'=>$blocked,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$can('prepare',13),'review'=>$can('review',14)],'official_submission'=>false,'tax_payable'=>null,'notice'=>'Prazo administrativo artificial; entrega apenas simulada, sem transmissão à AT ou pagamento.'];});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\RoundingMode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** A deliberately small, data-only expression language for reviewed tax-rule drafts. */
|
||||
final class FiscalRuleExpression
|
||||
{
|
||||
private const LANGUAGE = 'investmoz.tax.v1';
|
||||
|
||||
private const ROUNDING = [
|
||||
'UNNECESSARY' => RoundingMode::Unnecessary,
|
||||
'UP' => RoundingMode::Up,
|
||||
'DOWN' => RoundingMode::Down,
|
||||
'CEILING' => RoundingMode::Ceiling,
|
||||
'FLOOR' => RoundingMode::Floor,
|
||||
'HALF_UP' => RoundingMode::HalfUp,
|
||||
'HALF_DOWN' => RoundingMode::HalfDown,
|
||||
'HALF_CEILING' => RoundingMode::HalfCeiling,
|
||||
'HALF_FLOOR' => RoundingMode::HalfFloor,
|
||||
'HALF_EVEN' => RoundingMode::HalfEven,
|
||||
];
|
||||
|
||||
/** @param array<string,mixed> $definition */
|
||||
public function validate(array $definition): void
|
||||
{
|
||||
$this->topologicalOrder($definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a validated expression. Missing values are reported as indeterminate;
|
||||
* callers must supply versioned parameters and provenance explicitly.
|
||||
*
|
||||
* @param array<string,mixed> $definition
|
||||
* @param array<string,string|int> $inputs
|
||||
* @param array<string,string|int> $parameters
|
||||
* @return array{status:string,result:?string,missing:list<string>,steps:list<array<string,mixed>>}
|
||||
*/
|
||||
public function evaluate(array $definition, array $inputs, array $parameters): array
|
||||
{
|
||||
$order = $this->topologicalOrder($definition);
|
||||
$nodes = $definition['nodes'];
|
||||
$values = [];
|
||||
$missing = [];
|
||||
$steps = [];
|
||||
|
||||
foreach ($order as $id) {
|
||||
$node = $nodes[$id];
|
||||
$op = $node['op'];
|
||||
if ($op === 'input' || $op === 'parameter') {
|
||||
$key = $node['key'];
|
||||
$source = $op === 'input' ? $inputs : $parameters;
|
||||
if (! array_key_exists($key, $source)) {
|
||||
$missing[] = $op.':'.$key;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'key' => $key, 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->number($source[$key]);
|
||||
} elseif ($op === 'literal') {
|
||||
$value = $this->number($node['value']);
|
||||
} else {
|
||||
$args = array_values(array_map(static fn (string $ref): ?BigRational => $values[$ref] ?? null, $node['args'] ?? []));
|
||||
if (in_array(null, $args, true)) {
|
||||
$values[$id] = null;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'args' => $node['args'] ?? [], 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->operate($op, $args, $node);
|
||||
}
|
||||
$values[$id] = $value;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'value' => (string) $value];
|
||||
}
|
||||
|
||||
$result = $values[$definition['result']] ?? null;
|
||||
|
||||
return [
|
||||
'status' => $missing === [] && $result !== null ? 'determinate' : 'indeterminate',
|
||||
'result' => $result === null ? null : (string) $result,
|
||||
'missing' => array_values(array_unique($missing)),
|
||||
'steps' => $steps,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $definition
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topologicalOrder(array $definition): array
|
||||
{
|
||||
if (($definition['language'] ?? null) !== self::LANGUAGE || ! is_array($definition['nodes'] ?? null) || ! is_string($definition['result'] ?? null)) {
|
||||
throw new InvalidArgumentException('O formato da expressão não é suportado.');
|
||||
}
|
||||
$nodes = $definition['nodes'];
|
||||
if ($nodes === [] || count($nodes) > 100) {
|
||||
throw new InvalidArgumentException('A expressão deve conter entre 1 e 100 elementos.');
|
||||
}
|
||||
|
||||
foreach ($nodes as $id => $node) {
|
||||
if (! is_string($id) || ! preg_match('/^[a-z][a-z0-9_]{0,47}$/', $id) || ! is_array($node) || ! is_string($node['op'] ?? null)) {
|
||||
throw new InvalidArgumentException('Reveja o elemento indicado na expressão.');
|
||||
}
|
||||
$op = $node['op'];
|
||||
$allowedFields = match ($op) {
|
||||
'input', 'parameter' => ['op', 'key'],
|
||||
'literal' => ['op', 'value'],
|
||||
'add', 'subtract', 'multiply', 'divide', 'min', 'max' => ['op', 'args'],
|
||||
'round' => ['op', 'args', 'scale', 'mode'],
|
||||
default => throw new InvalidArgumentException('Esta operação não é suportada na expressão.'),
|
||||
};
|
||||
if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) {
|
||||
throw new InvalidArgumentException('Há campos em falta ou não suportados num elemento da expressão.');
|
||||
}
|
||||
if (in_array($op, ['input', 'parameter'], true) && (! is_string($node['key']) || ! preg_match('/^[a-z][a-z0-9_.-]{0,79}$/', $node['key']))) {
|
||||
throw new InvalidArgumentException('Reveja o identificador da entrada ou do parâmetro.');
|
||||
}
|
||||
if ($op === 'literal') {
|
||||
$this->number($node['value']);
|
||||
}
|
||||
if (isset($node['args'])) {
|
||||
if (! is_array($node['args'])) {
|
||||
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
|
||||
}
|
||||
$count = count($node['args']);
|
||||
$validCount = $op === 'round' ? $count === 1 : ($op === 'subtract' || $op === 'divide' ? $count === 2 : $count >= 2 && $count <= 20);
|
||||
if (! $validCount || count(array_filter($node['args'], 'is_string')) !== $count) {
|
||||
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
|
||||
}
|
||||
}
|
||||
if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) {
|
||||
throw new InvalidArgumentException('Reveja a configuração de arredondamento.');
|
||||
}
|
||||
}
|
||||
|
||||
if (! array_key_exists($definition['result'], $nodes)) {
|
||||
throw new InvalidArgumentException('O resultado da expressão refere um elemento inexistente.');
|
||||
}
|
||||
$state = [];
|
||||
$order = [];
|
||||
$visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void {
|
||||
if (($state[$id] ?? 0) === 1) {
|
||||
throw new InvalidArgumentException('A expressão contém uma dependência circular.');
|
||||
}
|
||||
if (($state[$id] ?? 0) === 2) {
|
||||
return;
|
||||
}
|
||||
$state[$id] = 1;
|
||||
foreach ($nodes[$id]['args'] ?? [] as $dependency) {
|
||||
if (! is_string($dependency) || ! array_key_exists($dependency, $nodes)) {
|
||||
throw new InvalidArgumentException('A expressão refere um elemento inexistente.');
|
||||
}
|
||||
$visit($dependency);
|
||||
}
|
||||
$state[$id] = 2;
|
||||
$order[] = $id;
|
||||
};
|
||||
foreach (array_keys($nodes) as $id) {
|
||||
$visit($id);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<BigRational> $args
|
||||
* @param array<string,mixed> $node
|
||||
*/
|
||||
private function operate(string $op, array $args, array $node): BigRational
|
||||
{
|
||||
if ($op === 'round') {
|
||||
return BigRational::of((string) $args[0]->toScale($node['scale'], self::ROUNDING[$node['mode']]));
|
||||
}
|
||||
if ($op === 'add') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->plus($value), $args[0]);
|
||||
}
|
||||
if ($op === 'subtract') {
|
||||
return $args[0]->minus($args[1]);
|
||||
}
|
||||
if ($op === 'multiply') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->multipliedBy($value), $args[0]);
|
||||
}
|
||||
if ($op === 'divide') {
|
||||
return $args[0]->dividedBy($args[1]);
|
||||
}
|
||||
if ($op === 'min' || $op === 'max') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => ($op === 'min' ? $carry->compareTo($value) <= 0 : $carry->compareTo($value) >= 0) ? $carry : $value, $args[0]);
|
||||
}
|
||||
throw new InvalidArgumentException('Esta operação não é suportada na expressão.');
|
||||
}
|
||||
|
||||
private function number(mixed $value): BigRational
|
||||
{
|
||||
if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d{1,24}(?:\.\d{1,8})?$/D', (string) $value)) {
|
||||
throw new InvalidArgumentException('Introduza valores numéricos decimais ou inteiros.');
|
||||
}
|
||||
|
||||
return BigRational::of((string) $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use App\Modules\Identity\Services\{Permissions,Audit};
|
||||
use Illuminate\Support\Facades\{DB,Crypt,Validator};
|
||||
use Brick\Math\{BigDecimal,BigRational,RoundingMode};
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Versioned data-only rules. Only pinned artificial cases may execute in this QA contract. */
|
||||
final class FiscalRules
|
||||
{
|
||||
public const CONTRACT='SGF-FISCAL-RULES-COMMON-QA-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $a):string{return Payables::canonical($a);}
|
||||
private function hash(array $a):string{return hash('sha256',$this->json($a));}
|
||||
public function scope(int $actor,string $action='view'):array
|
||||
{
|
||||
abort_unless(config('fiscal_rules_qa.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&ModuleRecords::active()==='accounting',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_rules.'.$r,$s['unit_id']);return $s;
|
||||
}
|
||||
public function fixture():array
|
||||
{
|
||||
$file=base_path('resources/data/fiscal-rule-qa.json');$pin=config('fiscal_rules_qa.source_sha256');abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503,'Fonte artificial do contrato não conferida.');$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);abort_unless($f['contract']===self::CONTRACT&&$f['not_tax_law']===true,503);$f['sha256']=$pin;return $f;
|
||||
}
|
||||
private function events(array $s){return DB::table('fr_events')->where($s);}
|
||||
private function row(object $r):array{$a=(array)$r;foreach(['definition','input','result','dependencies']as$k)if(isset($a[$k]))$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return $a;}
|
||||
private function currentVersions(array $s)
|
||||
{
|
||||
return DB::table('fr_events as f')->where(array_combine(array_map(fn($k)=>'f.'.$k,array_keys($s)),array_values($s)))->whereNotExists(function($q){$q->selectRaw('1')->from('fr_events as newer');foreach(['tenant_id','company_id','unit_id','rule_key','rule_version']as$k)$q->whereColumn('newer.'.$k,'f.'.$k);$q->whereColumn('newer.revision','>','f.revision');});
|
||||
}
|
||||
private function exact(array $data,array $rules):array{abort_if(array_diff(array_keys($data),array_keys($rules)),422,'Campo não suportado neste contrato.');return Validator::make($data,$rules)->validate();}
|
||||
public function definition(array $d):array
|
||||
{
|
||||
$d=$this->exact($d,['label'=>'required|string|min:3|max:160','family'=>'required|in:qa,iva,irps,irpc,ispc,ice,inss,other','operation'=>'required|in:percentage_add,metadata_only','conditions'=>'required|string|min:10|max:2000','currency'=>'nullable|in:MZN','unit'=>'required|in:money,none','precision'=>'required|integer|in:2','rounding'=>'required|in:DOWN','priority'=>'required|integer|min:0|max:100','source_reference'=>'required|string|min:3|max:200','source_sha256'=>'nullable|string|regex:/^[a-f0-9]{64}$/D','source_status'=>'required|in:pending,qa_checked','valid_from'=>'nullable|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from|before_or_equal:9998-12-31','parameters'=>'present|array','expression'=>'present|array']);
|
||||
$this->exact($d['parameters'],['rate'=>'nullable|string|regex:/^0\.[0-9]{1,8}$/D']);
|
||||
if($d['expression']){try{(new FiscalRuleExpression)->validate($d['expression']);}catch(\InvalidArgumentException){abort(422,'Expressão declarativa inválida; não é executado código.');}abort_if(array_diff(array_keys($d['expression']),['language','nodes','result']),422);}
|
||||
if($d['source_status']==='qa_checked')abort_unless($this->verified($d),422,'Somente os casos artificiais exactos deste contrato podem ser conferidos para execução QA. Fontes legais não são activadas.');return $d;
|
||||
}
|
||||
private function verified(array $d):bool
|
||||
{
|
||||
$f=$this->fixture();if(($d['source_sha256']??null)!==$f['sha256']||($d['family']??null)!=='qa'||($d['source_status']??null)!=='qa_checked')return false;
|
||||
foreach($f['cases']as$case){$expected=$case['definition'];$actual=$d;unset($actual['source_sha256'],$actual['label']);unset($expected['label']);if($this->hash($actual)===$this->hash($expected))return true;}return false;
|
||||
}
|
||||
private function basis(array $s,array $d):array
|
||||
{
|
||||
$q=$this->events($s);$last=isset($d['rule_key'])?(clone$q)->where('rule_key',$d['rule_key'])->orderByDesc('revision')->first():null;$target=isset($d['target_id'])?(clone$q)->where('id',$d['target_id'])->first():null;$selected=isset($d['data']['rule_event_id'])?(clone$q)->where('id',$d['data']['rule_event_id'])->first():null;
|
||||
return ['source_sha256'=>$this->fixture()['sha256'],'count'=>(int)(clone$q)->count(),'last_id'=>(int)(clone$q)->max('id'),'last'=>$last?$this->row($last):null,'target'=>$target?$this->row($target):null,'selected'=>$selected?$this->row($selected):null];
|
||||
}
|
||||
private function right(string $action):string{return in_array($action,['review','withdraw'],true)?'review':'prepare';}
|
||||
private function lock(int $actor,array $s):void
|
||||
{
|
||||
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first(),403);DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
abort_if(strlen(json_encode($input))>32768,422);$d=$this->exact($input,['action'=>'required|in:prepare,review,withdraw,calculate,calculate_document','rule_key'=>'nullable|required_unless:action,calculate,calculate_document|string|regex:/^[A-Z0-9_-]{3,80}$/D','revision'=>'required|integer|min:0','target_id'=>'nullable|required_if:action,review,withdraw|integer|min:1','data'=>'present|array','reason'=>'required|string|min:10|max:4000']);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);if($d['action']==='calculate_document')abort_unless(config('fiscal_rules_qa.document_enabled')===true,404);$d['revision']=(int)$d['revision'];if(isset($d['target_id']))$d['target_id']=(int)$d['target_id'];$s=$this->scope($actor,$this->right($d['action']));
|
||||
return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));$b=$this->basis($s,$d);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$d,$b);}finally{while(DB::transactionLevel()>$level)DB::rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'observed'=>time(),'basis'=>$this->hash($b),'impact'=>$out['impact']];return ['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
|
||||
}
|
||||
public function confirm(int $actor,string $preview):array
|
||||
{
|
||||
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$d=$c['input'];$s=$this->scope($actor,$this->right($d['action']));return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$this->right($d['action']));abort_unless($c['actor']===$actor&&$c['scope']==$s&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto ou prévia expirados.');$old=DB::table('fr_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$c['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409,'A mesma chave não aceita outra entrada.');return json_decode($old->result,true)+['replayed'=>true,'historical_receipt_only'=>true];}$b=$this->basis($s,$d);abort_unless($this->hash($b)===$c['basis'],409,'Regras, fonte ou versão mudaram; exige nova conferência.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409);DB::table('fr_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('fiscal_rule.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['revision'],'before_revision'=>$d['revision'],'reason'=>$d['reason'],'review_hash'=>$b['source_sha256']]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function execute(int $actor,array $s,array $d,array $basis):array
|
||||
{
|
||||
if(in_array($d['action'],['calculate','calculate_document'],true)){
|
||||
abort_if(!empty($d['rule_key'])||isset($d['target_id'])||$d['revision']!==0,422);$in=$d['action']==='calculate_document'?$this->documentInput($d['data']):$this->exact($d['data'],['rule_event_id'=>'nullable|integer|min:1','base'=>'required|string|regex:/^\d{1,16}(\.\d{1,8})?$/D','currency'=>'required|string|regex:/^[A-Z]{3}$/D','date'=>'required|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31']);$selected=$basis['selected'];abort_if(isset($in['rule_event_id'])&&!$selected,403,'Regra fora do contexto.');$result=$d['action']==='calculate_document'?$this->evaluateDocument($s,$in,$selected):$this->evaluate($s,$in,$selected);$deps=['source_sha256'=>$basis['source_sha256'],'rules_count'=>$basis['count'],'rules_last_id'=>$basis['last_id'],'selected_id'=>$selected['id']??null,'selected_fingerprint'=>$selected?$this->hash($selected):null];$id=DB::table('fr_calculations')->insertGetId($s+['actor_id'=>$actor,'rule_event_id'=>$selected['id']??null,'input'=>$this->json($in),'result'=>$this->json($result),'dependencies'=>$this->json($deps),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'calculation','revision'=>0,'state'=>$result['state'],'impact'=>['action'=>'calculate','result'=>$result,'new_journals'=>0,'legal_activation'=>false]];
|
||||
}
|
||||
$last=$basis['last'];abort_unless(($last['revision']??0)===$d['revision'],409,'Revisão do catálogo alterada.');$prepared=$actor;$target=null;
|
||||
if($d['action']==='prepare'){abort_if(isset($d['target_id']),422);$definition=$this->definition($d['data']);$version=(int)$this->events($s)->where('rule_key',$d['rule_key'])->max('rule_version')+1;$state='prepared';}
|
||||
else{
|
||||
$decision=$d['action']==='review'?$this->exact($d['data'],['decision'=>'required|in:reviewed,refused']):$this->exact($d['data'],[]);$target=$basis['target'];abort_unless($target&&$target['rule_key']===$d['rule_key'],403,'Escolha a versão exacta desta regra.');$current=$this->events($s)->where(['rule_key'=>$target['rule_key'],'rule_version'=>$target['rule_version']])->orderByDesc('revision')->value('id');abort_unless($current===$target['id'],409,'A versão escolhida já mudou.');abort_unless($target['prepared_by']!==$actor,403,'Preparação e revisão por pessoas distintas.');abort_unless($target['state']===($d['action']==='withdraw'?'reviewed':'prepared'),409,'Estado incompatível com a decisão.');$prepared=$target['prepared_by'];DB::table('users')->where('id',$prepared)->lockForUpdate()->firstOrFail();abort_unless(DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$prepared])->lockForUpdate()->first(),403);abort_unless(DB::table('permission_grants')->where($s+['user_id'=>$prepared,'permission'=>'fiscal_rules.prepare'])->lockForUpdate()->first(),403,'Autor da preparação sem autoridade actual.');$definition=$this->definition($target['definition']);$version=$target['rule_version'];$state=$d['action']==='withdraw'?'withdrawn':$decision['decision'];
|
||||
}
|
||||
$revision=$d['revision']+1;$id=DB::table('fr_events')->insertGetId($s+['rule_key'=>$d['rule_key'],'revision'=>$revision,'rule_version'=>$version,'previous_id'=>$last['id']??null,'target_id'=>$target['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'definition'=>$this->json($definition),'reason'=>$d['reason'],'created_at'=>now()]);return ['id'=>$id,'type'=>'rule','revision'=>$revision,'rule_version'=>$version,'state'=>$state,'impact'=>['action'=>$d['action'],'before'=>['revision'=>$d['revision'],'state'=>$last['state']??null],'after'=>['revision'=>$revision,'rule_version'=>$version,'state'=>$state],'source_checked'=>$this->verified($definition),'definition'=>$definition,'new_journals'=>0,'legal_activation'=>false]];
|
||||
}
|
||||
private function evaluate(array $s,array $in,?array $selected):array
|
||||
{
|
||||
$pending=[];$def=$selected['definition']??null;
|
||||
if(!$selected)$pending[]='Escolha explícita de regra em falta.';
|
||||
else{$current=$this->events($s)->where(['rule_key'=>$selected['rule_key'],'rule_version'=>$selected['rule_version']])->orderByDesc('revision')->first();if(!$current||$current->id!==$selected['id']||$current->state!=='reviewed')$pending[]='Regra exacta não revista, revogada ou alterada.';if(!$this->verified($def))$pending[]='Fonte/aplicabilidade não conferida para execução neste ensaio.';if(($def['currency']??null)!==$in['currency'])$pending[]='Moeda incompatível.';if(empty($def['valid_from'])||$in['date']<$def['valid_from']||(!empty($def['valid_until'])&&$in['date']>$def['valid_until']))$pending[]='Data fora de vigência ou vigência desconhecida.';
|
||||
$eligible=$this->currentVersions($s)->where('f.state','reviewed')->where('f.definition->family',$def['family'])->where('f.definition->operation',$def['operation'])->where('f.definition->conditions',$def['conditions'])->where('f.definition->currency',$in['currency'])->where('f.definition->source_status','qa_checked')->where('f.definition->valid_from','<=',$in['date'])->where(fn($q)=>$q->whereNull('f.definition->valid_until')->orWhere('f.definition->valid_until','>=',$in['date']));$max=(clone$eligible)->max(DB::raw("(f.definition->>'priority')::int"));if($max!==null){if((int)$max>(int)$def['priority'])$pending[]='Existe prioridade superior; escolha a regra exacta aplicável.';if((clone$eligible)->whereRaw("(f.definition->>'priority')::int=?",[$max])->count()>1)$pending[]='Conflito de regras com a mesma prioridade e âmbito.';}}
|
||||
if($pending)return ['state'=>'pending','pending'=>$pending,'base'=>$in['base'],'tax_exact'=>null,'tax_document'=>null,'total'=>null,'synthetic_only'=>true,'legal_use'=>false];
|
||||
$result=(new FiscalRuleExpression)->evaluate($def['expression'],['base'=>$in['base']],$def['parameters']);if($result['status']!=='determinate')return ['state'=>'pending','pending'=>$result['missing'],'tax_exact'=>null,'tax_document'=>null,'total'=>null,'synthetic_only'=>true,'legal_use'=>false];
|
||||
$tax=BigRational::of($result['result'])->toBigDecimal();$document=$tax->toScale(2,RoundingMode::Down);$base=BigDecimal::of($in['base']);$representable=$base->stripTrailingZeros()->getScale()<=2;
|
||||
return ['state'=>$representable?'determined_qa':'pending_representation','base'=>(string)$base->stripTrailingZeros(),'tax_exact'=>(string)$tax->stripTrailingZeros(),'tax_document'=>(string)$document,'difference'=>(string)$tax->minus($document)->stripTrailingZeros(),'total'=>$representable?(string)$base->plus($document)->toScale(2):null,'currency'=>$in['currency'],'rule'=>['id'=>$selected['id'],'key'=>$selected['rule_key'],'version'=>$selected['rule_version'],'revision'=>$selected['revision'],'source_reference'=>$def['source_reference'],'source_sha256'=>$def['source_sha256']],'steps'=>$result['steps'],'pending'=>$representable?[]:['Base não representável em duas casas; não truncada.'],'synthetic_only'=>true,'legal_use'=>false];
|
||||
}
|
||||
private function documentInput(array $d):array
|
||||
{
|
||||
$in=$this->exact($d,['rule_event_id'=>'nullable|integer|min:1','currency'=>'required|string|regex:/^[A-Z]{3}$/D','date'=>'required|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31','price_mode'=>'required|in:net_added,gross_included','lines'=>'required|array|min:1|max:25']);
|
||||
foreach($in['lines']as$i=>$line){abort_unless(is_array($line),422);$in['lines'][$i]=$this->exact($line,['reference'=>'required|string|max:80','quantity'=>'required|string|regex:/^\d{1,16}(\.\d{1,8})?$/D','unit_price'=>'required|string|regex:/^\d{1,16}(\.\d{1,8})?$/D']);abort_unless(BigDecimal::of($line['quantity'])->isGreaterThan(0),422,'Quantidade positiva obrigatória.');}abort_unless(array_is_list($in['lines'])&&count(array_unique(array_column($in['lines'],'reference')))===count($in['lines']),422,'Linhas sequenciais e referências distintas obrigatórias.');return $in;
|
||||
}
|
||||
private function evaluateDocument(array $s,array $in,?array $selected):array
|
||||
{
|
||||
$gate=$this->evaluate($s,['base'=>'0','currency'=>$in['currency'],'date'=>$in['date']],$selected);if($gate['state']!=='determined_qa'){unset($gate['base']);return $gate+['policy'=>FiscalDocumentMath::POLICY,'price_mode'=>$in['price_mode'],'base_document'=>null,'lines'=>[]];}
|
||||
return FiscalDocumentMath::calculate($in['lines'],$in['price_mode'],$selected['definition']['parameters']['rate'])+['currency'=>$in['currency'],'rule'=>$gate['rule']];
|
||||
}
|
||||
/** Explicit read-only QA bridge; does not change the module required by rule administration. */
|
||||
public function invoiceDocument(int $actor,array $scope,int $event,array $input,?int $boundCalculation=null):array
|
||||
{
|
||||
abort_unless(config('multiline_invoices.enabled')===true&&config('fiscal_rules_qa.enabled')===true,404);CompositionRuntime::assert();$actual=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$actual===$scope&&ModuleRecords::active()==='commercial',403);if(!$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'fiscal_rules.view',$scope['unit_id'])){
|
||||
// Invoice readers cannot enumerate/administer the rule catalogue. Only revalidate
|
||||
// a calculation already captured for this exact draft/input by a fiscal preparer.
|
||||
$this->permissions->require($actor,$scope['tenant_id'],$scope['company_id'],'billing_drafts.view',$scope['unit_id']);$row=$boundCalculation?DB::table('mi_calculations')->where($scope)->where('id',$boundCalculation)->first():null;abort_unless($row,403,'Regra exige captura documental previamente autorizada.');$frozen=json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR);$expected=$frozen['common']['input'];unset($expected['rule_event_id']);abort_unless((int)$frozen['common']['rule']['id']===$event&&$this->hash($expected)===$this->hash($input),409,'Entrada/data da captura fiscal mudou.');
|
||||
}$input['rule_event_id']=$event;$in=$this->documentInput($input);$row=$this->events($scope)->where('id',$event)->first();abort_unless($row,403,'Regra fora do contexto.');$selected=$this->row($row);$this->fixture();return ['rule'=>$selected,'result'=>$this->evaluateDocument($scope,$in,$selected),'input'=>$in,'rule_stream'=>['count'=>$this->events($scope)->count(),'last_id'=>$this->events($scope)->max('id')]];
|
||||
}
|
||||
public function documentReport(int $actor,int $id):array
|
||||
{
|
||||
$detail=$this->detail($actor,$id,'calculation');$r=$detail['result'];abort_unless(isset($r['price_mode'],$r['lines']),422,'Este registo não é cálculo por linhas.');$fp=fopen('php://temp','w+');$rule=$r['rule']??[];$rows=[['MAPA INTERNO POR LINHAS QA — SEM VALIDADE FISCAL'],['Politica',$r['policy']],['Modo',$r['price_mode']],['Data',$detail['input']['date']],['Moeda',$detail['input']['currency']],['Regra escolhida',(string)($detail['input']['rule_event_id']??'INDETERMINADA')],['Versao regra',(string)($rule['version']??'INDETERMINADA')],['Fonte',$rule['source_reference']??'INDETERMINADA'],['SHA fonte',$rule['source_sha256']??'INDETERMINADO'],['Estado historico',$r['state']],['Estado actual',$detail['current_evaluation']['state']],['Linha','Referencia','Base exacta fraccao','Imposto exacto fraccao','Base documental','Imposto documental','Total','Diferenca base fraccao','Diferenca imposto fraccao']];foreach($r['lines']as$line){$ref="'".$line['reference'];$rows[]=[(string)$line['line'],$ref,$line['base_exact_fraction'],$line['tax_exact_fraction'],$line['base_document']??'INDETERMINADO',$line['tax_document'],$line['total_document']??'INDETERMINADO',$line['base_difference_fraction']??'INDETERMINADO',$line['tax_difference_fraction']];}$rows[]=['Totais documentais','','','',$r['base_document']??'INDETERMINADO',$r['tax_document']??'INDETERMINADO',$r['total']??'INDETERMINADO'];foreach($rows as$row)fputcsv($fp,$row,';','"','',"\r\n");rewind($fp);$bytes=stream_get_contents($fp);$s=$this->scope($actor);Audit::record('fiscal_rule.document_report',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>0,'review_hash'=>hash('sha256',$bytes)]);return ['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes)];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=$this->exact($input,['view'=>'sometimes|in:rules,history,calculations','rule_key'=>'sometimes|string|regex:/^[A-Z0-9_-]{3,80}$/D','family'=>'sometimes|in:qa,iva,irps,irpc,ispc,ice,inss,other','state'=>'sometimes|in:prepared,reviewed,refused,withdrawn','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$view=$d['view']??'rules';abort_if($view==='history'&&!isset($d['rule_key']),422);abort_if($view==='calculations'&&array_intersect(array_keys($d),['rule_key','family','state','search']),422);
|
||||
return DB::transaction(function()use($actor,$s,$d,$view){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$view==='calculations'?DB::table('fr_calculations')->where($s):($view==='history'?$this->events($s):$this->currentVersions($s));$alias=$view==='rules'?'f.':'';if(isset($d['rule_key']))$q->where($alias.'rule_key',$d['rule_key']);foreach(['family'=>'definition->family','state'=>'state']as$filter=>$column)if(isset($d[$filter]))$q->where($alias.$column,$d[$filter]);if(!empty($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($w)=>$w->whereRaw($alias."rule_key ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw($alias."definition->>'label' ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw($alias."definition->>'source_reference' ILIKE ? ESCAPE '!'",[$needle]));}$page=$q->orderByDesc($alias.'id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$rows=$page->getCollection()->map(fn($r)=>$this->row($r));$ids=$rows->pluck('id')->all();$refs=DB::table('fr_commands')->where($s)->where('actor_id',$actor)->where('result->type',$view==='calculations'?'calculation':'rule')->whereIn(DB::raw("(result->>'id')::bigint"),$ids)->get();$page->setCollection($rows->map(function($r)use($refs){$r['command_reference']=null;foreach($refs as$ref)if(json_decode($ref->result,true)['id']===$r['id'])$r['command_reference']=$ref->command_key;return $r;}));return ['document_enabled'=>config('fiscal_rules_qa.document_enabled')===true,'stream_revision'=>isset($d['rule_key'])?(int)$this->events($s)->where('rule_key',$d['rule_key'])->max('revision'):null,'contract'=>self::CONTRACT,'view'=>$view,'records'=>$page->toArray(),'fixtures'=>$this->fixture(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_rules.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_rules.review',$s['unit_id'])],'synthetic_only'=>true,'legal_activation'=>false];});
|
||||
}
|
||||
public function detail(int $actor,int $id,string $type='rule'):array
|
||||
{
|
||||
$s=$this->scope($actor);$row=DB::table($type==='calculation'?'fr_calculations':'fr_events')->where($s)->where('id',$id)->first();abort_unless($row,404);$a=$this->row($row);if($type==='rule'){$a['current_revision']=(int)$this->events($s)->where('rule_key',$row->rule_key)->max('revision');$a['current_version_event_id']=(int)$this->events($s)->where(['rule_key'=>$row->rule_key,'rule_version'=>$row->rule_version])->orderByDesc('revision')->value('id');}else{$selected=isset($a['input']['rule_event_id'])?$this->events($s)->where('id',$a['input']['rule_event_id'])->first():null;$a['current_evaluation']=isset($a['input']['lines'])?$this->evaluateDocument($s,$a['input'],$selected?$this->row($selected):null):$this->evaluate($s,$a['input'],$selected?$this->row($selected):null);$a['historical_snapshot_unchanged']=true;}return $a;
|
||||
}
|
||||
public function receipt(int $actor,string $key):array
|
||||
{
|
||||
$s=$this->scope($actor);abort_unless(preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D',$key),422);$r=DB::table('fr_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($r,404);return ['result'=>json_decode($r->result,true),'created_at'=>$r->created_at,'command_key'=>$key,'historical_receipt_only'=>true,'operation_executed'=>false];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user